From dcb14503a259c6528eb825a070e40818d85df80f Mon Sep 17 00:00:00 2001 From: poduck Date: Wed, 3 Dec 2025 13:02:44 -0500 Subject: [PATCH] feat: Dashboard redesign, plan permissions, and help docs improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major updates including: - Customizable dashboard with drag-and-drop widget grid layout - Plan-based feature locking for plugins and tasks - Comprehensive help documentation updates across all pages - Plugin seeding in deployment process for all tenants - Permission synchronization system for subscription plans - QuotaOverageModal component and enhanced UX flows 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- PLAN_HELP_DOCS.md | 179 ++++ deploy.sh | 11 + frontend/package-lock.json | 68 ++ frontend/package.json | 2 + frontend/src/App.tsx | 6 +- frontend/src/components/QuotaOverageModal.tsx | 268 ++++++ frontend/src/components/Sidebar.tsx | 3 +- .../components/dashboard/CapacityWidget.tsx | 140 +++ .../src/components/dashboard/ChartWidget.tsx | 105 +++ .../dashboard/CustomerBreakdownWidget.tsx | 134 +++ .../src/components/dashboard/MetricWidget.tsx | 90 ++ .../components/dashboard/NoShowRateWidget.tsx | 144 +++ .../dashboard/OpenTicketsWidget.tsx | 121 +++ .../dashboard/RecentActivityWidget.tsx | 144 +++ .../dashboard/WidgetConfigModal.tsx | 131 +++ frontend/src/components/dashboard/index.ts | 9 + frontend/src/components/dashboard/types.ts | 146 +++ frontend/src/hooks/usePlanFeatures.ts | 2 + frontend/src/hooks/usePlatformSettings.ts | 12 + frontend/src/hooks/useScrollToTop.ts | 15 +- frontend/src/index.css | 93 ++ frontend/src/layouts/BusinessLayout.tsx | 2 +- frontend/src/layouts/CustomerLayout.tsx | 7 +- frontend/src/layouts/ManagerLayout.tsx | 7 +- frontend/src/layouts/PlatformLayout.tsx | 7 +- frontend/src/pages/Dashboard.tsx | 527 ++++++++--- frontend/src/pages/HelpGuide.tsx | 4 +- frontend/src/pages/MyPlugins.tsx | 11 +- frontend/src/pages/OwnerScheduler.tsx | 34 +- frontend/src/pages/PluginMarketplace.tsx | 11 +- frontend/src/pages/Tasks.tsx | 12 +- frontend/src/pages/help/HelpComprehensive.tsx | 829 ++++++++++++++++++ frontend/src/pages/help/HelpCreatePlugin.tsx | 189 ---- frontend/src/pages/help/HelpDashboard.tsx | 258 ++++-- .../src/pages/{ => help}/HelpPluginDocs.tsx | 0 frontend/src/pages/help/HelpPlugins.tsx | 205 +++-- frontend/src/pages/help/HelpScheduler.tsx | 16 +- frontend/src/pages/help/HelpSettingsApi.tsx | 373 ++++++-- .../src/pages/help/HelpSettingsAppearance.tsx | 326 ++++++- frontend/src/pages/help/HelpSettingsAuth.tsx | 367 ++++++-- .../src/pages/help/HelpSettingsBilling.tsx | 352 ++++++-- .../src/pages/help/HelpSettingsBooking.tsx | 281 ++++-- .../src/pages/help/HelpSettingsDomains.tsx | 397 +++++++-- frontend/src/pages/help/HelpSettingsEmail.tsx | 331 +++++-- .../src/pages/help/HelpSettingsGeneral.tsx | 252 +++++- frontend/src/pages/help/HelpSettingsQuota.tsx | 380 ++++++-- .../pages/help/HelpSettingsResourceTypes.tsx | 293 +++++-- .../src/pages/platform/PlatformSettings.tsx | 91 ++ .../platform/components/BusinessEditModal.tsx | 394 ++++----- frontend/src/types.ts | 1 + smoothschedule/core/apps.py | 4 +- .../migrations/0021_add_can_use_plugins.py | 23 + .../core/migrations/0022_add_can_use_tasks.py | 18 + smoothschedule/core/models.py | 10 +- smoothschedule/core/signals.py | 67 ++ smoothschedule/payments/views.py | 35 +- smoothschedule/platform_admin/apps.py | 4 + smoothschedule/platform_admin/serializers.py | 38 + smoothschedule/platform_admin/signals.py | 28 + smoothschedule/platform_admin/tasks.py | 139 +++ smoothschedule/platform_admin/views.py | 26 + smoothschedule/schedule/api_views.py | 3 +- .../commands/seed_platform_plugins.py | 189 ++-- smoothschedule/schedule/views.py | 152 +++- .../smoothschedule/comms_credits/views.py | 7 + .../smoothschedule/public_api/views.py | 43 +- 66 files changed, 7099 insertions(+), 1467 deletions(-) create mode 100644 PLAN_HELP_DOCS.md create mode 100644 frontend/src/components/QuotaOverageModal.tsx create mode 100644 frontend/src/components/dashboard/CapacityWidget.tsx create mode 100644 frontend/src/components/dashboard/ChartWidget.tsx create mode 100644 frontend/src/components/dashboard/CustomerBreakdownWidget.tsx create mode 100644 frontend/src/components/dashboard/MetricWidget.tsx create mode 100644 frontend/src/components/dashboard/NoShowRateWidget.tsx create mode 100644 frontend/src/components/dashboard/OpenTicketsWidget.tsx create mode 100644 frontend/src/components/dashboard/RecentActivityWidget.tsx create mode 100644 frontend/src/components/dashboard/WidgetConfigModal.tsx create mode 100644 frontend/src/components/dashboard/index.ts create mode 100644 frontend/src/components/dashboard/types.ts create mode 100644 frontend/src/pages/help/HelpComprehensive.tsx delete mode 100644 frontend/src/pages/help/HelpCreatePlugin.tsx rename frontend/src/pages/{ => help}/HelpPluginDocs.tsx (100%) create mode 100644 smoothschedule/core/migrations/0021_add_can_use_plugins.py create mode 100644 smoothschedule/core/migrations/0022_add_can_use_tasks.py create mode 100644 smoothschedule/core/signals.py create mode 100644 smoothschedule/platform_admin/signals.py diff --git a/PLAN_HELP_DOCS.md b/PLAN_HELP_DOCS.md new file mode 100644 index 0000000..078c1ea --- /dev/null +++ b/PLAN_HELP_DOCS.md @@ -0,0 +1,179 @@ +# Help Documentation Implementation Plan + +## Overview +This plan covers creating comprehensive help documentation for the SmoothSchedule business dashboard, adding contextual help buttons to each page, and creating a monolithic help document. + +## Phase 1: Create Plugin Page First (User Request) + +### Task 1.1: Create CreatePlugin.tsx Page +- Create `/frontend/src/pages/CreatePlugin.tsx` +- Features: + - Name, description, short description fields + - Category dropdown (EMAIL, REPORTS, CUSTOMER, BOOKING, INTEGRATION, AUTOMATION, OTHER) + - Plugin code editor with syntax highlighting (using same Prism setup as HelpPluginDocs) + - Template variables preview (auto-extracted from code) + - Version field (default 1.0.0) + - Logo URL field (optional) + - Save as Private / Submit to Marketplace options + - Visibility selector (PRIVATE, PUBLIC) +- Uses API endpoint: `POST /api/plugin-templates/` +- Plan feature gate: `can_create_plugins` + +### Task 1.2: Add Route for CreatePlugin +- Add lazy import: `const CreatePlugin = React.lazy(() => import('./pages/CreatePlugin'));` +- Add route: `/plugins/create` pointing to CreatePlugin component + +## Phase 2: Create Reusable HelpButton Component + +### Task 2.1: Create HelpButton Component +- Create `/frontend/src/components/HelpButton.tsx` +- Props: `helpPath: string` (route to help page) +- Renders: HelpCircle icon button at fixed position (top-right of page) +- Styling: Circular button with question mark icon, tooltip on hover +- Uses Link from react-router-dom to navigate to help page + +## Phase 3: Create Individual Help Pages + +### 3.1 Core Pages Help +| Page | Help File | Route | +|------|-----------|-------| +| Dashboard | HelpDashboard.tsx | /help/dashboard | +| Scheduler | HelpScheduler.tsx | /help/scheduler | +| Tasks | HelpTasks.tsx | /help/tasks | + +### 3.2 Manage Section Help +| Page | Help File | Route | +|------|-----------|-------| +| Customers | HelpCustomers.tsx | /help/customers | +| Services | HelpServices.tsx | /help/services | +| Resources | HelpResources.tsx | /help/resources | +| Staff | HelpStaff.tsx | /help/staff | + +### 3.3 Communicate Section Help +| Page | Help File | Route | +|------|-----------|-------| +| Messages | HelpMessages.tsx | /help/messages | +| Tickets | HelpTicketing.tsx (exists) | /help/ticketing | + +### 3.4 Money Section Help +| Page | Help File | Route | +|------|-----------|-------| +| Payments | HelpPayments.tsx | /help/payments | + +### 3.5 Extend Section Help +| Page | Help File | Route | +|------|-----------|-------| +| Plugins | HelpPluginsOverview.tsx | /help/plugins-overview | +| Plugin Marketplace | (link to existing HelpPluginDocs) | /help/plugins | +| My Plugins | HelpMyPlugins.tsx | /help/my-plugins | +| Create Plugin | HelpCreatePlugin.tsx | /help/create-plugin | + +### 3.6 Settings Section Help +| Page | Help File | Route | +|------|-----------|-------| +| General | HelpSettingsGeneral.tsx | /help/settings/general | +| Resource Types | HelpSettingsResourceTypes.tsx | /help/settings/resource-types | +| Booking | HelpSettingsBooking.tsx | /help/settings/booking | +| Appearance | HelpSettingsAppearance.tsx | /help/settings/appearance | +| Email Templates | HelpSettingsEmailTemplates.tsx | /help/settings/email-templates | +| Custom Domains | HelpSettingsCustomDomains.tsx | /help/settings/custom-domains | +| API & Webhooks | HelpSettingsApi.tsx | /help/settings/api | +| Authentication | HelpSettingsAuth.tsx | /help/settings/authentication | +| Email Setup | HelpEmailSettings.tsx (exists) | /help/email-settings | +| SMS & Calling | HelpSettingsSmsCalling.tsx | /help/settings/sms-calling | +| Plan & Billing | HelpSettingsBilling.tsx | /help/settings/billing | +| Quota Management | HelpSettingsQuota.tsx | /help/settings/quota | + +## Phase 4: Add HelpButton to Each Page + +Add the HelpButton component to the top-right of each dashboard page, linking to its corresponding help page. + +## Phase 5: Update HelpPluginDocs + +### Task 5.1: Review and Update Plugin Documentation +- Verify plugin documentation matches current codebase +- Add section for "Creating Custom Plugins" +- Add links to API documentation +- Ensure examples work with current API + +## Phase 6: Create Monolithic Help Document + +### Task 6.1: Create HelpGuideComplete.tsx +- Compile all help content into single comprehensive page +- Table of contents with anchor links +- Searchable content +- Organized by sections (Core, Manage, Communicate, Money, Extend, Settings) + +### Task 6.2: Update HelpGuide.tsx +- Replace "Coming Soon" with actual compiled documentation +- Or redirect to HelpGuideComplete + +## Phase 7: Register All Routes + +Add all new help page routes to App.tsx in the business dashboard section. + +## Help Page Template Structure + +Each help page should follow this structure: +```tsx +- Header with icon and title +- Overview/Introduction +- Key Features section +- How to Use section (step-by-step) +- Benefits section +- Tips & Best Practices +- Related Features (links to other help pages) +- Need More Help? (link to support/tickets) +``` + +## Implementation Order + +1. Create CreatePlugin.tsx page and route +2. Create HelpButton component +3. Create help pages for core pages (Dashboard, Scheduler, Tasks) +4. Create help pages for Manage section +5. Create help pages for Communicate section +6. Create help pages for Money section +7. Create help pages for Extend section (including plugin docs update) +8. Create help pages for Settings section +9. Add HelpButton to all pages +10. Create monolithic help document +11. Test all help pages and navigation + +## Files to Create + +### New Components +- `/frontend/src/components/HelpButton.tsx` + +### New Pages +- `/frontend/src/pages/CreatePlugin.tsx` +- `/frontend/src/pages/help/HelpDashboard.tsx` +- `/frontend/src/pages/help/HelpScheduler.tsx` +- `/frontend/src/pages/help/HelpTasks.tsx` +- `/frontend/src/pages/help/HelpCustomers.tsx` +- `/frontend/src/pages/help/HelpServices.tsx` +- `/frontend/src/pages/help/HelpResources.tsx` +- `/frontend/src/pages/help/HelpStaff.tsx` +- `/frontend/src/pages/help/HelpMessages.tsx` +- `/frontend/src/pages/help/HelpPayments.tsx` +- `/frontend/src/pages/help/HelpPluginsOverview.tsx` +- `/frontend/src/pages/help/HelpMyPlugins.tsx` +- `/frontend/src/pages/help/HelpCreatePlugin.tsx` +- `/frontend/src/pages/help/HelpSettingsGeneral.tsx` +- `/frontend/src/pages/help/HelpSettingsResourceTypes.tsx` +- `/frontend/src/pages/help/HelpSettingsBooking.tsx` +- `/frontend/src/pages/help/HelpSettingsAppearance.tsx` +- `/frontend/src/pages/help/HelpSettingsEmailTemplates.tsx` +- `/frontend/src/pages/help/HelpSettingsCustomDomains.tsx` +- `/frontend/src/pages/help/HelpSettingsApi.tsx` +- `/frontend/src/pages/help/HelpSettingsAuth.tsx` +- `/frontend/src/pages/help/HelpSettingsSmsCalling.tsx` +- `/frontend/src/pages/help/HelpSettingsBilling.tsx` +- `/frontend/src/pages/help/HelpSettingsQuota.tsx` +- `/frontend/src/pages/help/HelpGuideComplete.tsx` + +### Files to Modify +- `/frontend/src/App.tsx` - Add routes +- `/frontend/src/pages/HelpPluginDocs.tsx` - Update with current codebase info +- `/frontend/src/pages/HelpGuide.tsx` - Replace Coming Soon +- All dashboard pages - Add HelpButton component diff --git a/deploy.sh b/deploy.sh index 15a6bb0..f84abb7 100755 --- a/deploy.sh +++ b/deploy.sh @@ -98,6 +98,17 @@ docker compose -f docker-compose.production.yml exec -T django sh -c 'export DAT echo ">>> Collecting static files..." docker compose -f docker-compose.production.yml exec -T django sh -c 'export DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} && python manage.py collectstatic --noinput' +echo ">>> Seeding/updating platform plugins for all tenants..." +docker compose -f docker-compose.production.yml exec -T django python -c " +from django_tenants.utils import get_tenant_model +from django.core.management import call_command +Tenant = get_tenant_model() +for tenant in Tenant.objects.exclude(schema_name='public'): + print(f' Seeding plugins for {tenant.schema_name}...') + call_command('tenant_command', 'seed_platform_plugins', schema=tenant.schema_name, verbosity=0) +print(' Done!') +" + echo ">>> Checking container status..." docker compose -f docker-compose.production.yml ps diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 96db25a..c668325 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "@stripe/react-stripe-js": "^5.4.1", "@stripe/stripe-js": "^8.5.3", "@tanstack/react-query": "^5.90.10", + "@types/react-grid-layout": "^1.3.6", "@types/react-syntax-highlighter": "^15.5.13", "axios": "^1.13.2", "date-fns": "^4.1.0", @@ -25,6 +26,7 @@ "lucide-react": "^0.554.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-grid-layout": "^1.5.2", "react-hot-toast": "^2.6.0", "react-i18next": "^16.3.5", "react-phone-number-input": "^3.4.14", @@ -1960,6 +1962,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-grid-layout": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.6.tgz", + "integrity": "sha512-Cw7+sb3yyjtmxwwJiXtEXcu5h4cgs+sCGkHwHXsFmPyV30bf14LeD/fa2LwQovuD2HWxCcjIdNhDlcYGj95qGA==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-syntax-highlighter": { "version": "15.5.13", "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", @@ -2922,6 +2933,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4376,6 +4393,38 @@ "react": "^19.2.0" } }, + "node_modules/react-draggable": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz", + "integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.2.tgz", + "integrity": "sha512-vT7xmQqszTT+sQw/LfisrEO4le1EPNnSEMVHy6sBZyzS3yGkMywdOd+5iEFFwQwt0NSaGkxuRmYwa1JsP6OJdw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.0.5", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/react-hot-toast": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", @@ -4477,6 +4526,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-resizable": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz", + "integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.0.3" + }, + "peerDependencies": { + "react": ">= 16.3" + } + }, "node_modules/react-router": { "version": "7.9.6", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz", @@ -4603,6 +4665,12 @@ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "license": "MIT" }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5483988..c83ca2f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "@stripe/react-stripe-js": "^5.4.1", "@stripe/stripe-js": "^8.5.3", "@tanstack/react-query": "^5.90.10", + "@types/react-grid-layout": "^1.3.6", "@types/react-syntax-highlighter": "^15.5.13", "axios": "^1.13.2", "date-fns": "^4.1.0", @@ -21,6 +22,7 @@ "lucide-react": "^0.554.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-grid-layout": "^1.5.2", "react-hot-toast": "^2.6.0", "react-i18next": "^16.3.5", "react-phone-number-input": "^3.4.14", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7ca6eea..1adecd5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -65,7 +65,7 @@ const Tickets = React.lazy(() => import('./pages/Tickets')); // Import Tickets p const HelpGuide = React.lazy(() => import('./pages/HelpGuide')); // Import Platform Guide page const HelpTicketing = React.lazy(() => import('./pages/HelpTicketing')); // Import Help page for ticketing const HelpApiDocs = React.lazy(() => import('./pages/HelpApiDocs')); // Import API documentation page -const HelpPluginDocs = React.lazy(() => import('./pages/HelpPluginDocs')); // Import Plugin documentation page +const HelpPluginDocs = React.lazy(() => import('./pages/help/HelpPluginDocs')); // Import Plugin documentation page const HelpEmailSettings = React.lazy(() => import('./pages/HelpEmailSettings')); // Import Email settings help page // Import new help pages @@ -79,7 +79,6 @@ const HelpStaff = React.lazy(() => import('./pages/help/HelpStaff')); const HelpMessages = React.lazy(() => import('./pages/help/HelpMessages')); const HelpPayments = React.lazy(() => import('./pages/help/HelpPayments')); const HelpPlugins = React.lazy(() => import('./pages/help/HelpPlugins')); -const HelpCreatePlugin = React.lazy(() => import('./pages/help/HelpCreatePlugin')); const HelpSettingsGeneral = React.lazy(() => import('./pages/help/HelpSettingsGeneral')); const HelpSettingsResourceTypes = React.lazy(() => import('./pages/help/HelpSettingsResourceTypes')); const HelpSettingsBooking = React.lazy(() => import('./pages/help/HelpSettingsBooking')); @@ -90,6 +89,7 @@ const HelpSettingsApi = React.lazy(() => import('./pages/help/HelpSettingsApi')) const HelpSettingsAuth = React.lazy(() => import('./pages/help/HelpSettingsAuth')); const HelpSettingsBilling = React.lazy(() => import('./pages/help/HelpSettingsBilling')); const HelpSettingsQuota = React.lazy(() => import('./pages/help/HelpSettingsQuota')); +const HelpComprehensive = React.lazy(() => import('./pages/help/HelpComprehensive')); const PlatformSupport = React.lazy(() => import('./pages/PlatformSupport')); // Import Platform Support page (for businesses to contact SmoothSchedule) const PluginMarketplace = React.lazy(() => import('./pages/PluginMarketplace')); // Import Plugin Marketplace page const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Plugins page @@ -613,6 +613,7 @@ const AppContent: React.FC = () => { /> } /> } /> + } /> } /> } /> } /> @@ -629,7 +630,6 @@ const AppContent: React.FC = () => { } /> } /> } /> - } /> } /> } /> } /> diff --git a/frontend/src/components/QuotaOverageModal.tsx b/frontend/src/components/QuotaOverageModal.tsx new file mode 100644 index 0000000..264858a --- /dev/null +++ b/frontend/src/components/QuotaOverageModal.tsx @@ -0,0 +1,268 @@ +/** + * QuotaOverageModal Component + * + * Modal that appears on login/masquerade when the tenant has exceeded quotas. + * Shows warning about grace period and what will happen when it expires. + * Uses sessionStorage to only show once per session. + */ + +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { + AlertTriangle, + X, + Clock, + Archive, + ChevronRight, + Users, + Layers, + Briefcase, + Mail, + Zap, +} from 'lucide-react'; +import { QuotaOverage } from '../api/auth'; + +interface QuotaOverageModalProps { + overages: QuotaOverage[]; + onDismiss: () => void; +} + +const QUOTA_ICONS: Record = { + 'MAX_ADDITIONAL_USERS': , + 'MAX_RESOURCES': , + 'MAX_SERVICES': , + 'MAX_EMAIL_TEMPLATES': , + 'MAX_AUTOMATED_TASKS': , +}; + +const SESSION_STORAGE_KEY = 'quota_overage_modal_dismissed'; + +const QuotaOverageModal: React.FC = ({ overages, onDismiss }) => { + const { t } = useTranslation(); + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + // Check if already dismissed this session + const dismissed = sessionStorage.getItem(SESSION_STORAGE_KEY); + if (!dismissed && overages && overages.length > 0) { + setIsVisible(true); + } + }, [overages]); + + const handleDismiss = () => { + sessionStorage.setItem(SESSION_STORAGE_KEY, 'true'); + setIsVisible(false); + onDismiss(); + }; + + if (!isVisible || !overages || overages.length === 0) { + return null; + } + + // Find the most urgent overage (least days remaining) + const mostUrgent = overages.reduce((prev, curr) => + curr.days_remaining < prev.days_remaining ? curr : prev + ); + + const isCritical = mostUrgent.days_remaining <= 1; + const isUrgent = mostUrgent.days_remaining <= 7; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + }; + + return ( +
+
+ {/* Header */} +
+
+
+
+ +
+
+

+ {isCritical + ? t('quota.modal.titleCritical', 'Action Required Immediately!') + : isUrgent + ? t('quota.modal.titleUrgent', 'Action Required Soon') + : t('quota.modal.title', 'Quota Exceeded') + } +

+

+ {mostUrgent.days_remaining <= 0 + ? t('quota.modal.subtitleExpired', 'Grace period has expired') + : mostUrgent.days_remaining === 1 + ? t('quota.modal.subtitleOneDay', '1 day remaining') + : t('quota.modal.subtitle', '{{days}} days remaining', { days: mostUrgent.days_remaining }) + } +

+
+
+ +
+
+ + {/* Body */} +
+ {/* Main message */} +
+ +
+

+ {t('quota.modal.gracePeriodEnds', 'Grace period ends on {{date}}', { + date: formatDate(mostUrgent.grace_period_ends_at) + })} +

+

+ {t('quota.modal.explanation', + 'Your account has exceeded its plan limits. Please remove or archive excess items before the grace period ends, or they will be automatically archived.' + )} +

+
+
+ + {/* Overage list */} +
+

+ {t('quota.modal.overagesTitle', 'Items Over Quota')} +

+
+ {overages.map((overage) => ( +
+
+
+ {QUOTA_ICONS[overage.quota_type] || } +
+
+

+ {overage.display_name} +

+

+ {t('quota.modal.usageInfo', '{{current}} used / {{limit}} allowed', { + current: overage.current_usage, + limit: overage.allowed_limit + })} +

+
+
+
+

+ +{overage.overage_amount} +

+

+ {t('quota.modal.overLimit', 'over limit')} +

+
+
+ ))} +
+
+ + {/* What happens section */} +
+ +
+

+ {t('quota.modal.whatHappens', 'What happens if I don\'t take action?')} +

+

+ {t('quota.modal.autoArchiveExplanation', + 'After the grace period ends, the oldest items over your limit will be automatically archived. Archived items remain in your account but cannot be used until you upgrade or remove other items.' + )} +

+
+
+
+ + {/* Footer */} +
+ + + {t('quota.modal.manageButton', 'Manage Quota')} + + +
+
+
+ ); +}; + +export default QuotaOverageModal; + +/** + * Clear the session storage dismissal flag + * Call this when user logs out or masquerade changes + */ +export const resetQuotaOverageModalDismissal = () => { + sessionStorage.removeItem(SESSION_STORAGE_KEY); +}; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index c2300fe..185bcb2 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -119,6 +119,7 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo icon={Clock} label={t('nav.tasks', 'Tasks')} isCollapsed={isCollapsed} + locked={!canUse('plugins') || !canUse('tasks')} /> @@ -193,7 +194,7 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo {canViewAdminPages && ( void; +} + +const CapacityWidget: React.FC = ({ + appointments, + resources, + isEditing, + onRemove, +}) => { + const capacityData = useMemo(() => { + const now = new Date(); + const weekStart = startOfWeek(now, { weekStartsOn: 1 }); + const weekEnd = endOfWeek(now, { weekStartsOn: 1 }); + + // Calculate for each resource + const resourceStats = resources.map((resource) => { + // Filter appointments for this resource this week + const resourceAppointments = appointments.filter( + (appt) => + appt.resourceId === resource.id && + isWithinInterval(new Date(appt.startTime), { start: weekStart, end: weekEnd }) && + appt.status !== 'CANCELLED' + ); + + // Calculate total booked minutes + const bookedMinutes = resourceAppointments.reduce( + (sum, appt) => sum + appt.durationMinutes, + 0 + ); + + // Assume 8 hours/day, 5 days/week = 2400 minutes capacity + const totalCapacityMinutes = 8 * 60 * 5; + const utilization = Math.min((bookedMinutes / totalCapacityMinutes) * 100, 100); + + return { + id: resource.id, + name: resource.name, + utilization: Math.round(utilization), + bookedHours: Math.round(bookedMinutes / 60), + }; + }); + + // Calculate overall utilization + const totalBooked = resourceStats.reduce((sum, r) => sum + r.bookedHours, 0); + const totalCapacity = resources.length * 40; // 40 hours/week per resource + const overallUtilization = totalCapacity > 0 ? Math.round((totalBooked / totalCapacity) * 100) : 0; + + return { + overall: overallUtilization, + resources: resourceStats.sort((a, b) => b.utilization - a.utilization), + }; + }, [appointments, resources]); + + const getUtilizationColor = (utilization: number) => { + if (utilization >= 80) return 'bg-green-500'; + if (utilization >= 50) return 'bg-yellow-500'; + return 'bg-gray-300 dark:bg-gray-600'; + }; + + const getUtilizationTextColor = (utilization: number) => { + if (utilization >= 80) return 'text-green-600 dark:text-green-400'; + if (utilization >= 50) return 'text-yellow-600 dark:text-yellow-400'; + return 'text-gray-500 dark:text-gray-400'; + }; + + return ( +
+ {isEditing && ( + <> +
+ +
+ + + )} + +
+

+ Capacity This Week +

+
+ + + {capacityData.overall}% + +
+
+ + {capacityData.resources.length === 0 ? ( +
+ +

No resources configured

+
+ ) : ( +
+ {capacityData.resources.map((resource) => ( +
+
+ + + {resource.name} + +
+
+
+
+
+ + {resource.utilization}% + +
+
+ ))} +
+ )} +
+ ); +}; + +export default CapacityWidget; diff --git a/frontend/src/components/dashboard/ChartWidget.tsx b/frontend/src/components/dashboard/ChartWidget.tsx new file mode 100644 index 0000000..39c483d --- /dev/null +++ b/frontend/src/components/dashboard/ChartWidget.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + LineChart, + Line, +} from 'recharts'; +import { GripVertical, X } from 'lucide-react'; + +interface ChartData { + name: string; + value: number; +} + +interface ChartWidgetProps { + title: string; + data: ChartData[]; + type: 'bar' | 'line'; + color?: string; + valuePrefix?: string; + isEditing?: boolean; + onRemove?: () => void; +} + +const ChartWidget: React.FC = ({ + title, + data, + type, + color = '#3b82f6', + valuePrefix = '', + isEditing, + onRemove, +}) => { + const formatValue = (value: number) => `${valuePrefix}${value}`; + + return ( +
+ {isEditing && ( + <> +
+ +
+ + + )} + +

+ {title} +

+ +
+ + {type === 'bar' ? ( + + + + + [formatValue(value), title]} + /> + + + ) : ( + + + + + [value, title]} + /> + + + )} + +
+
+ ); +}; + +export default ChartWidget; diff --git a/frontend/src/components/dashboard/CustomerBreakdownWidget.tsx b/frontend/src/components/dashboard/CustomerBreakdownWidget.tsx new file mode 100644 index 0000000..d0975ac --- /dev/null +++ b/frontend/src/components/dashboard/CustomerBreakdownWidget.tsx @@ -0,0 +1,134 @@ +import React, { useMemo } from 'react'; +import { GripVertical, X, Users, UserPlus, UserCheck } from 'lucide-react'; +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'; +import { Customer } from '../../types'; + +interface CustomerBreakdownWidgetProps { + customers: Customer[]; + isEditing?: boolean; + onRemove?: () => void; +} + +const CustomerBreakdownWidget: React.FC = ({ + customers, + isEditing, + onRemove, +}) => { + const breakdownData = useMemo(() => { + // Customers with lastVisit are returning, without are new + const returning = customers.filter((c) => c.lastVisit !== null).length; + const newCustomers = customers.filter((c) => c.lastVisit === null).length; + const total = customers.length; + + return { + new: newCustomers, + returning, + total, + newPercentage: total > 0 ? Math.round((newCustomers / total) * 100) : 0, + returningPercentage: total > 0 ? Math.round((returning / total) * 100) : 0, + chartData: [ + { name: 'New', value: newCustomers, color: '#8b5cf6' }, + { name: 'Returning', value: returning, color: '#10b981' }, + ], + }; + }, [customers]); + + return ( +
+ {isEditing && ( + <> +
+ +
+ + + )} + +

+ Customers This Month +

+ +
+ {/* Pie Chart */} +
+ + + + {breakdownData.chartData.map((entry, index) => ( + + ))} + + + + +
+ + {/* Stats */} +
+
+
+ +
+
+

New

+

+ {breakdownData.new}{' '} + + ({breakdownData.newPercentage}%) + +

+
+
+ +
+
+ +
+
+

Returning

+

+ {breakdownData.returning}{' '} + + ({breakdownData.returningPercentage}%) + +

+
+
+
+
+ +
+
+
+ + Total Customers +
+ {breakdownData.total} +
+
+
+ ); +}; + +export default CustomerBreakdownWidget; diff --git a/frontend/src/components/dashboard/MetricWidget.tsx b/frontend/src/components/dashboard/MetricWidget.tsx new file mode 100644 index 0000000..f8cbf93 --- /dev/null +++ b/frontend/src/components/dashboard/MetricWidget.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { TrendingUp, TrendingDown, Minus, GripVertical, X } from 'lucide-react'; + +interface GrowthData { + weekly: { value: number; change: number }; + monthly: { value: number; change: number }; +} + +interface MetricWidgetProps { + title: string; + value: number | string; + growth: GrowthData; + icon?: React.ReactNode; + isEditing?: boolean; + onRemove?: () => void; +} + +const MetricWidget: React.FC = ({ + title, + value, + growth, + icon, + isEditing, + onRemove, +}) => { + const formatChange = (change: number) => { + if (change === 0) return '0%'; + return change > 0 ? `+${change.toFixed(1)}%` : `${change.toFixed(1)}%`; + }; + + const getTrendIcon = (change: number) => { + if (change > 0) return ; + if (change < 0) return ; + return ; + }; + + const getTrendClass = (change: number) => { + if (change > 0) return 'text-green-700 bg-green-50 dark:bg-green-900/30 dark:text-green-400'; + if (change < 0) return 'text-red-700 bg-red-50 dark:bg-red-900/30 dark:text-red-400'; + return 'text-gray-700 bg-gray-50 dark:bg-gray-700 dark:text-gray-300'; + }; + + return ( +
+ {isEditing && ( + <> +
+ +
+ + + )} + +
+
+ {icon && {icon}} +

{title}

+
+ +
+ {value} +
+ +
+
+ Week: + + {getTrendIcon(growth.weekly.change)} + {formatChange(growth.weekly.change)} + +
+
+ Month: + + {getTrendIcon(growth.monthly.change)} + {formatChange(growth.monthly.change)} + +
+
+
+
+ ); +}; + +export default MetricWidget; diff --git a/frontend/src/components/dashboard/NoShowRateWidget.tsx b/frontend/src/components/dashboard/NoShowRateWidget.tsx new file mode 100644 index 0000000..102f17f --- /dev/null +++ b/frontend/src/components/dashboard/NoShowRateWidget.tsx @@ -0,0 +1,144 @@ +import React, { useMemo } from 'react'; +import { GripVertical, X, UserX, TrendingUp, TrendingDown, Minus } from 'lucide-react'; +import { Appointment } from '../../types'; +import { subDays, subMonths, isAfter } from 'date-fns'; + +interface NoShowRateWidgetProps { + appointments: Appointment[]; + isEditing?: boolean; + onRemove?: () => void; +} + +const NoShowRateWidget: React.FC = ({ + appointments, + isEditing, + onRemove, +}) => { + const noShowData = useMemo(() => { + const now = new Date(); + const oneWeekAgo = subDays(now, 7); + const twoWeeksAgo = subDays(now, 14); + const oneMonthAgo = subMonths(now, 1); + const twoMonthsAgo = subMonths(now, 2); + + // Calculate rates for different periods + const calculateRate = (appts: Appointment[]) => { + const completed = appts.filter( + (a) => a.status === 'COMPLETED' || a.status === 'NO_SHOW' || a.status === 'CANCELLED' + ); + const noShows = completed.filter((a) => a.status === 'NO_SHOW'); + return completed.length > 0 ? (noShows.length / completed.length) * 100 : 0; + }; + + // Current week + const thisWeekAppts = appointments.filter((a) => isAfter(new Date(a.startTime), oneWeekAgo)); + const currentWeekRate = calculateRate(thisWeekAppts); + + // Last week + const lastWeekAppts = appointments.filter( + (a) => isAfter(new Date(a.startTime), twoWeeksAgo) && !isAfter(new Date(a.startTime), oneWeekAgo) + ); + const lastWeekRate = calculateRate(lastWeekAppts); + + // Current month + const thisMonthAppts = appointments.filter((a) => isAfter(new Date(a.startTime), oneMonthAgo)); + const currentMonthRate = calculateRate(thisMonthAppts); + + // Last month + const lastMonthAppts = appointments.filter( + (a) => isAfter(new Date(a.startTime), twoMonthsAgo) && !isAfter(new Date(a.startTime), oneMonthAgo) + ); + const lastMonthRate = calculateRate(lastMonthAppts); + + // Calculate changes (negative is good for no-show rate) + const weeklyChange = lastWeekRate !== 0 ? ((currentWeekRate - lastWeekRate) / lastWeekRate) * 100 : 0; + const monthlyChange = lastMonthRate !== 0 ? ((currentMonthRate - lastMonthRate) / lastMonthRate) * 100 : 0; + + // Count total no-shows this month + const noShowsThisMonth = thisMonthAppts.filter((a) => a.status === 'NO_SHOW').length; + + return { + currentRate: currentMonthRate, + noShowCount: noShowsThisMonth, + weeklyChange, + monthlyChange, + }; + }, [appointments]); + + const formatChange = (change: number) => { + if (change === 0) return '0%'; + return change > 0 ? `+${change.toFixed(1)}%` : `${change.toFixed(1)}%`; + }; + + // For no-show rate, down is good (green), up is bad (red) + const getTrendIcon = (change: number) => { + if (change < 0) return ; + if (change > 0) return ; + return ; + }; + + const getTrendClass = (change: number) => { + if (change < 0) return 'text-green-700 bg-green-50 dark:bg-green-900/30 dark:text-green-400'; + if (change > 0) return 'text-red-700 bg-red-50 dark:bg-red-900/30 dark:text-red-400'; + return 'text-gray-700 bg-gray-50 dark:bg-gray-700 dark:text-gray-300'; + }; + + const getRateColor = (rate: number) => { + if (rate <= 5) return 'text-green-600 dark:text-green-400'; + if (rate <= 10) return 'text-yellow-600 dark:text-yellow-400'; + return 'text-red-600 dark:text-red-400'; + }; + + return ( +
+ {isEditing && ( + <> +
+ +
+ + + )} + +
+
+ +

No-Show Rate

+
+ +
+ + {noShowData.currentRate.toFixed(1)}% + + + ({noShowData.noShowCount} this month) + +
+ +
+
+ Week: + + {getTrendIcon(noShowData.weeklyChange)} + {formatChange(noShowData.weeklyChange)} + +
+
+ Month: + + {getTrendIcon(noShowData.monthlyChange)} + {formatChange(noShowData.monthlyChange)} + +
+
+
+
+ ); +}; + +export default NoShowRateWidget; diff --git a/frontend/src/components/dashboard/OpenTicketsWidget.tsx b/frontend/src/components/dashboard/OpenTicketsWidget.tsx new file mode 100644 index 0000000..374e665 --- /dev/null +++ b/frontend/src/components/dashboard/OpenTicketsWidget.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { GripVertical, X, AlertCircle, Clock, ChevronRight } from 'lucide-react'; +import { Ticket } from '../../types'; +import { formatDistanceToNow } from 'date-fns'; + +interface OpenTicketsWidgetProps { + tickets: Ticket[]; + isEditing?: boolean; + onRemove?: () => void; +} + +const OpenTicketsWidget: React.FC = ({ + tickets, + isEditing, + onRemove, +}) => { + const openTickets = tickets.filter(t => t.status === 'open' || t.status === 'in_progress'); + const urgentCount = openTickets.filter(t => t.priority === 'urgent' || t.isOverdue).length; + + const getPriorityColor = (priority: string, isOverdue?: boolean) => { + if (isOverdue) return 'text-red-600 dark:text-red-400'; + switch (priority) { + case 'urgent': return 'text-red-600 dark:text-red-400'; + case 'high': return 'text-orange-600 dark:text-orange-400'; + case 'medium': return 'text-yellow-600 dark:text-yellow-400'; + default: return 'text-gray-600 dark:text-gray-400'; + } + }; + + const getPriorityBg = (priority: string, isOverdue?: boolean) => { + if (isOverdue) return 'bg-red-50 dark:bg-red-900/20'; + switch (priority) { + case 'urgent': return 'bg-red-50 dark:bg-red-900/20'; + case 'high': return 'bg-orange-50 dark:bg-orange-900/20'; + case 'medium': return 'bg-yellow-50 dark:bg-yellow-900/20'; + default: return 'bg-gray-50 dark:bg-gray-700/50'; + } + }; + + return ( +
+ {isEditing && ( + <> +
+ +
+ + + )} + +
+

+ Open Tickets +

+
+ {urgentCount > 0 && ( + + + {urgentCount} urgent + + )} + + {openTickets.length} open + +
+
+ +
+ {openTickets.length === 0 ? ( +
+ +

No open tickets

+
+ ) : ( + openTickets.slice(0, 5).map((ticket) => ( + +
+
+

+ {ticket.subject} +

+
+ + {ticket.isOverdue ? 'Overdue' : ticket.priority} + + + + {formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })} + +
+
+ +
+ + )) + )} +
+ + {openTickets.length > 5 && ( + + View all {openTickets.length} tickets + + )} +
+ ); +}; + +export default OpenTicketsWidget; diff --git a/frontend/src/components/dashboard/RecentActivityWidget.tsx b/frontend/src/components/dashboard/RecentActivityWidget.tsx new file mode 100644 index 0000000..582f94f --- /dev/null +++ b/frontend/src/components/dashboard/RecentActivityWidget.tsx @@ -0,0 +1,144 @@ +import React, { useMemo } from 'react'; +import { GripVertical, X, Calendar, UserPlus, XCircle, CheckCircle, DollarSign } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; +import { Appointment, Customer } from '../../types'; + +interface ActivityItem { + id: string; + type: 'booking' | 'cancellation' | 'completion' | 'new_customer' | 'payment'; + title: string; + description: string; + timestamp: Date; + icon: React.ReactNode; + iconBg: string; +} + +interface RecentActivityWidgetProps { + appointments: Appointment[]; + customers: Customer[]; + isEditing?: boolean; + onRemove?: () => void; +} + +const RecentActivityWidget: React.FC = ({ + appointments, + customers, + isEditing, + onRemove, +}) => { + const activities = useMemo(() => { + const items: ActivityItem[] = []; + + // Add appointments as activity + appointments.forEach((appt) => { + const timestamp = new Date(appt.startTime); + + if (appt.status === 'CONFIRMED' || appt.status === 'PENDING') { + items.push({ + id: `booking-${appt.id}`, + type: 'booking', + title: 'New Booking', + description: `${appt.customerName} booked an appointment`, + timestamp, + icon: , + iconBg: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400', + }); + } else if (appt.status === 'CANCELLED') { + items.push({ + id: `cancel-${appt.id}`, + type: 'cancellation', + title: 'Cancellation', + description: `${appt.customerName} cancelled their appointment`, + timestamp, + icon: , + iconBg: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400', + }); + } else if (appt.status === 'COMPLETED') { + items.push({ + id: `complete-${appt.id}`, + type: 'completion', + title: 'Completed', + description: `${appt.customerName}'s appointment completed`, + timestamp, + icon: , + iconBg: 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400', + }); + } + }); + + // Add recent customers (those with no lastVisit are new) + customers + .filter(c => !c.lastVisit) + .slice(0, 5) + .forEach((customer) => { + items.push({ + id: `customer-${customer.id}`, + type: 'new_customer', + title: 'New Customer', + description: `${customer.name} signed up`, + timestamp: new Date(), // Approximate - would need createdAt field + icon: , + iconBg: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400', + }); + }); + + // Sort by timestamp descending + items.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); + + return items.slice(0, 10); + }, [appointments, customers]); + + return ( +
+ {isEditing && ( + <> +
+ +
+ + + )} + +

+ Recent Activity +

+ +
+ {activities.length === 0 ? ( +
+ +

No recent activity

+
+ ) : ( +
+ {activities.map((activity) => ( +
+
+ {activity.icon} +
+
+

+ {activity.title} +

+

+ {activity.description} +

+

+ {formatDistanceToNow(activity.timestamp, { addSuffix: true })} +

+
+
+ ))} +
+ )} +
+
+ ); +}; + +export default RecentActivityWidget; diff --git a/frontend/src/components/dashboard/WidgetConfigModal.tsx b/frontend/src/components/dashboard/WidgetConfigModal.tsx new file mode 100644 index 0000000..f4e5957 --- /dev/null +++ b/frontend/src/components/dashboard/WidgetConfigModal.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { X, Plus, Check, LayoutDashboard, BarChart2, Ticket, Activity, Users, UserX, PieChart } from 'lucide-react'; +import { WIDGET_DEFINITIONS, WidgetType } from './types'; + +interface WidgetConfigModalProps { + isOpen: boolean; + onClose: () => void; + activeWidgets: string[]; + onToggleWidget: (widgetId: string) => void; + onResetLayout: () => void; +} + +const WIDGET_ICONS: Record = { + 'appointments-metric': , + 'customers-metric': , + 'services-metric': , + 'resources-metric': , + 'revenue-chart': , + 'appointments-chart': , + 'open-tickets': , + 'recent-activity': , + 'capacity-utilization': , + 'no-show-rate': , + 'customer-breakdown': , +}; + +const WidgetConfigModal: React.FC = ({ + isOpen, + onClose, + activeWidgets, + onToggleWidget, + onResetLayout, +}) => { + if (!isOpen) return null; + + const widgets = Object.values(WIDGET_DEFINITIONS); + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+

+ Configure Dashboard Widgets +

+ +
+ + {/* Content */} +
+

+ Select which widgets to show on your dashboard. You can drag widgets to reposition them. +

+ +
+ {widgets.map((widget) => { + const isActive = activeWidgets.includes(widget.id); + return ( + + ); + })} +
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; + +export default WidgetConfigModal; diff --git a/frontend/src/components/dashboard/index.ts b/frontend/src/components/dashboard/index.ts new file mode 100644 index 0000000..d492b32 --- /dev/null +++ b/frontend/src/components/dashboard/index.ts @@ -0,0 +1,9 @@ +export * from './types'; +export { default as MetricWidget } from './MetricWidget'; +export { default as ChartWidget } from './ChartWidget'; +export { default as OpenTicketsWidget } from './OpenTicketsWidget'; +export { default as RecentActivityWidget } from './RecentActivityWidget'; +export { default as CapacityWidget } from './CapacityWidget'; +export { default as NoShowRateWidget } from './NoShowRateWidget'; +export { default as CustomerBreakdownWidget } from './CustomerBreakdownWidget'; +export { default as WidgetConfigModal } from './WidgetConfigModal'; diff --git a/frontend/src/components/dashboard/types.ts b/frontend/src/components/dashboard/types.ts new file mode 100644 index 0000000..e5a3793 --- /dev/null +++ b/frontend/src/components/dashboard/types.ts @@ -0,0 +1,146 @@ +import { Layout } from 'react-grid-layout'; + +export type WidgetType = + | 'appointments-metric' + | 'customers-metric' + | 'services-metric' + | 'resources-metric' + | 'revenue-chart' + | 'appointments-chart' + | 'open-tickets' + | 'recent-activity' + | 'capacity-utilization' + | 'no-show-rate' + | 'customer-breakdown'; + +export interface WidgetConfig { + id: string; + type: WidgetType; + title: string; + description: string; + defaultSize: { w: number; h: number }; + minSize?: { w: number; h: number }; +} + +export interface DashboardLayout { + widgets: string[]; // Widget IDs that are visible + layout: Layout[]; +} + +// Widget definitions with metadata +export const WIDGET_DEFINITIONS: Record = { + 'appointments-metric': { + id: 'appointments-metric', + type: 'appointments-metric', + title: 'Total Appointments', + description: 'Shows appointment count with weekly and monthly growth', + defaultSize: { w: 3, h: 2 }, + minSize: { w: 2, h: 2 }, + }, + 'customers-metric': { + id: 'customers-metric', + type: 'customers-metric', + title: 'Active Customers', + description: 'Shows customer count with weekly and monthly growth', + defaultSize: { w: 3, h: 2 }, + minSize: { w: 2, h: 2 }, + }, + 'services-metric': { + id: 'services-metric', + type: 'services-metric', + title: 'Services', + description: 'Shows number of services offered', + defaultSize: { w: 3, h: 2 }, + minSize: { w: 2, h: 2 }, + }, + 'resources-metric': { + id: 'resources-metric', + type: 'resources-metric', + title: 'Resources', + description: 'Shows number of resources available', + defaultSize: { w: 3, h: 2 }, + minSize: { w: 2, h: 2 }, + }, + 'revenue-chart': { + id: 'revenue-chart', + type: 'revenue-chart', + title: 'Revenue', + description: 'Weekly revenue bar chart', + defaultSize: { w: 6, h: 4 }, + minSize: { w: 4, h: 3 }, + }, + 'appointments-chart': { + id: 'appointments-chart', + type: 'appointments-chart', + title: 'Appointments Trend', + description: 'Weekly appointments line chart', + defaultSize: { w: 6, h: 4 }, + minSize: { w: 4, h: 3 }, + }, + 'open-tickets': { + id: 'open-tickets', + type: 'open-tickets', + title: 'Open Tickets', + description: 'Shows open support tickets requiring attention', + defaultSize: { w: 4, h: 4 }, + minSize: { w: 3, h: 3 }, + }, + 'recent-activity': { + id: 'recent-activity', + type: 'recent-activity', + title: 'Recent Activity', + description: 'Timeline of recent business events', + defaultSize: { w: 4, h: 5 }, + minSize: { w: 3, h: 3 }, + }, + 'capacity-utilization': { + id: 'capacity-utilization', + type: 'capacity-utilization', + title: 'Capacity Utilization', + description: 'Shows how booked your resources are this week', + defaultSize: { w: 4, h: 4 }, + minSize: { w: 3, h: 3 }, + }, + 'no-show-rate': { + id: 'no-show-rate', + type: 'no-show-rate', + title: 'No-Show Rate', + description: 'Percentage of appointments marked as no-show', + defaultSize: { w: 3, h: 2 }, + minSize: { w: 2, h: 2 }, + }, + 'customer-breakdown': { + id: 'customer-breakdown', + type: 'customer-breakdown', + title: 'New vs Returning', + description: 'Customer breakdown this month', + defaultSize: { w: 4, h: 4 }, + minSize: { w: 3, h: 3 }, + }, +}; + +// Default layout for new users +export const DEFAULT_LAYOUT: DashboardLayout = { + widgets: [ + 'appointments-metric', + 'customers-metric', + 'no-show-rate', + 'revenue-chart', + 'appointments-chart', + 'open-tickets', + 'recent-activity', + 'capacity-utilization', + 'customer-breakdown', + ], + layout: [ + { i: 'appointments-metric', x: 0, y: 0, w: 4, h: 2 }, + { i: 'customers-metric', x: 4, y: 0, w: 4, h: 2 }, + { i: 'no-show-rate', x: 8, y: 0, w: 4, h: 2 }, + { i: 'revenue-chart', x: 0, y: 2, w: 6, h: 4 }, + { i: 'appointments-chart', x: 6, y: 2, w: 6, h: 4 }, + { i: 'open-tickets', x: 0, y: 6, w: 4, h: 4 }, + { i: 'recent-activity', x: 4, y: 6, w: 4, h: 4 }, + { i: 'capacity-utilization', x: 8, y: 6, w: 4, h: 4 }, + { i: 'customer-breakdown', x: 0, y: 10, w: 4, h: 4 }, + ], +}; diff --git a/frontend/src/hooks/usePlanFeatures.ts b/frontend/src/hooks/usePlanFeatures.ts index 5eb4512..4b35229 100644 --- a/frontend/src/hooks/usePlanFeatures.ts +++ b/frontend/src/hooks/usePlanFeatures.ts @@ -84,6 +84,7 @@ export const FEATURE_NAMES: Record = { white_label: 'White Label', custom_oauth: 'Custom OAuth', plugins: 'Custom Plugins', + tasks: 'Scheduled Tasks', export_data: 'Data Export', video_conferencing: 'Video Conferencing', two_factor_auth: 'Two-Factor Authentication', @@ -103,6 +104,7 @@ export const FEATURE_DESCRIPTIONS: Record = { white_label: 'Remove SmoothSchedule branding and use your own', custom_oauth: 'Configure your own OAuth credentials for social login', plugins: 'Create custom plugins to extend functionality', + tasks: 'Create scheduled tasks to automate plugin execution', export_data: 'Export your data to CSV or other formats', video_conferencing: 'Add video conferencing links to appointments', two_factor_auth: 'Require two-factor authentication for enhanced security', diff --git a/frontend/src/hooks/usePlatformSettings.ts b/frontend/src/hooks/usePlatformSettings.ts index 69715b7..6489f60 100644 --- a/frontend/src/hooks/usePlatformSettings.ts +++ b/frontend/src/hooks/usePlatformSettings.ts @@ -243,3 +243,15 @@ export const useSyncPlansWithStripe = () => { }, }); }; + +/** + * Hook to sync a plan's permissions to all tenants on that plan + */ +export const useSyncPlanToTenants = () => { + return useMutation({ + mutationFn: async (planId: number) => { + const { data } = await apiClient.post(`/platform/subscription-plans/${planId}/sync_tenants/`); + return data as { message: string; tenant_count: number }; + }, + }); +}; diff --git a/frontend/src/hooks/useScrollToTop.ts b/frontend/src/hooks/useScrollToTop.ts index 95e02cc..066fedb 100644 --- a/frontend/src/hooks/useScrollToTop.ts +++ b/frontend/src/hooks/useScrollToTop.ts @@ -1,15 +1,22 @@ -import { useEffect } from 'react'; +import { useEffect, RefObject } from 'react'; import { useLocation } from 'react-router-dom'; /** * Hook to scroll to top on route changes * Should be used in layout components to ensure scroll restoration * works consistently across all routes + * + * @param containerRef - Optional ref to a scrollable container element. + * If provided, scrolls that element instead of window. */ -export function useScrollToTop() { +export function useScrollToTop(containerRef?: RefObject) { const { pathname } = useLocation(); useEffect(() => { - window.scrollTo(0, 0); - }, [pathname]); + if (containerRef?.current) { + containerRef.current.scrollTo(0, 0); + } else { + window.scrollTo(0, 0); + } + }, [pathname, containerRef]); } diff --git a/frontend/src/index.css b/frontend/src/index.css index aa30ad5..d6cb54f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -33,3 +33,96 @@ body { width: 100%; min-height: 100vh; } + +/* React Grid Layout Dashboard Styling */ +.layout { + position: relative; +} + +.widget-container { + height: 100%; +} + +.widget-container > div { + height: 100%; +} + +/* Drag handle styling */ +.drag-handle { + cursor: grab; +} + +.drag-handle:active { + cursor: grabbing; +} + +/* React Grid Layout overrides */ +.react-grid-item { + transition: all 200ms ease; + transition-property: left, top; +} + +.react-grid-item.cssTransforms { + transition-property: transform; +} + +.react-grid-item.resizing { + z-index: 1; + will-change: width, height; +} + +.react-grid-item.react-draggable-dragging { + transition: none; + z-index: 3; + will-change: transform; +} + +.react-grid-item.dropping { + visibility: hidden; +} + +.react-grid-item > .react-resizable-handle { + position: absolute; + width: 20px; + height: 20px; +} + +.react-grid-item > .react-resizable-handle::after { + content: ""; + position: absolute; + right: 3px; + bottom: 3px; + width: 6px; + height: 6px; + border-right: 2px solid rgba(0, 0, 0, 0.3); + border-bottom: 2px solid rgba(0, 0, 0, 0.3); +} + +.dark .react-grid-item > .react-resizable-handle::after { + border-right-color: rgba(255, 255, 255, 0.3); + border-bottom-color: rgba(255, 255, 255, 0.3); +} + +.react-resizable-handle-se { + bottom: 0; + right: 0; + cursor: se-resize; +} + +.react-resizable-handle-sw { + bottom: 0; + left: 0; + cursor: sw-resize; +} + +.react-resizable-handle-nw { + top: 0; + left: 0; + cursor: nw-resize; +} + +.react-resizable-handle-ne { + top: 0; + right: 0; + cursor: ne-resize; +} diff --git a/frontend/src/layouts/BusinessLayout.tsx b/frontend/src/layouts/BusinessLayout.tsx index af3ab1f..334ae2e 100644 --- a/frontend/src/layouts/BusinessLayout.tsx +++ b/frontend/src/layouts/BusinessLayout.tsx @@ -52,7 +52,7 @@ const BusinessLayoutContent: React.FC = ({ business, user, const [searchParams] = useSearchParams(); const navigate = useNavigate(); - useScrollToTop(); + useScrollToTop(mainContentRef); // Fetch ticket data when modal is opened from notification const { data: ticketFromNotification } = useTicket(ticketModalId || undefined); diff --git a/frontend/src/layouts/CustomerLayout.tsx b/frontend/src/layouts/CustomerLayout.tsx index 11bcb7b..6223fda 100644 --- a/frontend/src/layouts/CustomerLayout.tsx +++ b/frontend/src/layouts/CustomerLayout.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { Outlet, Link, useNavigate } from 'react-router-dom'; import { User, Business } from '../types'; import { LayoutDashboard, CalendarPlus, CreditCard, HelpCircle, Sun, Moon } from 'lucide-react'; @@ -18,7 +18,8 @@ interface CustomerLayoutProps { const CustomerLayout: React.FC = ({ business, user, darkMode, toggleTheme }) => { const navigate = useNavigate(); - useScrollToTop(); + const mainContentRef = useRef(null); + useScrollToTop(mainContentRef); // Masquerade logic const [masqueradeStack, setMasqueradeStack] = useState([]); @@ -116,7 +117,7 @@ const CustomerLayout: React.FC = ({ business, user, darkMod
-
+
diff --git a/frontend/src/layouts/ManagerLayout.tsx b/frontend/src/layouts/ManagerLayout.tsx index dac581b..1481d62 100644 --- a/frontend/src/layouts/ManagerLayout.tsx +++ b/frontend/src/layouts/ManagerLayout.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; +import React, { useState, useRef } from 'react'; import { Outlet } from 'react-router-dom'; import { Moon, Sun, Bell, Globe, Menu } from 'lucide-react'; import { User } from '../types'; @@ -16,8 +16,9 @@ interface ManagerLayoutProps { const ManagerLayout: React.FC = ({ user, darkMode, toggleTheme, onSignOut }) => { const [isCollapsed, setIsCollapsed] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const mainContentRef = useRef(null); - useScrollToTop(); + useScrollToTop(mainContentRef); return (
@@ -63,7 +64,7 @@ const ManagerLayout: React.FC = ({ user, darkMode, toggleThe
-
+
diff --git a/frontend/src/layouts/PlatformLayout.tsx b/frontend/src/layouts/PlatformLayout.tsx index 187a945..c39718c 100644 --- a/frontend/src/layouts/PlatformLayout.tsx +++ b/frontend/src/layouts/PlatformLayout.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useRef } from 'react'; import { Outlet } from 'react-router-dom'; import { Moon, Sun, Globe, Menu } from 'lucide-react'; import { User } from '../types'; @@ -21,8 +21,9 @@ const PlatformLayout: React.FC = ({ user, darkMode, toggleT const [isCollapsed, setIsCollapsed] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [ticketModalId, setTicketModalId] = useState(null); + const mainContentRef = useRef(null); - useScrollToTop(); + useScrollToTop(mainContentRef); // Fetch ticket data when modal is opened from notification const { data: ticketFromNotification } = useTicket(ticketModalId && ticketModalId !== 'undefined' ? ticketModalId : undefined); @@ -83,7 +84,7 @@ const PlatformLayout: React.FC = ({ user, darkMode, toggleT -
+
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index c38113f..db97457 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,28 +1,31 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - LineChart, - Line -} from 'recharts'; -import { TrendingUp, TrendingDown, Minus } from 'lucide-react'; +import GridLayout, { Layout } from 'react-grid-layout'; +import 'react-grid-layout/css/styles.css'; +import 'react-resizable/css/styles.css'; +import { Settings, Calendar, Users, Briefcase, ClipboardList, Edit2, Check } from 'lucide-react'; import { useServices } from '../hooks/useServices'; import { useResources } from '../hooks/useResources'; import { useAppointments } from '../hooks/useAppointments'; import { useCustomers } from '../hooks/useCustomers'; +import { useTickets } from '../hooks/useTickets'; +import { subDays, subMonths, isAfter, startOfWeek, endOfWeek, isWithinInterval } from 'date-fns'; +import { + MetricWidget, + ChartWidget, + OpenTicketsWidget, + RecentActivityWidget, + CapacityWidget, + NoShowRateWidget, + CustomerBreakdownWidget, + WidgetConfigModal, + WIDGET_DEFINITIONS, + DEFAULT_LAYOUT, + DashboardLayout, + WidgetType, +} from '../components/dashboard'; -interface Metric { - label: string; - value: string; - trend: 'up' | 'down' | 'neutral'; - change: string; -} +const STORAGE_KEY = 'dashboard_layout'; const Dashboard: React.FC = () => { const { t } = useTranslation(); @@ -30,76 +33,311 @@ const Dashboard: React.FC = () => { const { data: resources, isLoading: resourcesLoading } = useResources(); const { data: appointments, isLoading: appointmentsLoading } = useAppointments(); const { data: customers, isLoading: customersLoading } = useCustomers(); + const { data: tickets, isLoading: ticketsLoading } = useTickets(); - const isLoading = servicesLoading || resourcesLoading || appointmentsLoading || customersLoading; + const [isEditing, setIsEditing] = useState(false); + const [showConfig, setShowConfig] = useState(false); + const [dashboardLayout, setDashboardLayout] = useState(() => { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return DEFAULT_LAYOUT; + } + } + return DEFAULT_LAYOUT; + }); - // Calculate metrics from real data - const metrics: Metric[] = useMemo(() => { + // Save layout to localStorage when it changes + useEffect(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(dashboardLayout)); + }, [dashboardLayout]); + + const isLoading = servicesLoading || resourcesLoading || appointmentsLoading || customersLoading || ticketsLoading; + + // Calculate growth percentages + const calculateGrowth = useCallback(( + items: any[], + dateField: string, + filterFn?: (item: any) => boolean + ) => { + const now = new Date(); + const oneWeekAgo = subDays(now, 7); + const twoWeeksAgo = subDays(now, 14); + const oneMonthAgo = subMonths(now, 1); + const twoMonthsAgo = subMonths(now, 2); + + const filteredItems = filterFn ? items.filter(filterFn) : items; + + const thisWeek = filteredItems.filter(item => { + const date = new Date(item[dateField]); + return isAfter(date, oneWeekAgo); + }).length; + + const lastWeek = filteredItems.filter(item => { + const date = new Date(item[dateField]); + return isAfter(date, twoWeeksAgo) && !isAfter(date, oneWeekAgo); + }).length; + + const thisMonth = filteredItems.filter(item => { + const date = new Date(item[dateField]); + return isAfter(date, oneMonthAgo); + }).length; + + const lastMonth = filteredItems.filter(item => { + const date = new Date(item[dateField]); + return isAfter(date, twoMonthsAgo) && !isAfter(date, oneMonthAgo); + }).length; + + const weeklyChange = lastWeek !== 0 ? ((thisWeek - lastWeek) / lastWeek) * 100 : (thisWeek > 0 ? 100 : 0); + const monthlyChange = lastMonth !== 0 ? ((thisMonth - lastMonth) / lastMonth) * 100 : (thisMonth > 0 ? 100 : 0); + + return { + weekly: { value: thisWeek, change: weeklyChange }, + monthly: { value: thisMonth, change: monthlyChange }, + }; + }, []); + + // Calculate metrics with real growth data + const metrics = useMemo(() => { if (!appointments || !customers || !services || !resources) { - return [ - { label: t('dashboard.totalAppointments'), value: '0', trend: 'neutral', change: '0%' }, - { label: t('customers.title'), value: '0', trend: 'neutral', change: '0%' }, - { label: t('services.title'), value: '0', trend: 'neutral', change: '0%' }, - { label: t('resources.title'), value: '0', trend: 'neutral', change: '0%' }, - ]; + return { + appointments: { count: 0, growth: { weekly: { value: 0, change: 0 }, monthly: { value: 0, change: 0 } } }, + customers: { count: 0, growth: { weekly: { value: 0, change: 0 }, monthly: { value: 0, change: 0 } } }, + services: { count: 0 }, + resources: { count: 0 }, + }; } - const activeCustomers = customers.filter(c => c.status === 'Active').length; + const activeCustomers = customers.filter(c => c.status === 'Active'); - return [ - { label: t('dashboard.totalAppointments'), value: appointments.length.toString(), trend: 'up', change: '+12%' }, - { label: t('customers.title'), value: activeCustomers.toString(), trend: 'up', change: '+8%' }, - { label: t('services.title'), value: services.length.toString(), trend: 'neutral', change: '0%' }, - { label: t('resources.title'), value: resources.length.toString(), trend: 'up', change: '+3%' }, - ]; - }, [appointments, customers, services, resources, t]); + return { + appointments: { + count: appointments.length, + growth: calculateGrowth(appointments, 'startTime'), + }, + customers: { + count: activeCustomers.length, + growth: calculateGrowth(customers, 'lastVisit', c => c.status === 'Active' && c.lastVisit), + }, + services: { count: services.length }, + resources: { count: resources.length }, + }; + }, [appointments, customers, services, resources, calculateGrowth]); - // Calculate weekly data from appointments + // Calculate weekly chart data const weeklyData = useMemo(() => { if (!appointments) { - return [ - { name: 'Mon', revenue: 0, appointments: 0 }, - { name: 'Tue', revenue: 0, appointments: 0 }, - { name: 'Wed', revenue: 0, appointments: 0 }, - { name: 'Thu', revenue: 0, appointments: 0 }, - { name: 'Fri', revenue: 0, appointments: 0 }, - { name: 'Sat', revenue: 0, appointments: 0 }, - { name: 'Sun', revenue: 0, appointments: 0 }, - ]; + return { revenue: [], appointments: [] }; } - // Group appointments by day of week - const dayMap: { [key: string]: { revenue: number; count: number } } = { - 'Mon': { revenue: 0, count: 0 }, - 'Tue': { revenue: 0, count: 0 }, - 'Wed': { revenue: 0, count: 0 }, - 'Thu': { revenue: 0, count: 0 }, - 'Fri': { revenue: 0, count: 0 }, - 'Sat': { revenue: 0, count: 0 }, - 'Sun': { revenue: 0, count: 0 }, + const now = new Date(); + const weekStart = startOfWeek(now, { weekStartsOn: 1 }); + const weekEnd = endOfWeek(now, { weekStartsOn: 1 }); + + const dayMap: Record = { + Mon: { revenue: 0, count: 0 }, + Tue: { revenue: 0, count: 0 }, + Wed: { revenue: 0, count: 0 }, + Thu: { revenue: 0, count: 0 }, + Fri: { revenue: 0, count: 0 }, + Sat: { revenue: 0, count: 0 }, + Sun: { revenue: 0, count: 0 }, }; - appointments.forEach(appt => { - const date = new Date(appt.startTime); - const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - const dayName = dayNames[date.getDay()]; + appointments + .filter(appt => isWithinInterval(new Date(appt.startTime), { start: weekStart, end: weekEnd })) + .forEach(appt => { + const date = new Date(appt.startTime); + const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const dayName = dayNames[date.getDay()]; - dayMap[dayName].count++; - // Use price from appointment or default to 0 - dayMap[dayName].revenue += appt.price || 0; - }); + dayMap[dayName].count++; + dayMap[dayName].revenue += (appt as any).price || 0; + }); - return [ - { name: 'Mon', revenue: dayMap['Mon'].revenue, appointments: dayMap['Mon'].count }, - { name: 'Tue', revenue: dayMap['Tue'].revenue, appointments: dayMap['Tue'].count }, - { name: 'Wed', revenue: dayMap['Wed'].revenue, appointments: dayMap['Wed'].count }, - { name: 'Thu', revenue: dayMap['Thu'].revenue, appointments: dayMap['Thu'].count }, - { name: 'Fri', revenue: dayMap['Fri'].revenue, appointments: dayMap['Fri'].count }, - { name: 'Sat', revenue: dayMap['Sat'].revenue, appointments: dayMap['Sat'].count }, - { name: 'Sun', revenue: dayMap['Sun'].revenue, appointments: dayMap['Sun'].count }, - ]; + const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + return { + revenue: days.map(day => ({ name: day, value: dayMap[day].revenue })), + appointments: days.map(day => ({ name: day, value: dayMap[day].count })), + }; }, [appointments]); + // Handle layout change + const onLayoutChange = useCallback((newLayout: Layout[]) => { + setDashboardLayout(prev => ({ + ...prev, + layout: newLayout, + })); + }, []); + + // Toggle widget visibility + const toggleWidget = useCallback((widgetId: string) => { + setDashboardLayout(prev => { + const isActive = prev.widgets.includes(widgetId); + if (isActive) { + return { + widgets: prev.widgets.filter(id => id !== widgetId), + layout: prev.layout.filter(l => l.i !== widgetId), + }; + } else { + const widgetDef = WIDGET_DEFINITIONS[widgetId as WidgetType]; + const maxY = Math.max(0, ...prev.layout.map(l => l.y + l.h)); + return { + widgets: [...prev.widgets, widgetId], + layout: [ + ...prev.layout, + { + i: widgetId, + x: 0, + y: maxY, + w: widgetDef.defaultSize.w, + h: widgetDef.defaultSize.h, + minW: widgetDef.minSize?.w, + minH: widgetDef.minSize?.h, + }, + ], + }; + } + }); + }, []); + + // Remove widget + const removeWidget = useCallback((widgetId: string) => { + setDashboardLayout(prev => ({ + widgets: prev.widgets.filter(id => id !== widgetId), + layout: prev.layout.filter(l => l.i !== widgetId), + })); + }, []); + + // Reset to default layout + const resetLayout = useCallback(() => { + setDashboardLayout(DEFAULT_LAYOUT); + }, []); + + // Render individual widget + const renderWidget = useCallback((widgetId: string) => { + const widgetProps = { + isEditing, + onRemove: () => removeWidget(widgetId), + }; + + switch (widgetId) { + case 'appointments-metric': + return ( + } + {...widgetProps} + /> + ); + case 'customers-metric': + return ( + } + {...widgetProps} + /> + ); + case 'services-metric': + return ( + } + {...widgetProps} + /> + ); + case 'resources-metric': + return ( + } + {...widgetProps} + /> + ); + case 'revenue-chart': + return ( + + ); + case 'appointments-chart': + return ( + + ); + case 'open-tickets': + return ( + + ); + case 'recent-activity': + return ( + + ); + case 'capacity-utilization': + return ( + + ); + case 'no-show-rate': + return ( + + ); + case 'customer-breakdown': + return ( + + ); + default: + return null; + } + }, [t, metrics, weeklyData, tickets, appointments, customers, resources, isEditing, removeWidget]); + if (isLoading) { return (
@@ -119,85 +357,86 @@ const Dashboard: React.FC = () => { ); } + // Get layout with min sizes + const layoutWithConstraints = dashboardLayout.layout.map(l => { + const widgetDef = WIDGET_DEFINITIONS[l.i as WidgetType]; + return { + ...l, + minW: widgetDef?.minSize?.w || 2, + minH: widgetDef?.minSize?.h || 2, + }; + }); + return ( -
-
-

{t('dashboard.title')}

-

{t('dashboard.todayOverview')}

+
+ {/* Header */} +
+
+

{t('dashboard.title')}

+

{t('dashboard.todayOverview')}

+
+
+ + +
-
- {metrics.map((metric, index) => ( -
-

{metric.label}

-
- {metric.value} - - {metric.trend === 'up' && } - {metric.trend === 'down' && } - {metric.trend === 'neutral' && } - {metric.change} - + {/* Edit mode hint */} + {isEditing && ( +
+ Drag widgets to reposition them. Drag the corner to resize. Hover over a widget and click the X to remove it. +
+ )} + + {/* Grid Layout */} +
+ + {dashboardLayout.widgets.map(widgetId => ( +
+ {renderWidget(widgetId)}
-
- ))} + ))} +
-
- {/* Revenue Chart */} -
-

{t('dashboard.totalRevenue')}

-
- - - - - `$${value}`} tick={{ fill: '#9CA3AF' }} /> - - - - -
-
-
- - {/* Appointments Chart - Full Width */} -
-

{t('dashboard.upcomingAppointments')}

-
- - - - - - - - - -
-
+ {/* Widget Config Modal */} + setShowConfig(false)} + activeWidgets={dashboardLayout.widgets} + onToggleWidget={toggleWidget} + onResetLayout={resetLayout} + />
); }; -export default Dashboard; \ No newline at end of file +export default Dashboard; diff --git a/frontend/src/pages/HelpGuide.tsx b/frontend/src/pages/HelpGuide.tsx index 3dfe1b7..9d48e37 100644 --- a/frontend/src/pages/HelpGuide.tsx +++ b/frontend/src/pages/HelpGuide.tsx @@ -77,9 +77,7 @@ const HelpGuide: React.FC = () => { title: 'Extend', description: 'Add functionality with plugins', links: [ - { label: 'Plugins Overview', path: '/help/plugins', icon: }, - { label: 'Creating Plugins', path: '/help/plugins/create', icon: }, - { label: 'Plugin Documentation', path: '/help/plugins/docs', icon: }, + { label: 'Plugins', path: '/help/plugins', icon: }, ], }, { diff --git a/frontend/src/pages/MyPlugins.tsx b/frontend/src/pages/MyPlugins.tsx index 036c485..343cc78 100644 --- a/frontend/src/pages/MyPlugins.tsx +++ b/frontend/src/pages/MyPlugins.tsx @@ -25,6 +25,8 @@ import { import api from '../api/client'; import { PluginInstallation, PluginCategory } from '../types'; import EmailTemplateSelector from '../components/EmailTemplateSelector'; +import { usePlanFeatures } from '../hooks/usePlanFeatures'; +import { LockedSection } from '../components/UpgradePrompt'; // Category icon mapping const categoryIcons: Record = { @@ -60,6 +62,11 @@ const MyPlugins: React.FC = () => { const [review, setReview] = useState(''); const [configValues, setConfigValues] = useState>({}); + // Check plan permissions + const { canUse, isLoading: permissionsLoading } = usePlanFeatures(); + const hasPluginsFeature = canUse('plugins'); + const isLocked = !hasPluginsFeature; + // Fetch installed plugins const { data: plugins = [], isLoading, error } = useQuery({ queryKey: ['plugin-installations'], @@ -227,7 +234,7 @@ const MyPlugins: React.FC = () => { } }; - if (isLoading) { + if (isLoading || permissionsLoading) { return (
@@ -250,6 +257,7 @@ const MyPlugins: React.FC = () => { } return ( +
{/* Header */}
@@ -763,6 +771,7 @@ const MyPlugins: React.FC = () => {
)}
+
); }; diff --git a/frontend/src/pages/OwnerScheduler.tsx b/frontend/src/pages/OwnerScheduler.tsx index 4530833..dc45c04 100644 --- a/frontend/src/pages/OwnerScheduler.tsx +++ b/frontend/src/pages/OwnerScheduler.tsx @@ -102,6 +102,7 @@ const OwnerScheduler: React.FC = ({ user, business }) => { const [editDateTime, setEditDateTime] = useState(''); const [editResource, setEditResource] = useState(''); const [editDuration, setEditDuration] = useState(0); + const [editStatus, setEditStatus] = useState('CONFIRMED'); // Filter state const [showFilterMenu, setShowFilterMenu] = useState(false); @@ -113,9 +114,17 @@ const OwnerScheduler: React.FC = ({ user, business }) => { // Update edit state when selected appointment changes useEffect(() => { if (selectedAppointment) { - setEditDateTime(new Date(selectedAppointment.startTime).toISOString().slice(0, 16)); + // Format date in local time for datetime-local input (toISOString uses UTC) + const date = new Date(selectedAppointment.startTime); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + setEditDateTime(`${year}-${month}-${day}T${hours}:${minutes}`); setEditResource(selectedAppointment.resourceId || ''); setEditDuration(selectedAppointment.durationMinutes); + setEditStatus(selectedAppointment.status); } }, [selectedAppointment]); @@ -551,7 +560,7 @@ const OwnerScheduler: React.FC = ({ user, business }) => { const getStatusColor = (status: Appointment['status'], startTime: Date, endTime: Date) => { if (status === 'COMPLETED') return 'bg-green-100 border-green-500 text-green-900 dark:bg-green-900/50 dark:border-green-500 dark:text-green-200'; - if (status === 'NO_SHOW') return 'bg-gray-100 border-gray-400 text-gray-600 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400'; + if (status === 'NO_SHOW') return 'bg-orange-100 border-orange-500 text-orange-900 dark:bg-orange-900/50 dark:border-orange-500 dark:text-orange-200'; if (status === 'CANCELLED') return 'bg-gray-100 border-gray-400 text-gray-500 opacity-75 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400'; const now = new Date(); if (now > endTime) return 'bg-red-100 border-red-500 text-red-900 dark:bg-red-900/50 dark:border-red-500 dark:text-red-200'; @@ -562,7 +571,7 @@ const OwnerScheduler: React.FC = ({ user, business }) => { // Simplified status colors for month view (no border classes) const getMonthStatusColor = (status: Appointment['status'], startTime: Date, endTime: Date) => { if (status === 'COMPLETED') return 'bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800/50'; - if (status === 'NO_SHOW') return 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'; + if (status === 'NO_SHOW') return 'bg-orange-100 dark:bg-orange-900/50 text-orange-800 dark:text-orange-200 hover:bg-orange-200 dark:hover:bg-orange-800/50'; if (status === 'CANCELLED') return 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 opacity-75 hover:bg-gray-200 dark:hover:bg-gray-600'; const now = new Date(); if (now > endTime) return 'bg-red-100 dark:bg-red-900/50 text-red-800 dark:text-red-200 hover:bg-red-200 dark:hover:bg-red-800/50'; @@ -823,6 +832,7 @@ const OwnerScheduler: React.FC = ({ user, business }) => { const updates: any = { startTime: new Date(editDateTime), durationMinutes: validDuration, + status: editStatus, }; if (editResource) { @@ -1112,9 +1122,9 @@ const OwnerScheduler: React.FC = ({ user, business }) => {
))} @@ -1719,8 +1729,18 @@ const OwnerScheduler: React.FC = ({ user, business }) => {

{services.find(s => s.id === selectedAppointment.serviceId)?.name}

-

Status

-

{selectedAppointment.status.toLowerCase().replace('_', ' ')}

+ +
diff --git a/frontend/src/pages/PluginMarketplace.tsx b/frontend/src/pages/PluginMarketplace.tsx index 024a1e7..01d29fd 100644 --- a/frontend/src/pages/PluginMarketplace.tsx +++ b/frontend/src/pages/PluginMarketplace.tsx @@ -29,6 +29,8 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import api from '../api/client'; import { PluginTemplate, PluginCategory } from '../types'; +import { usePlanFeatures } from '../hooks/usePlanFeatures'; +import { LockedSection } from '../components/UpgradePrompt'; // Category icon mapping const categoryIcons: Record = { @@ -91,6 +93,11 @@ const PluginMarketplace: React.FC = () => { const [showWhatsNextModal, setShowWhatsNextModal] = useState(false); const [installedPluginId, setInstalledPluginId] = useState(null); + // Check plan permissions + const { canUse, isLoading: permissionsLoading } = usePlanFeatures(); + const hasPluginsFeature = canUse('plugins'); + const isLocked = !hasPluginsFeature; + // Fetch marketplace plugins const { data: plugins = [], isLoading, error } = useQuery({ queryKey: ['plugin-templates', 'marketplace'], @@ -206,7 +213,7 @@ const PluginMarketplace: React.FC = () => { } }; - if (isLoading) { + if (isLoading || permissionsLoading) { return (
@@ -229,6 +236,7 @@ const PluginMarketplace: React.FC = () => { } return ( +
{/* Header */}
@@ -702,6 +710,7 @@ const PluginMarketplace: React.FC = () => {
)}
+
); }; diff --git a/frontend/src/pages/Tasks.tsx b/frontend/src/pages/Tasks.tsx index 5014ebd..60aedbc 100644 --- a/frontend/src/pages/Tasks.tsx +++ b/frontend/src/pages/Tasks.tsx @@ -21,6 +21,8 @@ import { import toast from 'react-hot-toast'; import CreateTaskModal from '../components/CreateTaskModal'; import EditTaskModal from '../components/EditTaskModal'; +import { usePlanFeatures } from '../hooks/usePlanFeatures'; +import { LockedSection } from '../components/UpgradePrompt'; // Types interface ScheduledTask { @@ -95,6 +97,12 @@ const Tasks: React.FC = () => { const [editingTask, setEditingTask] = useState(null); const [editingEventAutomation, setEditingEventAutomation] = useState(null); + // Check plan permissions - tasks requires both plugins AND tasks features + const { canUse, isLoading: permissionsLoading } = usePlanFeatures(); + const hasPluginsFeature = canUse('plugins'); + const hasTasksFeature = canUse('tasks'); + const isLocked = !hasPluginsFeature || !hasTasksFeature; + // Fetch scheduled tasks const { data: scheduledTasks = [], isLoading: tasksLoading } = useQuery({ queryKey: ['scheduled-tasks'], @@ -246,7 +254,7 @@ const Tasks: React.FC = () => { return AlertCircle; }; - if (isLoading) { + if (isLoading || permissionsLoading) { return (
@@ -255,6 +263,7 @@ const Tasks: React.FC = () => { } return ( +
{/* Header */}
@@ -588,6 +597,7 @@ const Tasks: React.FC = () => { /> )}
+ ); }; diff --git a/frontend/src/pages/help/HelpComprehensive.tsx b/frontend/src/pages/help/HelpComprehensive.tsx new file mode 100644 index 0000000..d4ebc00 --- /dev/null +++ b/frontend/src/pages/help/HelpComprehensive.tsx @@ -0,0 +1,829 @@ +/** + * Comprehensive Help Guide - Monolithic Documentation + * + * Complete documentation for SmoothSchedule in a single scrollable page. + * Includes all features: Dashboard, Scheduler, Services, Resources, Customers, Staff, and Settings. + */ + +import React from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { + ArrowLeft, BookOpen, LayoutDashboard, Calendar, Briefcase, Users, UserCog, + ClipboardList, Settings, ChevronRight, HelpCircle, CheckCircle, AlertCircle, + Clock, Eye, Palette, Link2, Mail, Globe, CreditCard, Zap, Search, Filter, + Plus, Edit, Trash2, ArrowUpDown, GripVertical, Image, Save, ExternalLink, + MessageSquare, Tag, UserPlus, Shield, Copy, Layers, Play, Pause, Puzzle, +} from 'lucide-react'; + +interface TocSubItem { + label: string; + href: string; +} + +interface TocItem { + id: string; + label: string; + icon: React.ReactNode; + subItems?: TocSubItem[]; +} + +const HelpComprehensive: React.FC = () => { + const navigate = useNavigate(); + const [expandedItems, setExpandedItems] = React.useState(['getting-started', 'settings']); + + // Table of contents items with sub-items + const tocItems: TocItem[] = [ + { + id: 'getting-started', + label: 'Getting Started', + icon: , + subItems: [ + { label: 'Services Setup', href: '/help/services' }, + { label: 'Resources Setup', href: '/help/resources' }, + { label: 'Branding', href: '/help/settings/appearance' }, + { label: 'Booking URL', href: '/help/settings/booking' }, + { label: 'Scheduler', href: '/help/scheduler' }, + ], + }, + { id: 'dashboard', label: 'Dashboard', icon: }, + { id: 'scheduler', label: 'Scheduler', icon: }, + { id: 'services', label: 'Services', icon: }, + { id: 'resources', label: 'Resources', icon: }, + { id: 'customers', label: 'Customers', icon: }, + { id: 'staff', label: 'Staff', icon: }, + { id: 'plugins', label: 'Plugins', icon: }, + { + id: 'settings', + label: 'Settings', + icon: , + subItems: [ + { label: 'Resource Types', href: '/help/settings/resource-types' }, + { label: 'Email Settings', href: '/help/settings/email' }, + { label: 'Custom Domains', href: '/help/settings/domains' }, + { label: 'Billing', href: '/help/settings/billing' }, + { label: 'API Settings', href: '/help/settings/api' }, + { label: 'Authentication', href: '/help/settings/auth' }, + { label: 'Usage & Quota', href: '/help/settings/quota' }, + ], + }, + ]; + + const scrollToSection = (id: string) => { + const element = document.getElementById(id); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }; + + const toggleExpanded = (id: string) => { + setExpandedItems((prev) => + prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id] + ); + }; + + const scrollToTop = () => { + window.scrollTo({ top: 0, behavior: 'instant' }); + }; + + return ( +
+ {/* Fixed Header */} +
+
+
+ +
+ +

SmoothSchedule Complete Guide

+
+
+ + Contact Support + +
+
+ +
+ {/* Sidebar Table of Contents */} + + + {/* Main Content */} +
+ {/* Introduction */} +
+
+

Welcome to SmoothSchedule

+

+ SmoothSchedule is a complete scheduling platform designed to help businesses manage appointments, + customers, staff, and services. This comprehensive guide covers everything you need to know to + get the most out of the platform. +

+

+ Use the table of contents on the left to jump to specific sections, or scroll through the entire guide. +

+
+
+ + {/* ============================================== */} + {/* GETTING STARTED */} + {/* ============================================== */} +
+
+
+ +
+

Getting Started

+
+ +
+

Quick Setup Checklist

+

+ Follow these steps to get your scheduling system up and running: +

+
    +
  1. + 1 +
    + + Set up your Services + + +

    Define what you offer - consultations, appointments, classes, etc. Include names, durations, and prices.

    +
    +
  2. +
  3. + 2 +
    + + Add your Resources + + +

    Create staff members, rooms, or equipment that can be booked. Set their availability schedules.

    +
    +
  4. +
  5. + 3 +
    + + Configure your Branding + + +

    Upload your logo and set your brand colors so customers recognize your business.

    +
    +
  6. +
  7. + 4 +
    + + Share your Booking URL + + +

    Copy your booking URL from Settings → Booking and share it with customers.

    +
    +
  8. +
  9. + 5 +
    + + Start Managing Appointments + + +

    Use the Scheduler to view, create, and manage bookings as they come in.

    +
    +
  10. +
+
+
+ + {/* ============================================== */} + {/* DASHBOARD */} + {/* ============================================== */} +
+
+
+ +
+

Dashboard

+
+ +
+

+ The Dashboard provides an at-a-glance overview of your business performance. It displays key metrics + and charts to help you understand how your scheduling business is doing. +

+ +

Key Metrics

+
+
+

Total Appointments

+

Number of bookings in the system

+
+
+

Active Customers

+

Customers with Active status

+
+
+

Services

+

Total number of services offered

+
+
+

Resources

+

Staff, rooms, and equipment available

+
+
+ +

Charts

+
    +
  • + + Revenue Chart: Bar chart showing daily revenue by day of week +
  • +
  • + + Appointments Chart: Line chart showing appointment volume by day +
  • +
+
+
+ + {/* ============================================== */} + {/* SCHEDULER */} + {/* ============================================== */} +
+
+
+ +
+

Scheduler

+
+ +
+

+ The Scheduler is the heart of SmoothSchedule. It provides a visual calendar interface for managing + all your appointments with full drag-and-drop support. +

+ +

Interface Layout

+
+
+ +
+

Left Sidebar - Pending Appointments

+

Unscheduled appointments waiting to be placed on the calendar. Drag them onto available time slots.

+
+
+
+ +
+

Center - Calendar View

+

Main calendar showing appointments organized by resource in columns. Switch between day, 3-day, week, and month views.

+
+
+
+ +
+

Right Sidebar - Appointment Details

+

Click any appointment to view/edit details, add notes, change status, or send reminders.

+
+
+
+ +

Key Features

+
    +
  • + + Drag & Drop: Move appointments between time slots and resources +
  • +
  • + + Resize: Drag appointment edges to change duration +
  • +
  • + + Quick Create: Double-click any empty slot to create a new appointment +
  • +
  • + + Resource Filtering: Toggle which resources are visible in the calendar +
  • +
  • + + Status Colors: Appointments are color-coded by status (confirmed, pending, cancelled) +
  • +
+ +

Appointment Statuses

+
+
+ Pending +
+
+ Confirmed +
+
+ Cancelled +
+
+ Completed +
+
+ No-Show +
+
+
+
+ + {/* ============================================== */} + {/* SERVICES */} + {/* ============================================== */} +
+
+
+ +
+

Services

+
+ +
+

+ Services define what customers can book with you. Each service has a name, duration, price, and + description. The Services page uses a two-column layout: an editable list on the left and a + customer preview on the right. +

+ +

Service Properties

+
+
+

Name

+

The service title shown to customers

+
+
+

Duration

+

How long the appointment takes (in minutes)

+
+
+

Price

+

Cost of the service (displayed to customers)

+
+
+

Description

+

Details about what the service includes

+
+
+ +

Key Features

+
    +
  • + + Drag to Reorder: Change the display order by dragging services up/down +
  • +
  • + + Photo Gallery: Add, reorder, and remove images for each service +
  • +
  • + + Live Preview: See how customers will view your service in real-time +
  • +
  • + + Quick Add: Create new services with the Add Service button +
  • +
+
+
+ + {/* ============================================== */} + {/* RESOURCES */} + {/* ============================================== */} +
+
+
+ +
+

Resources

+
+ +
+

+ Resources are the things that get booked - staff members, rooms, equipment, or any other bookable + entity. Each resource appears as a column in the scheduler calendar. +

+ +

Resource Types

+
+
+ +

Staff

+

People who provide services (employees, contractors, etc.)

+
+
+ +

Room

+

Physical spaces (meeting rooms, studios, treatment rooms)

+
+
+ +

Equipment

+

Physical items (cameras, projectors, vehicles)

+
+
+ +

Key Features

+
    +
  • + + Staff Autocomplete: When creating staff resources, link to existing staff members +
  • +
  • + + Multilane Mode: Enable for resources that can handle multiple concurrent bookings +
  • +
  • + + View Calendar: Click the calendar icon to see a resource's schedule +
  • +
  • + + Table Actions: Edit or delete resources from the actions column +
  • +
+
+
+ + {/* ============================================== */} + {/* CUSTOMERS */} + {/* ============================================== */} +
+
+
+ +
+

Customers

+
+ +
+

+ The Customers page lets you manage all the people who book appointments with your business. + Track their information, booking history, and status. +

+ +

Customer Statuses

+
+
+

Active

+

Customer can book appointments normally

+
+
+

Inactive

+

Customer record is dormant

+
+
+

Blocked

+

Customer cannot make new bookings

+
+
+ +

Key Features

+
    +
  • + + Search: Find customers by name, email, or phone +
  • +
  • + + Filter: Filter by status (Active, Inactive, Blocked) +
  • +
  • + + Tags: Organize customers with custom tags (VIP, New, etc.) +
  • +
  • + + Sorting: Click column headers to sort the table +
  • +
+ +
+

+ Masquerading +

+

+ Use the Masquerade feature to see exactly what a customer sees when they log in. This is helpful + for walking customers through tasks or troubleshooting issues. Click the eye icon in a customer's + row to start masquerading. +

+
+
+
+ + {/* ============================================== */} + {/* STAFF */} + {/* ============================================== */} +
+
+
+ +
+

Staff

+
+ +
+

+ The Staff page lets you manage team members who help run your business. Invite new staff, + assign roles, and control what each person can access. +

+ +

Staff Roles

+
+
+ +
+

Owner

+

Full access to everything including billing and settings. Cannot be removed.

+
+
+
+ +
+

Manager

+

Can manage staff, customers, services, and appointments. No billing access.

+
+
+
+ +
+

Staff

+

Basic access. Can view scheduler and manage own appointments if bookable.

+
+
+
+ +

Inviting Staff

+
    +
  1. Click the Invite Staff button
  2. +
  3. Enter their email address
  4. +
  5. Select a role (Manager or Staff)
  6. +
  7. Click Send Invitation
  8. +
  9. They'll receive an email with a link to join
  10. +
+ +

Make Bookable

+

+ The "Make Bookable" option creates a bookable resource for a staff member. When enabled, they + appear as a column in the scheduler and customers can book appointments with them directly. +

+
+
+ + {/* ============================================== */} + {/* PLUGINS */} + {/* ============================================== */} +
+
+
+ +
+

Plugins

+
+ +
+

+ Plugins extend SmoothSchedule with custom automation and integrations. Browse the marketplace + for pre-built plugins or create your own using our scripting language. +

+ +

What Plugins Can Do

+
    +
  • + + Send Emails: Automated reminders, confirmations, and follow-ups +
  • +
  • + + Webhooks: Integrate with external services when events occur +
  • +
  • + + Reports: Generate and email business reports on a schedule +
  • +
  • + + Cleanup: Automatically archive old data or manage records +
  • +
+ +

Plugin Types

+
+
+

Marketplace Plugins

+

Pre-built plugins available to install immediately. Browse, install, and configure with a few clicks.

+
+
+

Custom Plugins

+

Create your own plugins using our scripting language. Full control over logic and triggers.

+
+
+ +

Triggers

+

+ Plugins can be triggered in various ways: +

+
+
+ Before Event +
+
+ At Start +
+
+ After End +
+
+ On Status Change +
+
+ +

Learn More

+ + +
+

Plugin Documentation

+

Complete guide to creating and using plugins, including API reference and examples

+
+ + +
+
+ + {/* ============================================== */} + {/* SETTINGS */} + {/* ============================================== */} +
+
+
+ +
+

Settings

+
+ +
+

+ Settings is where business owners configure their scheduling platform. Most settings are + owner-only and affect how your business operates. +

+ +
+
+ +

+ Owner Access Required: Only business owners can access most settings pages. +

+
+
+ +
+ {/* General Settings */} +
+

General Settings

+

+ Configure your business name, timezone, and contact information. +

+
    +
  • • Business Name: Your company name displayed throughout the app
  • +
  • • Subdomain: Your booking URL (read-only after creation)
  • +
  • • Timezone: Business operating timezone
  • +
  • • Time Display Mode: Show times in business timezone or viewer's timezone
  • +
  • • Contact Email/Phone: How customers can reach you
  • +
+
+ + {/* Booking Settings */} +
+

Booking Settings

+

+ Your booking URL and post-booking redirect configuration. +

+
    +
  • • Booking URL: The link customers use to book (copy/share it)
  • +
  • • Return URL: Where to redirect customers after booking (optional)
  • +
+
+ + {/* Branding Settings */} +
+

Branding (Appearance)

+

+ Customize your business appearance with logos and colors. +

+
    +
  • • Website Logo: Appears in sidebar and booking pages (500×500px recommended)
  • +
  • • Email Logo: Appears in email notifications (600×200px recommended)
  • +
  • • Display Mode: Text Only, Logo Only, or Logo and Text
  • +
  • • Color Palettes: 10 preset palettes to choose from
  • +
  • • Custom Colors: Set your own primary and secondary colors
  • +
+
+ + {/* Other Settings */} +
+

Other Settings

+
+ +

Resource Types

+

Configure staff, room, equipment types

+ + +

Email Templates

+

Customize email notifications

+ + +

Custom Domains

+

Use your own domain for booking

+ + +

Billing

+

Manage subscription and payments

+ + +

API Settings

+

API keys and webhooks

+ + +

Usage & Quota

+

Track usage and limits

+ +
+
+
+
+
+ + {/* Help Footer */} +
+ +

Need More Help?

+

+ Can't find what you're looking for? Our support team is ready to help. +

+ +
+
+
+
+ ); +}; + +export default HelpComprehensive; diff --git a/frontend/src/pages/help/HelpCreatePlugin.tsx b/frontend/src/pages/help/HelpCreatePlugin.tsx deleted file mode 100644 index 9b6f817..0000000 --- a/frontend/src/pages/help/HelpCreatePlugin.tsx +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Help Create Plugin Page - */ - -import React from 'react'; -import { useNavigate, Link } from 'react-router-dom'; -import { - ArrowLeft, Code, FileCode, Play, Settings, Eye, - CheckCircle, ChevronRight, HelpCircle, Puzzle, BookOpen, -} from 'lucide-react'; - -const HelpCreatePlugin: React.FC = () => { - const navigate = useNavigate(); - - return ( -
- - -
-
-
- -
-
-

Creating Plugins Guide

-

Build custom plugins for your business

-
-
-
- -
-

- Overview -

-
-

- Create custom plugins to automate workflows, generate reports, send notifications, and more. Plugins use AI-powered prompts that can access your business data to perform specific actions. -

-

- No coding experience required - simply describe what you want the plugin to do using natural language prompts. -

-
-
- -
-

- Plugin Components -

-
-
-
- -
-

Basic Information

-

Name, description, category, and version for your plugin

-
-
-
- -
-

Plugin Code

-

AI prompt instructions that define plugin behavior

-
-
-
- -
-

Visibility

-

Private (only you) or Public (share with community)

-
-
-
-
-
- -
-

- Template Variables -

-
-

- Use these special variables in your plugin code to access dynamic data: -

-
-
- {"{{PROMPT}}"} - - User input when running the plugin -
-
- {"{{CONTEXT}}"} - - Additional context data from the system -
-
- {"{{DATE}}"} - - Current date for time-based operations -
-
-
-
- -
-

- Plugin Categories -

-
-
-
- Scheduling -

Appointment automation

-
-
- Communication -

Messages and notifications

-
-
- Analytics -

Reports and insights

-
-
- Integration -

External service connections

-
-
- Automation -

Workflow automation

-
-
- Other -

Custom functionality

-
-
-
-
- -
-

- Best Practices -

-
-
    -
  • - - Be Specific: Write clear, detailed prompts for better results -
  • -
  • - - Test First: Run plugins in test mode before production use -
  • -
  • - - Version Control: Update version numbers when making changes -
  • -
  • - - Document: Add clear descriptions for easy understanding -
  • -
-
-
- -
-

Related Guides

-
- - - Plugins Overview - - - - - Plugin Documentation - - -
-
- -
- -

Need More Help?

-

Our support team is ready to help with any questions.

- -
-
- ); -}; - -export default HelpCreatePlugin; diff --git a/frontend/src/pages/help/HelpDashboard.tsx b/frontend/src/pages/help/HelpDashboard.tsx index 03d3b54..a861cbd 100644 --- a/frontend/src/pages/help/HelpDashboard.tsx +++ b/frontend/src/pages/help/HelpDashboard.tsx @@ -19,6 +19,13 @@ import { CheckCircle, ChevronRight, HelpCircle, + Settings, + Edit2, + GripVertical, + Ticket, + Activity, + UserX, + PieChart, } from 'lucide-react'; const HelpDashboard: React.FC = () => { @@ -47,7 +54,7 @@ const HelpDashboard: React.FC = () => { Dashboard Guide

- Your business at a glance + Your customizable business command center

@@ -61,89 +68,202 @@ const HelpDashboard: React.FC = () => {

- The Dashboard is your command center for understanding your business performance. It provides real-time insights into appointments, customers, services, and resources, helping you make informed decisions quickly. + The Dashboard is your fully customizable command center for understanding your business performance. It provides real-time insights into appointments, customers, capacity, tickets, and more.

- All metrics update automatically as you add new appointments, register customers, and manage your business operations. + You can personalize your dashboard by adding, removing, repositioning, and resizing widgets to focus on the metrics that matter most to you. Your layout is automatically saved.

- {/* Key Metrics Section */} + {/* Customization Section */}

- - Key Metrics -

-
-

- The Dashboard displays four primary metrics that give you an instant snapshot of your business: -

-
-
- -
-

Total Appointments

-

- The number of appointments in your system. Shows trend compared to previous period. -

-
-
-
- -
-

Active Customers

-

- Customers with "Active" status who can book appointments with your business. -

-
-
-
- -
-

Services

-

- The total number of services you offer. Click to manage your service catalog. -

-
-
-
- -
-

Resources

-

- Staff, rooms, and equipment available for booking. Essential for scheduling. -

-
-
-
-
-
- - {/* Charts Section */} -
-

- - Analytics Charts + + Customizing Your Dashboard

-

Weekly Revenue Chart

-

- A bar chart showing revenue by day of the week. Helps identify your busiest and most profitable days. Calculated from appointment prices. +

+ + Edit Layout Mode +

+

+ Click the "Edit Layout" button to enter edit mode. While in edit mode you can: +

+
    +
  • + + Drag widgets to reposition them on the dashboard +
  • +
  • • Drag corners to resize widgets larger or smaller
  • +
  • • Click the X on any widget to remove it
  • +
+

+ Click "Done" when finished editing.

-

Weekly Appointments Chart

+

+ + Widget Configuration +

- A line chart showing appointment counts by day. Useful for staffing decisions and understanding demand patterns throughout the week. + Click the "Widgets" button to open the widget configuration panel. Here you can toggle widgets on/off and click "Reset to Default" to restore the original layout.

+ {/* Available Widgets Section */} +
+

+ + Available Widgets +

+
+

+ Choose from a variety of widgets to build your perfect dashboard: +

+ + {/* Metric Widgets */} +

Metric Widgets

+
+
+ +
+
Total Appointments
+

+ Count with weekly and monthly growth trends +

+
+
+
+ +
+
Active Customers
+

+ Active customer count with growth trends +

+
+
+
+ +
+
No-Show Rate
+

+ Percentage of missed appointments with trends +

+
+
+
+ + {/* Chart Widgets */} +

Chart Widgets

+
+
+ +
+
Weekly Revenue
+

+ Bar chart of revenue by day of week +

+
+
+
+ +
+
Appointments Trend
+

+ Line chart of appointments by day +

+
+
+
+ + {/* Activity Widgets */} +

Activity & Status Widgets

+
+
+ +
+
Open Tickets
+

+ Support tickets requiring attention +

+
+
+
+ +
+
Recent Activity
+

+ Timeline of recent business events +

+
+
+
+ +
+
Capacity Utilization
+

+ Shows booking percentage for each resource +

+
+
+
+ +
+
Customer Breakdown
+

+ New vs returning customers this month +

+
+
+
+
+
+ + {/* Growth Metrics Section */} +
+

+ + Understanding Growth Metrics +

+
+

+ Metric widgets display both weekly and monthly growth percentages: +

+
+
+
+ +12% +
+
+

+ Green with + indicates growth compared to the previous period +

+
+
+
+
+ -8% +
+
+

+ Red with - indicates decline compared to the previous period +

+
+
+
+

+ Weekly compares this week to last week. Monthly compares this month to last month. +

+
+
+ {/* Benefits Section */}

@@ -155,25 +275,31 @@ const HelpDashboard: React.FC = () => {
  • - Quick Overview: See your entire business performance in seconds + Fully Customizable: Arrange widgets exactly how you want them
  • - Trend Tracking: Understand if your business is growing with percentage changes + Persistent Layout: Your dashboard layout is saved automatically
  • - Data-Driven Decisions: Use charts to identify patterns and optimize operations + Real-Time Data: All metrics update automatically as your data changes
  • - Real-Time Updates: All metrics refresh automatically as your data changes + Trend Tracking: Weekly and monthly growth percentages help you spot patterns + +
  • +
  • + + + Resource Insights: See capacity utilization for each team member
  • diff --git a/frontend/src/pages/HelpPluginDocs.tsx b/frontend/src/pages/help/HelpPluginDocs.tsx similarity index 100% rename from frontend/src/pages/HelpPluginDocs.tsx rename to frontend/src/pages/help/HelpPluginDocs.tsx diff --git a/frontend/src/pages/help/HelpPlugins.tsx b/frontend/src/pages/help/HelpPlugins.tsx index 244c671..681331b 100644 --- a/frontend/src/pages/help/HelpPlugins.tsx +++ b/frontend/src/pages/help/HelpPlugins.tsx @@ -1,52 +1,87 @@ /** - * Help Plugins Overview Page + * Help Plugins Page + * + * User-friendly help documentation for Plugins. */ import React from 'react'; +import { useTranslation } from 'react-i18next'; import { useNavigate, Link } from 'react-router-dom'; import { - ArrowLeft, Puzzle, Store, Code, Zap, Settings, - CheckCircle, ChevronRight, HelpCircle, Shield, Download, + ArrowLeft, + Puzzle, + Store, + Code, + Zap, + Clock, + CheckCircle, + ChevronRight, + HelpCircle, + Play, + Pause, + Settings, + Mail, + BookOpen, + Calendar, + ListTodo, } from 'lucide-react'; const HelpPlugins: React.FC = () => { + const { t } = useTranslation(); const navigate = useNavigate(); return (
    - + {/* Header */}
    -

    Plugins Guide

    -

    Extend your scheduling platform

    +

    + Plugins Guide +

    +

    + Automate your business with powerful plugins +

    + {/* Overview Section */}

    - Overview + + Overview

    - Plugins extend the functionality of your scheduling platform. Browse the marketplace for ready-made solutions, or create custom plugins tailored to your specific business needs. + Plugins extend your scheduling platform with automation capabilities. Send reminder emails, + generate reports, track no-shows, and create custom workflows - all running automatically + on schedules you define.

    - Plugins can automate tasks, integrate with external services, add new features, and customize how your scheduling system works. + Browse the marketplace for ready-made plugins, or create your own custom automations + using our simple scripting language.

    + {/* Plugin Areas Section */}

    - Plugin Areas + + Plugin Areas

    @@ -54,7 +89,7 @@ const HelpPlugins: React.FC = () => {

    Marketplace

    -

    Browse and install plugins from the community

    +

    Browse and install pre-built plugins from our library

    @@ -68,23 +103,65 @@ const HelpPlugins: React.FC = () => {

    Create Plugin

    -

    Build custom plugins with our development tools

    +

    Build custom plugins with our scripting tools

    - +
    -

    Plugin Actions

    -

    Configure how plugins interact with your system

    +

    Tasks

    +

    View and manage scheduled plugin executions

    + {/* What Plugins Can Do */}

    - Getting Started + + What Plugins Can Do +

    +
    +
    +
    + +
    +

    Send Emails

    +

    Automated reminders, follow-ups, and notifications to customers

    +
    +
    +
    + +
    +

    Manage Appointments

    +

    Query, filter, and process appointment data automatically

    +
    +
    +
    + +
    +

    Run on Schedules

    +

    Execute hourly, daily, weekly, or on custom cron schedules

    +
    +
    +
    + +
    +

    External Integrations

    +

    Connect to approved external APIs for advanced workflows

    +
    +
    +
    +
    +
    + + {/* Getting Started */} +
    +

    + + Getting Started

      @@ -92,81 +169,101 @@ const HelpPlugins: React.FC = () => { 1

      Browse Marketplace

      -

      Explore available plugins by category or search for specific functionality.

      +

      Go to Plugins → Marketplace to explore available plugins.

    1. 2
      -

      Install Plugin

      +

      Install a Plugin

      Click "Install" on any plugin to add it to your account.

    2. 3
      -

      Configure Settings

      -

      Set up plugin options and connect any required integrations.

      +

      Configure & Schedule

      +

      Set up plugin options and choose when it should run.

    3. 4
      -

      Run Plugin

      -

      Execute plugins on-demand or set up automatic triggers.

      +

      Monitor Tasks

      +

      View execution history and logs in the Tasks page.

    + {/* Task Management */}

    - Benefits + + Managing Tasks

    -
    -
      -
    • - - Automation: Automate repetitive tasks and workflows +
      +

      + When you install a plugin, it creates a scheduled task that runs automatically. Use the Tasks page to: +

      +
        +
      • + + Run manually: Execute a task immediately without waiting for the schedule
      • -
      • - - Integration: Connect with external services and tools +
      • + + Pause/Resume: Temporarily stop a task without deleting it
      • -
      • - - Customization: Tailor functionality to your needs -
      • -
      • - - Community: Access plugins built by other users +
      • + + View logs: See execution history and any errors
    + {/* Developer Documentation Link */}
    -

    Related Guides

    -
    - - - Creating Plugins - - - - - Plugin Documentation - - +
    +
    + +
    +

    + Creating Custom Plugins +

    +

    + Want to build your own automations? Our comprehensive developer documentation covers + the scripting language, available API methods, and example code. +

    + + View Developer Docs + + +
    +
    + {/* Need More Help */}
    -

    Need More Help?

    -

    Our support team is ready to help with any questions.

    - +

    + Need More Help? +

    +

    + Our support team is ready to help with any questions about plugins. +

    +
    ); diff --git a/frontend/src/pages/help/HelpScheduler.tsx b/frontend/src/pages/help/HelpScheduler.tsx index 3e08ff6..0a829c3 100644 --- a/frontend/src/pages/help/HelpScheduler.tsx +++ b/frontend/src/pages/help/HelpScheduler.tsx @@ -259,9 +259,9 @@ const HelpScheduler: React.FC = () => { Green - Completed Successfully finished
    -
    - Gray - No-show - Customer didn't arrive +
    + Orange - No-show + Customer didn't arrive
    Gray (faded) - Cancelled @@ -542,9 +542,9 @@ const HelpScheduler: React.FC = () => {
    -

    Update Status

    +

    Change Status

    - Mark as completed, no-show, or cancelled + Use the status dropdown to change between Pending, Confirmed, Completed, No-show, or Cancelled

    @@ -560,12 +560,6 @@ const HelpScheduler: React.FC = () => {
      -
    • - - - Drag from where you grab: When dragging, the appointment time adjusts based on where you clicked on it, making repositioning intuitive - -
    • diff --git a/frontend/src/pages/help/HelpSettingsApi.tsx b/frontend/src/pages/help/HelpSettingsApi.tsx index f057537..5ffb24c 100644 --- a/frontend/src/pages/help/HelpSettingsApi.tsx +++ b/frontend/src/pages/help/HelpSettingsApi.tsx @@ -1,12 +1,27 @@ /** * Help Settings API Page + * + * Documentation for managing API tokens for third-party integrations. */ import React from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { - ArrowLeft, Code, Key, Shield, Zap, FileText, - CheckCircle, ChevronRight, HelpCircle, Lock, + ArrowLeft, + Key, + Shield, + Plus, + Copy, + Trash2, + Clock, + Eye, + EyeOff, + CheckCircle, + ChevronRight, + HelpCircle, + AlertTriangle, + Lock, + Settings, } from 'lucide-react'; const HelpSettingsApi: React.FC = () => { @@ -14,144 +29,364 @@ const HelpSettingsApi: React.FC = () => { return (
      - + {/* Header */}
      -
      - +
      +
      -

      API Settings Guide

      -

      Integrate with external systems

      +

      + API Settings Guide +

      +

      + Manage API tokens for integrations +

      + {/* Overview */}

      - Overview + + Overview

      - The API allows you to programmatically access your scheduling data. Create integrations with your website, mobile app, or other business systems. -

      -

      - API access requires a Business plan or higher. + API Settings allows you to create and manage API tokens for integrating your scheduling + system with external applications. These tokens provide secure, controlled access to your + business data through our REST API.

      +
      + +

      + API access requires a plan with the api_access feature. Only the business + owner can manage API tokens. +

      +
      + {/* Creating API Tokens */}

      - API Keys + + Creating API Tokens

      -
      -
      - -
      -

      Generate Keys

      -

      Create new API keys for authentication

      -
      +

      + Click the New Token button to create a new API token. You'll need to configure: +

      + +
      + {/* Token Name */} +
      +

      Token Name

      +

      + Give your token a descriptive name to identify its purpose, like "Website Integration" + or "Mobile App". +

      -
      - -
      -

      Key Permissions

      -

      Set read/write access levels per key

      -
      + + {/* Permission Presets */} +
      +

      Permission Presets

      +

      + Choose from predefined permission sets: +

      +
        +
      • + + Read Only - Can only view data, no modifications +
      • +
      • + + Full Access - Complete read and write access +
      • +
      • + + Custom - Select individual permissions +
      • +
      -
      - -
      -

      Revoke Keys

      -

      Disable compromised or unused keys

      -
      + + {/* Individual Permissions */} +
      +

      Individual Permissions

      +

      + Expand "Show individual permissions" for granular control over what the token can access: + appointments, customers, services, resources, and more. +

      -
      - -
      -

      Rate Limits

      -

      View your API usage and limits

      -
      + + {/* Expiration */} +
      +

      + + Expiration +

      +

      + Set when the token should expire: +

      +
        +
      • Never expires
      • +
      • 7 days
      • +
      • 30 days
      • +
      • 90 days
      • +
      • 1 year
      • +
      + {/* Token Security */}

      - Available Endpoints + + Token Security

      -
      -
      -
      - GET /api/appointments -
      -
      - GET /api/customers -
      -
      - GET /api/services -
      -
      - GET /api/resources +
      +
      + +
      +

      + Important: Copy Your Token Immediately +

      +

      + When a token is created, you'll see the full token only once. Copy it + immediately and store it securely. You will not be able to view it again. +

      -

      See full documentation for complete endpoint list.

      +
      +

      + + Use the Copy button to copy the token to your clipboard +

      +

      + + Toggle visibility with the eye icon to verify the token +

      +
      + {/* Managing Tokens */}

      - Security Best Practices + + Managing Existing Tokens +

      +
      +

      + Your tokens are organized into Active Tokens and Revoked Tokens. + Click on any token to expand and view its details. +

      + +
      + {/* Token Information */} +
      +

      Token Information

      +

      + Each token displays: +

      +
        +
      • Token name and status (Active, Expired, or Revoked)
      • +
      • Key prefix (first few characters of the token)
      • +
      • Created date and who created it
      • +
      • Last used date
      • +
      • Expiration date
      • +
      • Assigned permissions
      • +
      +
      + + {/* Revoking Tokens */} +
      +

      + + Revoking Tokens +

      +

      + Click the trash icon to revoke a token. This action: +

      +
        +
      • Cannot be undone
      • +
      • Immediately disables API access for that token
      • +
      • Any applications using the token will lose access instantly
      • +
      +
      +
      +
      +
      + + {/* Best Practices */} +
      +

      + + Best Practices

      • - Never share API keys in public code repositories + + One token per integration - Create separate tokens for each application + or service that needs API access +
      • - Use separate keys for different integrations + + Minimum permissions - Grant only the permissions each integration needs +
      • - Rotate keys periodically for security + + Set expiration dates - Use expiring tokens for temporary integrations +
      • - Grant minimum required permissions + + Never share tokens publicly - Don't commit tokens to version control + or share them in public channels + +
      • +
      • + + + Rotate tokens regularly - Create new tokens and revoke old ones periodically + +
      • +
      • + + + Monitor last used dates - Revoke tokens that haven't been used recently +
      + {/* Step by Step */}
      -

      Related Features

      +

      + Step-by-Step: Create an API Token +

      +
      +
        +
      1. + + 1 + + + Go to Settings > API Settings + +
      2. +
      3. + + 2 + + + Click the New Token button + +
      4. +
      5. + + 3 + + + Enter a descriptive Token Name (e.g., "Website Booking Integration") + +
      6. +
      7. + + 4 + + + Select a Permission Preset or customize individual permissions + +
      8. +
      9. + + 5 + + + Choose an Expiration period if desired + +
      10. +
      11. + + 6 + + + Click Create Token + +
      12. +
      13. + + 7 + + + Immediately copy the token and store it securely - you won't see it again! + +
      14. +
      +
      +
      + + {/* Related Features */} +
      +

      + Related Features +

      - - - Plugins Guide - - - + Authentication Settings + + + Plugins Guide + +
      + {/* Need Help */}
      -

      Need More Help?

      -

      Our support team is ready to help with any questions.

      - +

      + Need More Help? +

      +

      + Our support team is ready to help with API integration questions. +

      +
      ); diff --git a/frontend/src/pages/help/HelpSettingsAppearance.tsx b/frontend/src/pages/help/HelpSettingsAppearance.tsx index 1f48f4f..03fddf9 100644 --- a/frontend/src/pages/help/HelpSettingsAppearance.tsx +++ b/frontend/src/pages/help/HelpSettingsAppearance.tsx @@ -1,12 +1,15 @@ /** - * Help Settings Appearance Page + * Help Settings Appearance (Branding) Page + * + * Comprehensive documentation for the Branding Settings page. + * Documents: Logo uploads (website/email), display modes, color palettes, custom colors with live preview. */ import React from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { - ArrowLeft, Palette, Image, Type, Layout, Eye, - CheckCircle, ChevronRight, HelpCircle, Sun, Moon, + ArrowLeft, Palette, Image, Upload, Eye, Save, + CheckCircle, ChevronRight, HelpCircle, AlertCircle, Sparkles, } from 'lucide-react'; const HelpSettingsAppearance: React.FC = () => { @@ -18,134 +21,365 @@ const HelpSettingsAppearance: React.FC = () => { Back + {/* Header */}
      -
      - +
      +
      -

      Appearance Settings Guide

      -

      Customize your brand and look

      +

      Branding Settings Guide

      +

      Customize your logos and brand colors

      + {/* Overview */}

      Overview

      - Appearance Settings let you customize how your booking page looks to customers. Upload your logo, set brand colors, and create a cohesive visual experience that matches your brand identity. + The Branding Settings page lets you customize how your business appears throughout SmoothSchedule. + Upload your logos, choose from preset color palettes, or define custom brand colors. Changes + preview instantly so you can see exactly how they'll look before saving.

      +
      +
      + +

      + Owner Access Required: Only business owners can modify branding settings. + This feature may be limited based on your subscription plan. +

      +
      +
      + {/* Brand Logos */}

      - Branding Options + Brand Logos

      -
      -
      - -
      -

      Logo

      -

      Upload your business logo for the booking page

      +

      + You can upload two different logos for different contexts. PNG images with transparent + backgrounds are recommended for the best appearance across light and dark themes. +

      + +
      + {/* Website Logo */} +
      +

      + Website Logo +

      +

      + This logo appears in the sidebar navigation and on customer-facing booking pages. +

      +
      +
      + Recommended Size: + 500×500 pixels +
      +
      + Supported Formats: + PNG, JPEG, SVG +
      -
      - -
      -

      Favicon

      -

      Small icon shown in browser tabs

      + + {/* Email Logo */} +
      +

      + Email Logo +

      +

      + This logo appears at the top of all email notifications sent to customers (booking + confirmations, reminders, etc.). A wider format works best for email headers. +

      +
      +
      + Recommended Size: + 600×200 pixels (wide) +
      +
      + Supported Formats: + PNG, JPEG, SVG +
      + + {/* Upload Instructions */} +
      +

      Uploading a Logo

      +
        +
      1. Click the Upload button next to the logo type
      2. +
      3. Select an image file from your computer
      4. +
      5. The logo preview will update immediately
      6. +
      7. Click Save Changes to apply the logo
      8. +
      +

      + To remove a logo, click the × button on the preview image, then save. +

      +
      +
      +
      +
      + + {/* Logo Display Mode */} +
      +

      + Logo Display Mode +

      +
      +

      + Control how your website logo is displayed in the sidebar and on booking pages. + This setting only affects the website logo, not the email logo. +

      + +
      - +
      + Aa +
      -

      Brand Color

      -

      Primary color used throughout the interface

      +

      Text Only

      +

      + Display only your business name in text. Good if you don't have a logo or prefer a clean look. +

      +
      - +
      + +
      -

      Custom CSS

      -

      Advanced styling with custom CSS (Pro plan)

      +

      Logo Only

      +

      + Display only your uploaded logo image without text. Best for recognizable brand marks. +

      +
      +
      + +
      +
      +
      + + + +
      +
      +
      +

      Logo and Text

      +

      + Display both your logo and business name together. Ideal for full brand recognition. +

      + {/* Color Palettes */}

      - Theme Options + Color Palettes

      -
      -
      - -
      -

      Light Mode

      -

      Clean, bright interface for daytime use

      +

      + Choose from 10 professionally designed color palettes. Each palette includes a primary + and secondary color that work well together. Click any palette to instantly preview how + it looks throughout the interface. +

      + +
      + {[ + { name: 'Ocean Blue', colors: ['#2563eb', '#0ea5e9'] }, + { name: 'Sky Blue', colors: ['#0ea5e9', '#38bdf8'] }, + { name: 'Mint Green', colors: ['#10b981', '#34d399'] }, + { name: 'Coral Reef', colors: ['#f97316', '#fb923c'] }, + { name: 'Lavender', colors: ['#a78bfa', '#c4b5fd'] }, + { name: 'Rose Pink', colors: ['#ec4899', '#f472b6'] }, + { name: 'Forest Green', colors: ['#059669', '#10b981'] }, + { name: 'Royal Purple', colors: ['#7c3aed', '#a78bfa'] }, + { name: 'Slate Gray', colors: ['#475569', '#64748b'] }, + { name: 'Crimson Red', colors: ['#dc2626', '#ef4444'] }, + ].map((palette) => ( +
      +
      + {palette.name}
      -
      -
      - + ))} +
      + +
      +
      +
      -

      Dark Mode

      -

      Easy on the eyes in low-light conditions

      +

      Live Preview

      +

      + When you click a palette, the interface instantly updates to show those colors. + If you navigate away without saving, colors revert to your saved settings. +

      + {/* Custom Colors */} +
      +

      + Custom Colors +

      +
      +

      + If the preset palettes don't match your brand, you can define your own colors using + the color picker or by entering hex color codes directly. +

      + +
      +
      +
      +

      Primary Color

      +

      + Used for buttons, links, and interactive elements. This is your main brand color. +

      +
      +
      +

      Secondary Color

      +

      + Used for accents, hover states, and gradients. Should complement the primary color. +

      +
      +
      + +
      +

      Setting Custom Colors

      +
        +
      1. Click the color swatch to open the color picker
      2. +
      3. Select a color visually or enter a hex code (e.g., #3b82f6)
      4. +
      5. The preview bar shows the gradient of your primary and secondary colors
      6. +
      7. Click Save Changes to apply your custom colors
      8. +
      +
      +
      +
      +
      + + {/* Saving Changes */} +
      +

      + Saving Changes +

      +
      +

      + All branding changes (logos, display mode, and colors) are saved together when you + click the Save Changes button at the bottom of the page. +

      + +
      +
      + +
      +

      Changes Save Together

      +

      + Logos, display mode, and colors are all saved in one action +

      +
      +
      +
      + +
      +

      Unsaved Changes Revert

      +

      + If you leave the page without saving, colors revert to your last saved settings +

      +
      +
      +
      +
      +
      + + {/* Tips */}

      Tips

      -
      +
      • - Use a high-resolution logo (at least 200x200 pixels) + + Use PNG images with transparent backgrounds for logos to look best on both light and dark themes +
      • - Choose brand colors that provide good contrast for readability + + Test color readability by previewing your booking page in both light and dark modes +
      • - Test your booking page in both light and dark modes + + Choose colors that provide good contrast for text readability and accessibility + +
      • +
      • + + + Consider using your existing brand colors from your website or marketing materials +
      + {/* Related Features */}
      -

      Related Settings

      +

      Related Features

      General Settings + + + Booking Settings + + + + + Email Settings + + - + Custom Domains
      + {/* Help Footer */}

      Need More Help?

      -

      Our support team is ready to help with any questions.

      - +

      + Our support team is ready to help with any branding questions. +

      +
      ); diff --git a/frontend/src/pages/help/HelpSettingsAuth.tsx b/frontend/src/pages/help/HelpSettingsAuth.tsx index dfd80ca..3d03e37 100644 --- a/frontend/src/pages/help/HelpSettingsAuth.tsx +++ b/frontend/src/pages/help/HelpSettingsAuth.tsx @@ -1,12 +1,24 @@ /** * Help Settings Authentication Page + * + * Documentation for configuring OAuth providers and social login for customers. */ import React from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { - ArrowLeft, Shield, Key, Lock, Users, Smartphone, - CheckCircle, ChevronRight, HelpCircle, LogIn, + ArrowLeft, + Lock, + Users, + Key, + CheckCircle, + ChevronRight, + HelpCircle, + Save, + Eye, + EyeOff, + AlertCircle, + Check, } from 'lucide-react'; const HelpSettingsAuth: React.FC = () => { @@ -14,137 +26,328 @@ const HelpSettingsAuth: React.FC = () => { return (
      - + {/* Header */}
      -
      - +
      +
      -

      Authentication Settings Guide

      -

      Configure login and security

      +

      + Authentication Settings Guide +

      +

      + Configure social login for customers +

      + {/* Overview */}

      - Overview + + Overview

      - Authentication Settings control how users log in to your system. Configure security policies, enable two-factor authentication, and manage social login options. + Authentication Settings allows you to configure how customers can sign in to your booking + system. Enable social login providers like Google, Apple, and Facebook to make it easier + for customers to create accounts and book appointments.

      -
      -
      - -
      -

      - Security Features -

      -
      -
      -
      - -
      -

      Two-Factor Auth (2FA)

      -

      Require additional verification for logins

      -
      -
      -
      - -
      -

      Password Policies

      -

      Set minimum password requirements

      -
      -
      -
      - -
      -

      Session Timeout

      -

      Auto-logout after inactivity

      -
      -
      -
      - -
      -

      Login Attempts

      -

      Lock accounts after failed attempts

      -
      -
      +
      + +

      + OAuth settings require a plan with the custom_oauth feature. Only the + business owner can configure authentication settings. +

      + {/* Social Login */}

      - Login Options + + Social Login Providers

      +

      + Enable or disable social login providers that customers can use to sign in. Click on a + provider to toggle it on or off. +

      + +
      +
      + 🔍 +

      Google

      +
      +
      + 🍎 +

      Apple

      +
      +
      + 📘 +

      Facebook

      +
      +
      + 💼 +

      LinkedIn

      +
      +
      + 🪧 +

      Microsoft

      +
      +
      + 🐦 +

      X (Twitter)

      +
      +
      + 🎮 +

      Twitch

      +
      +
      + +
      + +

      + Enabled providers show a checkmark badge. Click Save after making changes. +

      +
      +
      +
      + + {/* OAuth Settings */} +
      +

      + + OAuth Settings +

      +
      +

      + Configure additional options for OAuth authentication: +

      +
      -
      - -
      -

      Email & Password

      -

      Traditional login with email and password

      + {/* Allow Registration */} +
      +
      +
      +

      Allow OAuth Registration

      +

      + When enabled, new customers can create accounts using OAuth providers. When disabled, + only existing customers can sign in via OAuth. +

      +
      -
      - -
      -

      Social Login

      -

      Sign in with Google, Apple, or other providers

      -
      -
      -
      - -
      -

      SSO (Single Sign-On)

      -

      Enterprise integration with SAML/OIDC

      + + {/* Auto-link by Email */} +
      +
      +
      +

      Auto-link by Email

      +

      + When enabled, if a customer signs in with OAuth and their email matches an existing + account, the accounts are automatically linked. This lets customers sign in with + multiple methods. +

      +
      + +
      + +

      + Click the Save button to apply your changes. +

      +
      + {/* Custom OAuth Credentials */}

      - Security Tips + + Custom OAuth Credentials +

      +
      +
      + +

      + This section only appears if your platform administrator has granted permission to + manage OAuth credentials. +

      +
      + +

      + By default, your business uses the platform's shared OAuth credentials. If you want + complete branding control, you can use your own OAuth app credentials. +

      + +
      + {/* Toggle Custom Credentials */} +
      +

      Use Custom Credentials

      +

      + Toggle this on to use your own OAuth credentials instead of the platform's shared + credentials. When using custom credentials, the OAuth consent screen will show your + app name and branding. +

      +
      + + {/* Provider Credentials */} +
      +

      Provider Credentials

      +

      + Expand each provider to enter your credentials: +

      +
        +
      • Client ID - Your OAuth app's client identifier
      • +
      • Client Secret - Your OAuth app's secret key
      • +
      +
      + + {/* Provider-specific Fields */} +
      +

      Provider-Specific Fields

      +

      + Some providers require additional information: +

      +
        +
      • Apple - Team ID and Key ID
      • +
      • Microsoft - Tenant ID (or "common" for multi-tenant)
      • +
      +
      +
      + +
      + +

      + Client secrets are hidden by default. Click the icon + to reveal them. +

      +
      +
      +
      + + {/* Step by Step */} +
      +

      + Step-by-Step: Enable Social Login +

      +
      +
        +
      1. + + 1 + + + Go to Settings > Authentication + +
      2. +
      3. + + 2 + + + In the Social Login section, click on the providers you want to enable + +
      4. +
      5. + + 3 + + + Configure Allow OAuth Registration if you want new customers to sign up + +
      6. +
      7. + + 4 + + + Enable Auto-link by Email to let customers use multiple sign-in methods + +
      8. +
      9. + + 5 + + + Click Save to apply your changes + +
      10. +
      +
      +
      + + {/* Tips */} +
      +

      + + Tips

      • - Enable 2FA for all staff accounts + + Enable Google and Apple at minimum - these are the most popular sign-in methods +
      • - Use strong, unique passwords for each service + + Keep Auto-link by Email enabled to prevent duplicate accounts +
      • - Review login activity regularly + + Only enable Allow OAuth Registration if you want new customers to create + accounts via OAuth +
      • - Set appropriate session timeouts + + Custom OAuth credentials are recommended for businesses that want their own branding + on the sign-in consent screen +
      + {/* Related Features */}
      -

      Related Features

      +

      + Related Features +

      - + - Staff Guide + Customers Guide - + API Settings @@ -152,11 +355,21 @@ const HelpSettingsAuth: React.FC = () => {
      + {/* Need Help */}
      -

      Need More Help?

      -

      Our support team is ready to help with any questions.

      - +

      + Need More Help? +

      +

      + Our support team is ready to help with authentication configuration. +

      +
      ); diff --git a/frontend/src/pages/help/HelpSettingsBilling.tsx b/frontend/src/pages/help/HelpSettingsBilling.tsx index d18fb8b..d82a0b6 100644 --- a/frontend/src/pages/help/HelpSettingsBilling.tsx +++ b/frontend/src/pages/help/HelpSettingsBilling.tsx @@ -1,12 +1,16 @@ /** * Help Settings Billing Page + * + * Comprehensive documentation for the Plan & Billing Settings page. + * Documents: Current plan, subscriptions, add-ons, payment methods, invoices, upgrade flow. */ import React from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { - ArrowLeft, CreditCard, FileText, Clock, TrendingUp, Shield, - CheckCircle, ChevronRight, HelpCircle, DollarSign, + ArrowLeft, CreditCard, Crown, Package, Wallet, FileText, + CheckCircle, ChevronRight, HelpCircle, AlertCircle, Calendar, + Plus, Trash2, Star, RotateCcw, X, Zap, ArrowRight, } from 'lucide-react'; const HelpSettingsBilling: React.FC = () => { @@ -18,135 +22,351 @@ const HelpSettingsBilling: React.FC = () => { Back + {/* Header */}
      -
      - +
      +
      -

      Billing Settings Guide

      -

      Manage your subscription and payments

      +

      Plan & Billing Guide

      +

      Manage your subscription, add-ons, and payment methods

      + {/* Overview */}

      Overview

      - Billing Settings let you manage your subscription plan, update payment methods, view invoices, and monitor your usage. Keep your account in good standing to maintain access to all features. + The Plan & Billing page is where you manage your SmoothSchedule subscription. View your current plan, + upgrade or change plans, add feature add-ons, manage payment methods, and access your billing history.

      -
      -
      - -
      -

      - Subscription Management -

      -
      -
      -
      - -
      -

      Current Plan

      -

      View your active subscription tier and features

      -
      -
      -
      - -
      -

      Payment Method

      -

      Update credit card or payment details

      -
      -
      -
      - -
      -

      Invoices

      -

      Download past invoices and receipts

      -
      -
      -
      - -
      -

      Billing Cycle

      -

      View next billing date and renewal info

      -
      +
      +
      + +

      + Owner Access Required: Only business owners can access billing settings. +

      + {/* Current Plan */}

      - Plan Features + Current Plan

      - Different plans unlock different features: + The Current Plan section shows your active subscription tier, its monthly price, and included features.

      -
      -
      - Free - - Basic scheduling, limited resources -
      -
      - Pro - - More resources, custom branding, payments -
      -
      - Business - - Unlimited resources, API access, priority support +
      +
      +

      What You'll See

      +
        +
      • • Plan Name: Your current tier (Free, Starter, Professional, Enterprise)
      • +
      • • Monthly Price: What you pay per month (or "Contact Us" for Enterprise)
      • +
      • • Key Features: List of what's included in your plan
      • +
      • • Upgrade Button: Opens the plan selection modal
      • +
      + {/* Active Subscriptions */} +
      +

      + Active Subscriptions +

      +
      +

      + This section lists all your active subscriptions, including your main plan and any add-ons you've purchased. +

      + +
      + {/* Subscription Details */} +
      +

      For Each Subscription

      +
        +
      • • Plan Name: The subscription's name
      • +
      • • Type Badge: "Plan" for main subscription, "Add-on" for extras
      • +
      • • Price & Interval: Amount charged and billing frequency (monthly/yearly)
      • +
      • • Next Billing Date: When you'll be charged next
      • +
      +
      + + {/* Cancellation States */} +
      +
      +
      + +

      Cancel Subscription

      +
      +

      + Cancels your subscription. You can choose to cancel at period end (keeps access until then) or immediately. +

      +
      +
      +
      + +

      Reactivate

      +
      +

      + If you've cancelled but your subscription hasn't ended yet, you can reactivate it. +

      +
      +
      + + {/* Cancelling Status */} +
      +

      Cancelling Status

      +

      + If you cancelled at period end, the subscription shows a "Cancelling" badge and displays when it will end. + You can still reactivate before that date. +

      +
      +
      +
      +
      + + {/* Available Add-ons */} +
      +

      + Available Add-ons +

      +
      +

      + Add-ons let you enhance your subscription with additional features beyond what's included in your base plan. +

      + +
      +
      +

      How Add-ons Work

      +
        +
      • • Each add-on has its own monthly price
      • +
      • • Click "Add" to purchase - you'll be taken to Stripe checkout
      • +
      • • Add-ons appear in your Active Subscriptions once purchased
      • +
      • • Cancel anytime just like your main subscription
      • +
      +
      +
      +
      +
      + + {/* Wallet */} +
      +

      + Wallet +

      +
      +

      + The Wallet section provides a quick link to manage your communication credits (SMS & Calling). + Credits are used for sending SMS reminders and making calls through the platform. +

      +
      +

      + To manage credits, top up your balance, or configure auto-reload, go to{' '} + Settings → SMS & Calling. +

      +
      +
      +
      + + {/* Payment Methods */} +
      +

      + Payment Methods +

      +
      +

      + Manage the credit cards used for your subscriptions and credit purchases. +

      + +
      + {/* Card Display */} +
      +

      Each Card Shows

      +
        +
      • • Card Brand: Visa, Mastercard, Amex, etc.
      • +
      • • Last 4 Digits: •••• 4242
      • +
      • • Expiration: Month/Year the card expires
      • +
      • • Default Badge: Shows which card is used for automatic payments
      • +
      +
      + + {/* Card Actions */} +
      +
      +
      + +

      Add Card

      +
      +

      + Opens Stripe to securely add a new card +

      +
      +
      +
      + +

      Set Default

      +
      +

      + Make a card the default for payments +

      +
      +
      +
      + +

      Remove

      +
      +

      + Delete a card from your account +

      +
      +
      +
      +
      +
      + + {/* Billing History */} +
      +

      + Billing History +

      +
      +

      + View and download invoices for all your past payments. Invoices are generated automatically + after each successful charge. +

      +
      +

      + Each invoice includes the date, amount, what was purchased, and a download link for your records. + Use these for expense tracking, tax purposes, or reimbursement requests. +

      +
      +
      +
      + + {/* Upgrading Your Plan */} +
      +

      + Upgrading Your Plan +

      +
      +

      + Click the "Upgrade Plan" button to see all available plans and change your subscription. +

      + +
      +
      +

      How to Upgrade

      +
        +
      1. Click Upgrade Plan in the Current Plan section
      2. +
      3. Review the available plans and their features
      4. +
      5. Click Upgrade on your desired plan
      6. +
      7. Complete checkout through Stripe
      8. +
      9. Your new plan activates immediately
      10. +
      +
      + + {/* Plan Comparison Note */} +
      +
      + +
      +

      Transaction Fees

      +

      + Higher tier plans include lower transaction fees on payment processing. + Enterprise plans offer custom pricing - contact support for details. +

      +
      +
      +
      +
      +
      +
      + + {/* Tips */}

      Tips

      -
      +
      • - Keep payment methods up to date to avoid service interruption + + Keep payment methods up to date to avoid service interruption +
      • - Annual billing saves money compared to monthly + + If you cancel at period end, you can reactivate anytime before it expires +
      • - Download invoices for your accounting records + + Download invoices regularly for your accounting records + +
      • +
      • + + + Add-ons are billed separately and can be cancelled independently +
      + {/* Related Features */}
      -

      Related Settings

      +

      Related Features

      - + Usage & Quota - + Payments Guide + + + General Settings + + + + + Branding Settings + +
      + {/* Help Footer */}

      Need More Help?

      -

      Our support team is ready to help with any questions.

      - +

      + Our support team is ready to help with billing questions. +

      +
      ); diff --git a/frontend/src/pages/help/HelpSettingsBooking.tsx b/frontend/src/pages/help/HelpSettingsBooking.tsx index 99db628..1bec6f3 100644 --- a/frontend/src/pages/help/HelpSettingsBooking.tsx +++ b/frontend/src/pages/help/HelpSettingsBooking.tsx @@ -1,12 +1,15 @@ /** * Help Settings Booking Page + * + * Comprehensive documentation for the Booking Settings page. + * Documents: Booking URL display/sharing, Return URL configuration. */ import React from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { - ArrowLeft, Calendar, Clock, Bell, Shield, CreditCard, - CheckCircle, ChevronRight, HelpCircle, AlertCircle, + ArrowLeft, Calendar, Link2, ExternalLink, Copy, Share2, + CheckCircle, ChevronRight, HelpCircle, AlertCircle, Globe, } from 'lucide-react'; const HelpSettingsBooking: React.FC = () => { @@ -18,6 +21,7 @@ const HelpSettingsBooking: React.FC = () => { Back + {/* Header */}
      @@ -25,141 +29,286 @@ const HelpSettingsBooking: React.FC = () => {

      Booking Settings Guide

      -

      Configure how customers book appointments

      +

      Configure your booking URL and customer redirect

      + {/* Overview */}

      Overview

      - Booking Settings control how customers interact with your scheduling system. Configure booking windows, cancellation policies, confirmation requirements, and more. + The Booking Settings page is where you find your public booking URL to share with customers + and configure where customers go after completing a booking. This is an owner-only section + that controls how customers access your scheduling system.

      +
      +
      + +

      + Owner Access Required: Only business owners can view and modify booking settings. +

      +
      +
      + {/* Your Booking URL */}

      - Time Settings + Your Booking URL

      -
      -
      - -
      -

      Booking Window

      -

      How far in advance customers can book

      -
      +

      + Your booking URL is the public link where customers can view your services and book appointments. + It's based on your business subdomain and is automatically generated when you create your business. +

      + +
      + {/* URL Format */} +
      +

      URL Format

      + + https://[your-subdomain].smoothschedule.com + +

      + Your subdomain was set when you created your business and cannot be changed. +

      -
      - -
      -

      Minimum Notice

      -

      Minimum time before appointment for booking

      + + {/* Actions Available */} +
      +
      + +
      +

      Copy to Clipboard

      +

      + Click the copy icon to copy the full URL to your clipboard for easy sharing +

      +
      -
      -
      - -
      -

      Buffer Time

      -

      Gap between consecutive appointments

      -
      -
      -
      - -
      -

      Time Slots

      -

      Interval between available booking times

      +
      + +
      +

      Open Booking Page

      +

      + Click the external link icon to open your booking page in a new tab +

      +
      + {/* Sharing Your Booking URL */}

      - Policies + Sharing Your Booking URL

      +

      + Once you've copied your booking URL, you can share it in many ways to help customers find you: +

      -
      - -
      -

      Cancellation Policy

      -

      How late customers can cancel without penalty

      -
      +
      +

      Website

      +

      Add a "Book Now" button linking to your URL

      -
      - -
      -

      Confirmation Required

      -

      Require staff to confirm new bookings

      -
      +
      +

      Email Signature

      +

      Include your booking link in every email

      -
      - -
      -

      Deposit Required

      -

      Collect payment upfront for bookings

      -
      +
      +

      Social Media

      +

      Add to your bio on Instagram, Facebook, etc.

      -
      - -
      -

      Auto-Confirm

      -

      Automatically confirm bookings instantly

      -
      +
      +

      Business Cards

      +

      Print a QR code or short URL on cards

      + {/* Return URL */}

      - Best Practices + Return URL +

      +
      +

      + The Return URL controls where customers are redirected after successfully completing a booking. + This is optional but useful for providing a seamless experience that connects back to your website. +

      + +
      + {/* How It Works */} +
      +

      How It Works

      +
        +
      1. Customer completes their booking on your SmoothSchedule page
      2. +
      3. If a Return URL is set, they're automatically redirected to that page
      4. +
      5. If no Return URL is set, they stay on the booking confirmation page
      6. +
      +
      + + {/* Example Uses */} +
      +

      Common Uses

      +
      +
      + +
      +
      Thank You Page
      +

      + Redirect to a custom thank you page on your website with additional info +

      + + https://yourbusiness.com/thank-you + +
      +
      +
      + +
      +
      Preparation Instructions
      +

      + Send customers to a page with info about what to bring or how to prepare +

      + + https://yourbusiness.com/appointment-prep + +
      +
      +
      + +
      +
      Homepage
      +

      + Simply return customers to your main website +

      + + https://yourbusiness.com + +
      +
      +
      +
      + + {/* Setting the Return URL */} +
      +

      Setting the Return URL

      +
        +
      1. Enter the full URL including https://
      2. +
      3. Click the Save button
      4. +
      5. A confirmation toast will appear when saved successfully
      6. +
      +

      + To remove the Return URL and keep customers on the confirmation page, clear the field and save. +

      +
      +
      +
      +
      + + {/* Custom Domains */} +
      +

      + Want Your Own Domain? +

      +
      +

      + If you'd prefer customers to book at your own domain (like book.yourbusiness.com instead of + yourbusiness.smoothschedule.com), you can set up a custom domain. +

      + + Set up a custom domain + +
      +
      + + {/* Tips */} +
      +

      + Tips

      • - Set minimum notice time to avoid last-minute bookings + + Test your booking URL by opening it in an incognito/private browser window to see what customers see +
      • - Use buffer time if services need cleanup or preparation + + Make sure your Return URL is accessible and mobile-friendly before setting it +
      • - Consider deposits for high-value services to reduce no-shows + + Use a URL shortener if you need a simpler link for print materials + +
      • +
      • + + + The confirmation page shows booking details, so leaving customers there is often fine +
      + {/* Related Features */}

      Related Features

      - + - Scheduler Guide + General Settings - - - Payments Guide + + + Appearance Settings + + + + + Services Guide + + + + + Custom Domains
      + {/* Help Footer */}

      Need More Help?

      -

      Our support team is ready to help with any questions.

      - +

      + Our support team is ready to help with any questions about your booking setup. +

      +
      ); diff --git a/frontend/src/pages/help/HelpSettingsDomains.tsx b/frontend/src/pages/help/HelpSettingsDomains.tsx index 73c5742..0ca5567 100644 --- a/frontend/src/pages/help/HelpSettingsDomains.tsx +++ b/frontend/src/pages/help/HelpSettingsDomains.tsx @@ -1,12 +1,25 @@ /** * Help Settings Custom Domains Page + * + * Documentation for managing custom domains and domain purchases. */ import React from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { - ArrowLeft, Globe, Shield, Link as LinkIcon, CheckCircle, - ChevronRight, HelpCircle, Settings, AlertCircle, + ArrowLeft, + Globe, + Copy, + Star, + Trash2, + RefreshCw, + CheckCircle, + AlertCircle, + ShoppingCart, + ChevronRight, + HelpCircle, + Lock, + Settings, } from 'lucide-react'; const HelpSettingsDomains: React.FC = () => { @@ -14,151 +27,379 @@ const HelpSettingsDomains: React.FC = () => { return (
      - + {/* Header */}
      -
      - +
      +
      -

      Custom Domains Guide

      -

      Use your own domain for booking

      +

      + Custom Domains Guide +

      +

      + Use your own domain for booking pages +

      + {/* Overview */}

      - Overview + + Overview

      - Custom Domains let you use your own domain name (like booking.yourcompany.com) instead of the default subdomain. This creates a more professional, branded experience for your customers. -

      -

      - Custom domains are available on Pro and Business plans. + Custom Domains lets you use your own domain (like booking.yourcompany.com) + for your booking pages. You can connect a domain you already own, or purchase a new one directly through the platform.

      +
      + +

      + Custom domains require a plan with the custom_domain feature. Only the + business owner can manage custom domains. +

      +
      + {/* Bring Your Own Domain */}

      - Setup Process + + Bring Your Own Domain +

      +
      +

      + Connect a domain you already own by following these steps: +

      + +
        +
      1. + + 1 + +
        +

        Add Your Domain

        +

        + Enter your domain in the input field (e.g., booking.yourdomain.com) + and click Add. +

        +
        +
      2. +
      3. + + 2 + +
        +

        Add DNS TXT Record

        +

        + Your domain will show as Pending with DNS instructions. Add the TXT record + to your DNS provider: +

        +
        +
        + Name: + + _smoothschedule-verify + + +
        +
        + Value: + + verify=abc123... + + +
        +
        +
        +
      4. +
      5. + + 3 + +
        +

        Verify Domain

        +

        + Click the verify button. + Once verified, the status changes to Verified. +

        +
        +
      6. +
      + +
      +

      + + DNS changes can take up to 48 hours to propagate worldwide. +

      +
      +
      +
      + + {/* Managing Domains */} +
      +

      + + Managing Your Domains +

      +
      +

      + Your domain list shows all connected domains with their status and actions: +

      + +
      + {/* Domain Status */} +
      +

      Domain Status

      +
        +
      • + + Verified + + Domain ownership confirmed, ready to use +
      • +
      • + + Pending + + Waiting for DNS verification +
      • +
      • + + Primary + + The main domain used for your booking pages +
      • +
      +
      + + {/* Actions */} +
      +

      Available Actions

      +
        +
      • + + Verify - Check DNS configuration and verify ownership +
      • +
      • + + Set Primary - Make this your main booking domain +
      • +
      • + + Delete - Remove domain from your account +
      • +
      +
      +
      +
      +
      + + {/* Domain Purchase */} +
      +

      + + Purchase a Domain +

      +
      +

      + Don't have a domain? You can search for and register a new domain directly from this page. +

      + +
      +
      +

      + How Domain Purchase Works +

      +
        +
      1. Enter the domain name you want to register
      2. +
      3. See availability and pricing
      4. +
      5. Complete payment to register the domain
      6. +
      7. Domain is automatically connected to your account
      8. +
      +
      + +
      +

      + Purchased domains are registered in your name and include automatic SSL certificates. + Domain pricing varies by extension (.com, .net, etc.). +

      +
      +
      +
      +
      + + {/* Step by Step */} +
      +

      + Step-by-Step: Connect Your Domain

      1. - 1 -
        -

        Add Domain

        -

        Enter your custom domain in the settings

        -
        + + 1 + + + Go to Settings > Custom Domains +
      2. - 2 -
        -

        Configure DNS

        -

        Add CNAME record pointing to our servers

        -
        + + 2 + + + In the Bring Your Own Domain section, enter your domain +
      3. - 3 -
        -

        Verify

        -

        Click verify to confirm DNS is configured

        -
        + + 3 + + + Click Add to add the domain +
      4. - 4 -
        -

        SSL Certificate

        -

        We automatically provision an SSL certificate

        -
        + + 4 + + + Copy the DNS TXT record details shown + +
      5. +
      6. + + 5 + + + Log into your domain registrar and add the TXT record + +
      7. +
      8. + + 6 + + + Wait for DNS propagation (usually a few minutes, up to 48 hours) + +
      9. +
      10. + + 7 + + + Click the Verify button + +
      11. +
      12. + + 8 + + + Once verified, click to Set as Primary if desired +
      + {/* Tips */}

      - DNS Configuration -

      -
      -

      - Add this CNAME record to your DNS provider: -

      -
      -
      -
      - Type: -
      CNAME
      -
      -
      - Name: -
      booking
      -
      -
      - Value: -
      cname.smoothschedule.com
      -
      -
      -
      -

      - DNS changes can take up to 48 hours to propagate. -

      -
      -
      - -
      -

      - Benefits + + Tips

      • - Professional: Use your own branded domain + + Use a subdomain like booking.yourcompany.com + rather than your main domain +
      • - Trust: Customers see your domain, not ours + + If verification fails, double-check the TXT record name and value match exactly +
      • - SEO: Build search rankings on your domain + + Use an online DNS lookup tool to check if your TXT record is published +
      • - SSL: Automatic HTTPS encryption included + + Set your most important domain as Primary - it will be used in booking links + +
      • +
      • + + + You can connect multiple domains and switch between them as needed +
      + {/* Related Features */}
      -

      Related Settings

      +

      + Related Features +

      - + - General Settings + Appearance Settings - - - Appearance Settings + + + Booking Settings
      + {/* Need Help */}
      -

      Need More Help?

      -

      Our support team is ready to help with any questions.

      - +

      + Need More Help? +

      +

      + Our support team is ready to help with domain configuration. +

      +
      ); diff --git a/frontend/src/pages/help/HelpSettingsEmail.tsx b/frontend/src/pages/help/HelpSettingsEmail.tsx index 809af8c..f3b4adf 100644 --- a/frontend/src/pages/help/HelpSettingsEmail.tsx +++ b/frontend/src/pages/help/HelpSettingsEmail.tsx @@ -1,12 +1,15 @@ /** - * Help Settings Email Templates Page + * Help Settings Email Page + * + * Comprehensive documentation for the Email Setup Settings page. + * Documents: Ticket email addresses for receiving and sending support tickets via IMAP/SMTP. */ import React from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { - ArrowLeft, Mail, FileText, Edit, Eye, Bell, - CheckCircle, ChevronRight, HelpCircle, Code, + ArrowLeft, Mail, Plus, Edit, Trash2, Star, TestTube, + RefreshCw, CheckCircle, ChevronRight, HelpCircle, AlertCircle, XCircle, } from 'lucide-react'; const HelpSettingsEmail: React.FC = () => { @@ -18,138 +21,320 @@ const HelpSettingsEmail: React.FC = () => { Back + {/* Header */}
      -
      - +
      +
      -

      Email Templates Guide

      -

      Customize automated emails

      +

      Email Setup Guide

      +

      Configure email addresses for ticket support

      + {/* Overview */}

      Overview

      - Email Templates let you customize the automated emails sent to customers. Personalize confirmation messages, reminders, and notifications to match your brand voice. + Email Setup lets you configure email addresses for your ticketing system. When customers email + these addresses, their messages automatically become support tickets. You can also send ticket + responses back via these email addresses.

      -
      -
      - -
      -

      - Template Types -

      -
      -
      -
      - -
      -

      Booking Confirmation

      -

      Sent when appointment is booked

      -
      -
      -
      - -
      -

      Appointment Reminder

      -

      Sent before the appointment

      -
      -
      -
      - -
      -

      Reschedule Notice

      -

      Sent when appointment is changed

      -
      -
      -
      - -
      -

      Cancellation

      -

      Sent when appointment is cancelled

      -
      +
      +
      + +

      + Owner Access Required: Only business owners can configure ticket email addresses. +

      + {/* Email Address List */}

      - Template Variables + Your Email Addresses

      - Use these variables in your templates to include dynamic content: + Each configured email address appears as a card showing:

      -
      -
      - {"{{customer_name}}"} - Customer's full name + +
      +
      +

      For Each Email Address

      +
        +
      • • Display Name: A friendly name for the address (e.g., "Support", "Sales")
      • +
      • • Email Address: The actual email (e.g., support@yourbusiness.com)
      • +
      • • Color: Left border color for visual identification
      • +
      • • Emails Processed: How many emails have been converted to tickets
      • +
      • • Last Checked: When emails were last fetched
      • +
      -
      - {"{{service_name}}"} - Name of the booked service -
      -
      - {"{{appointment_date}}"} - Date of appointment -
      -
      - {"{{appointment_time}}"} - Time of appointment -
      -
      - {"{{business_name}}"} - Your business name + + {/* Status Badges */} +
      +
      +
      + + Default +
      +

      + Primary address for outgoing ticket emails +

      +
      +
      +
      + + Active +
      +

      + Address is being monitored for new emails +

      +
      +
      +
      + + Inactive +
      +

      + Address is configured but not checking +

      +
      + {/* Actions */}

      - Best Practices + Available Actions +

      +
      +

      + Each email address has action buttons for testing and management: +

      + +
      +
      +
      + +

      Set as Default

      +
      +

      + Make this the default address for sending outgoing ticket emails +

      +
      +
      +
      + +

      Test IMAP

      +
      +

      + Test the incoming email (IMAP) connection to verify configuration +

      +
      +
      +
      + +

      Fetch Emails Now

      +
      +

      + Manually check for new emails and create tickets immediately +

      +
      +
      +
      + +

      Edit

      +
      +

      + Modify the email address settings, IMAP/SMTP configuration +

      +
      +
      +
      + +

      Delete

      +
      +

      + Remove the email address (with confirmation prompt) +

      +
      +
      +
      +
      + + {/* Adding an Email Address */} +
      +

      + Adding an Email Address +

      +
      +

      + Click the "Add Email Address" button to configure a new ticket email. You'll need IMAP and SMTP + credentials from your email provider. +

      + +
      +
      +

      Required Information

      +
      +
      + Display Name +

      Friendly name like "Support" or "Sales Inquiries"

      +
      +
      + Email Address +

      The full email address (e.g., support@example.com)

      +
      +
      + IMAP Settings +

      Server, port, username, and password for receiving emails

      +
      +
      + SMTP Settings +

      Server, port, username, and password for sending emails

      +
      +
      + Color +

      Choose a color to identify this address in the ticket list

      +
      +
      +
      + +
      +

      Where to Find IMAP/SMTP Settings

      +

      + Your email provider (Gmail, Outlook, etc.) has documentation with IMAP and SMTP server details. + Search for "[your provider] IMAP settings" or check your email account settings. +

      +
      +
      +
      +
      + + {/* How It Works */} +
      +

      + How Email-to-Ticket Works +

      +
      +
      +
      + 1 +
      +

      Customer Sends Email

      +

      A customer emails your configured address (e.g., support@yourbusiness.com)

      +
      +
      +
      + 2 +
      +

      System Fetches Email

      +

      SmoothSchedule checks the mailbox via IMAP and retrieves new messages

      +
      +
      +
      + 3 +
      +

      Ticket Created

      +

      A new ticket is created with the email content and sender information

      +
      +
      +
      + 4 +
      +

      You Respond

      +

      When you reply to the ticket, your response is sent via SMTP back to the customer

      +
      +
      +
      +
      +
      + + {/* Tips */} +
      +

      + Tips

      • - Keep emails concise and include only essential information + + Use "Test IMAP" after setup to verify your credentials are correct +
      • - Use preview mode to test templates before activating + + Consider using different email addresses for different departments (support, sales, billing) +
      • - Include clear calls to action (cancel, reschedule links) + + Set a meaningful default address - this is what customers see when you reply + +
      • +
      • + + + Assign different colors to make it easy to identify which department a ticket came from +
      + {/* Related Features */}
      -

      Related Settings

      +

      Related Features

      - - - Appearance Settings + + + Ticketing Guide - - - Booking Settings + + + General Settings + + + + + Branding Settings + + + + + Staff Guide
      + {/* Help Footer */}

      Need More Help?

      -

      Our support team is ready to help with any questions.

      - +

      + Our support team is ready to help with email setup questions. +

      +
      ); diff --git a/frontend/src/pages/help/HelpSettingsGeneral.tsx b/frontend/src/pages/help/HelpSettingsGeneral.tsx index 59c855d..6ffa442 100644 --- a/frontend/src/pages/help/HelpSettingsGeneral.tsx +++ b/frontend/src/pages/help/HelpSettingsGeneral.tsx @@ -1,12 +1,25 @@ /** * Help Settings General Page + * + * Comprehensive help documentation for General Settings. */ import React from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { - ArrowLeft, Settings, Building, Globe, Clock, MapPin, - CheckCircle, ChevronRight, HelpCircle, + ArrowLeft, + Settings, + Building2, + Globe, + Clock, + Mail, + Phone, + CheckCircle, + ChevronRight, + HelpCircle, + AlertCircle, + Save, + Shield, } from 'lucide-react'; const HelpSettingsGeneral: React.FC = () => { @@ -14,114 +27,299 @@ const HelpSettingsGeneral: React.FC = () => { return (
      - + {/* Header */}
      - +

      General Settings Guide

      -

      Configure your business basics

      +

      Configure your business identity, timezone, and contact information

      + {/* Overview Section */}

      Overview

      - General Settings contain the core configuration for your business. Set your business name, contact information, timezone, and other fundamental settings that affect your entire account. + General Settings contain the foundational configuration for your business. These settings affect how your business appears to customers, how appointment times are displayed, and how customers can contact you. +

      +

      + The page is organized into three sections: Business Identity, Timezone Settings, and Contact Information. Changes are saved when you click the "Save Changes" button.

      + {/* Owner Access Note */} +
      +
      +
      + +
      +

      Owner Access Required

      +

      + Only business owners can access and modify General Settings. Managers and staff members will see a message indicating they don't have permission to change these settings. +

      +
      +
      +
      +
      + + {/* Business Identity Section */}

      - Business Information + Business Identity

      -
      +

      + Define how your business is identified in the system: +

      +
      - +

      Business Name

      -

      Your company name displayed to customers

      +

      + Your company name displayed in the sidebar, emails, booking pages, and customer-facing interfaces. This can be changed at any time. +

      - +

      Subdomain

      -

      Your unique URL for customer booking

      +

      + Your unique URL: yourname.smoothschedule.com. + This is your booking page URL that customers use to schedule appointments. +

      +
      +
      +
      +
      +
      + +

      + Note: Your subdomain is read-only and cannot be changed directly. Contact support if you need to change your subdomain. +

      +
      +
      +
      +
      + + {/* Timezone Settings Section */} +
      +

      + Timezone Settings +

      +
      +

      + Configure how appointment times are calculated and displayed: +

      +
      +
      + +
      +

      Business Timezone

      +

      + Select the timezone where your business operates. All appointment times are stored relative to this timezone. +

      +

      + Includes common timezones for North America, Europe, Asia, and Australia. Examples: Eastern Time (New York), Pacific Time (Los Angeles), London (GMT/BST). +

      - +
      -

      Timezone

      -

      Business timezone for scheduling

      +

      Time Display Mode

      +

      + Choose how times are shown to viewers: +

      +
        +
      • + Business Timezone: + All appointment times display in your business timezone regardless of where the viewer is located. Best for local businesses. +
      • +
      • + Viewer's Local Timezone: + Appointment times automatically adapt to each viewer's local timezone. Best for businesses with customers in different time zones. +
      • +
      +
      +
      +
      +
      +
      + +

      + Important: Changing your business timezone after appointments are created may cause confusion. The appointments themselves remain at the same absolute time, but the displayed time will shift. Always verify existing appointments after a timezone change. +

      +
      +
      +
      +
      + + {/* Contact Information Section */} +
      +

      + Contact Information +

      +
      +

      + Provide contact details that customers can use to reach you: +

      +
      +
      + +
      +

      Contact Email

      +

      + The email address displayed on your booking page and in customer communications. This is where customers will expect to reach you for questions or support. +

      - +
      -

      Address

      -

      Business location and contact details

      +

      Phone Number

      +

      + Your business phone number for customers who prefer to call. Include country code for international businesses. +

      + {/* Saving Changes Section */}

      - Tips + Saving Changes +

      +
      +
      +
      + +
      +

      Save Changes Button

      +

      + After making any modifications, click the "Save Changes" button at the bottom of the page. A green confirmation toast appears when changes are saved successfully. +

      +
      +
      +
      + +
      +

      Unsaved Changes

      +

      + If you navigate away without saving, your changes will be lost. Always save before leaving the page. +

      +
      +
      +
      +
      +
      + + {/* Benefits Section */} +
      +

      + Benefits

      • - Set your timezone correctly to ensure appointments display at the right times + + Consistent Branding: Your business name appears across all customer touchpoints +
      • - Your subdomain cannot be changed after creation - choose wisely + + Accurate Scheduling: Correct timezone prevents appointment confusion +
      • - Keep contact information up to date for customer communications + + Flexible Display: Choose between business or viewer timezone based on your customer base + +
      • +
      • + + + Easy Contact: Clear contact info helps customers reach you quickly +
      + {/* Related Settings */}

      Related Settings

      - + - Appearance Settings + Branding Settings - + + + Booking Settings + + + Custom Domains + + + Email Settings + +
      + {/* Need More Help */}

      Need More Help?

      -

      Our support team is ready to help with any questions.

      - +

      + Our support team is ready to help with any questions about general settings. +

      +
      ); diff --git a/frontend/src/pages/help/HelpSettingsQuota.tsx b/frontend/src/pages/help/HelpSettingsQuota.tsx index a529687..ed864d0 100644 --- a/frontend/src/pages/help/HelpSettingsQuota.tsx +++ b/frontend/src/pages/help/HelpSettingsQuota.tsx @@ -1,12 +1,26 @@ /** * Help Settings Quota Page + * + * Documentation for managing quota overages and usage limits. */ import React from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { - ArrowLeft, BarChart2, TrendingUp, AlertTriangle, Users, Calendar, - CheckCircle, ChevronRight, HelpCircle, CreditCard, + ArrowLeft, + AlertTriangle, + Users, + Briefcase, + Calendar, + Archive, + Clock, + Check, + Download, + ChevronDown, + CheckCircle, + ChevronRight, + HelpCircle, + CreditCard, } from 'lucide-react'; const HelpSettingsQuota: React.FC = () => { @@ -14,139 +28,387 @@ const HelpSettingsQuota: React.FC = () => { return (
      - + {/* Header */}
      -
      - +
      +
      -

      Usage & Quota Guide

      -

      Monitor your plan limits

      +

      + Quota Management Guide +

      +

      + Manage account limits and overages +

      + {/* Overview */}

      - Overview + + Overview

      - The Usage & Quota page shows your current usage against your plan limits. Monitor resources, appointments, staff members, and other tracked metrics to ensure you stay within your plan. + Quota Management allows you to monitor your usage against plan limits and resolve overages. + When you exceed your plan's limits, you'll have a grace period to either archive resources + or upgrade your plan.

      +
      + +

      + Business owners and managers can access quota management. This page is most important + when you have active overages that need to be resolved. +

      +
      + {/* Current Usage */}

      - Tracked Metrics + + Current Usage

      -
      -
      - -
      -

      Resources

      -

      Staff, rooms, and equipment count

      +

      + The usage overview shows your current consumption for each quota type: +

      + +
      +
      +
      + + Additional Users
      +

      + Staff members beyond the plan's included users +

      -
      - -
      -

      Appointments

      -

      Monthly appointment count

      +
      +
      + + Resources
      +

      + Total resources (staff, rooms, equipment) +

      -
      - -
      -

      Staff Members

      -

      Team members with login access

      +
      +
      + + Services
      +

      + Active services offered to customers +

      -
      - -
      -

      Storage

      -

      File and image storage used

      -
      +
      + +
      +

      + + If all quotas show current / limit with current under limit, you're within + your plan limits. +

      +
      +
      +
      + + {/* Active Overages */} +
      +

      + + Active Overages +

      +
      +

      + When you exceed a limit, an overage is created with a grace period. + You must resolve the overage before the grace period ends. +

      + +
      + {/* Overage Card Example */} +
      +

      + Each Overage Shows: +

      +
        +
      • + + Quota Type - Which limit you've exceeded +
      • +
      • + 5/3 + Usage - Current count vs allowed limit +
      • +
      • + 2 over + Overage Amount - How many items over the limit +
      • +
      • + + Days Remaining - Time left to resolve the overage +
      • +
      +
      + + {/* Grace Period Warning */} +
      +

      + + Auto-Archive Warning +

      +

      + After the grace period ends, the system will automatically archive the + oldest items to bring you back within limits. Archive items yourself to choose which + ones to keep. +

      + {/* Resolving Overages */}

      - Limit Warnings + + Resolving Overages

      -
      -
      -
      - Under 75% - - Plenty of room remaining +

      + You have two options to resolve an overage: +

      + +
      + {/* Option 1: Archive */} +
      +

      + + Option 1: Archive Items +

      +
        +
      1. Click on an overage to expand it
      2. +
      3. Select the items you want to archive using the checkboxes
      4. +
      5. Click Archive Selected
      6. +
      7. Archived items become read-only and cannot be used for new bookings
      8. +
      +
      + + Tip: Archive the items you use least. You can still view archived data. +
      -
      -
      - 75% - 90% - - Consider upgrading soon -
      -
      -
      - Over 90% - - Approaching limit, upgrade recommended + + {/* Option 2: Upgrade */} +
      +

      + + Option 2: Upgrade Your Plan +

      +

      + Click Upgrade Plan Instead to increase your limits. This immediately + resolves the overage without archiving anything. +

      + + + Go to Billing Settings +
      + {/* Step by Step */} +
      +

      + Step-by-Step: Archive Items to Resolve Overage +

      +
      +
        +
      1. + + 1 + + + Go to Settings > Usage & Quota + +
      2. +
      3. + + 2 + + + Review the Active Overages section + +
      4. +
      5. + + 3 + + + Click on an overage to expand it + +
      6. +
      7. + + 4 + + + Review the list of items and select which to archive + +
      8. +
      9. + + 5 + + + Click Archive Selected to archive the selected items + +
      10. +
      11. + + 6 + + + Once enough items are archived, the overage is resolved + +
      12. +
      +
      +
      + + {/* Additional Actions */}

      - Tips + + Additional Actions +

      +
      +
      +
      +

      + + Export Data +

      +

      + Before archiving, you can export your data to keep a backup. This is useful if you + need to reference the data later. +

      +
      + +
      +

      Already Archived

      +

      + The "Already Archived" section shows items that have been previously archived with + their archive dates. These items are read-only but data is preserved. +

      +
      +
      +
      +
      + + {/* Tips */} +
      +

      + + Tips

      • - Check usage regularly to avoid unexpected limits + + Act before the grace period ends - Choose which items to archive rather + than letting the system auto-archive the oldest ones +
      • - Remove inactive resources to free up quota + + Archive inactive items first - Choose resources or services you no longer + actively use +
      • - Upgrade before hitting limits to avoid disruption + + Consider upgrading - If you need all your current items, upgrading your + plan is often simpler than archiving + +
      • +
      • + + + Export data first - Before archiving important items, export the data + as a backup + +
      • +
      • + + + Watch grace period colors - Red means less than 1 day, amber means less + than 7 days remaining +
      + {/* Related Features */}
      -

      Related Settings

      +

      + Related Features +

      - + Billing Settings - - + + Resources Guide
      + {/* Need Help */}
      -

      Need More Help?

      -

      Our support team is ready to help with any questions.

      - +

      + Need More Help? +

      +

      + Our support team is ready to help with quota management questions. +

      +
      ); diff --git a/frontend/src/pages/help/HelpSettingsResourceTypes.tsx b/frontend/src/pages/help/HelpSettingsResourceTypes.tsx index 9037045..ab2878e 100644 --- a/frontend/src/pages/help/HelpSettingsResourceTypes.tsx +++ b/frontend/src/pages/help/HelpSettingsResourceTypes.tsx @@ -1,12 +1,15 @@ /** * Help Settings Resource Types Page + * + * Comprehensive documentation for the Resource Types Settings page. + * Documents: Creating, editing, and deleting custom resource types with STAFF/OTHER categories. */ import React from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { - ArrowLeft, Layers, Users, Building, Wrench, Plus, - CheckCircle, ChevronRight, HelpCircle, Edit, Palette, + ArrowLeft, Layers, Users, Plus, Pencil, Trash2, + CheckCircle, ChevronRight, HelpCircle, AlertCircle, } from 'lucide-react'; const HelpSettingsResourceTypes: React.FC = () => { @@ -18,144 +21,310 @@ const HelpSettingsResourceTypes: React.FC = () => { Back + {/* Header */}
      -
      - +
      +

      Resource Types Guide

      -

      Categorize your bookable resources

      +

      Define custom categories for your resources

      + {/* Overview */}

      Overview

      - Resource Types help you categorize different kinds of bookable entities in your business. Create custom types like "Staff", "Rooms", "Equipment", or any category that fits your needs. -

      -

      - Each resource type can have its own icon and color, making it easy to distinguish between different categories in the scheduler. + Resource Types let you define custom categories for your bookable resources. Instead of just + "Staff", "Room", and "Equipment", you can create specific types like "Stylist", "Treatment Room", + "Camera", or any category that fits your business needs.

      +
      +
      + +

      + Owner Access Required: Only business owners can create and manage resource types. +

      +
      +
      + {/* Resource Type Categories */}

      - Default Types + Categories

      -
      -
      - -
      -

      Staff

      -

      Team members who provide services

      +

      + Each resource type belongs to one of two categories, which determines how it behaves in the system: +

      + +
      + {/* Staff Category */} +
      +
      +
      + +
      +
      +

      Staff Category

      +

      + Requires staff assignment - these resources must be linked to a team member from your Staff page. + Use this for types like Stylist, Therapist, Consultant, or any role performed by a person. +

      +
      -
      - -
      -

      Rooms

      -

      Physical spaces for appointments

      -
      -
      -
      - -
      -

      Equipment

      -

      Tools and machinery

      + + {/* Other Category */} +
      +
      +
      + +
      +
      +

      Other Category

      +

      + General resources - physical items or spaces that don't require a staff link. + Use this for rooms, equipment, vehicles, or any non-person resource. +

      +
      + {/* Your Resource Types List */}

      - Managing Types + Your Resource Types

      +

      + The main section displays all your defined resource types. Each type shows: +

      + +
      +
      +

      For Each Type

      +
        +
      • • Icon: Blue person icon for Staff types, gray layers icon for Other types
      • +
      • • Name: The type's name (e.g., "Stylist", "Treatment Room")
      • +
      • • Category Label: "Requires staff assignment" or "General resource"
      • +
      • • Description: Optional description you provide
      • +
      • • Default Badge: Shows if this is a system default type (can't be deleted)
      • +
      +
      + + {/* Actions */} +
      +
      +
      + +

      Edit

      +
      +

      + Click the pencil icon to modify name, description, or category +

      +
      +
      +
      + +

      Delete

      +
      +

      + Remove custom types (not available for default types) +

      +
      +
      +
      +
      +
      + + {/* Creating a Resource Type */} +
      +

      + Creating a Resource Type +

      +
      +

      + Click the "Add Type" button to create a new resource type. You'll fill out: +

      + +
      +
      +

      Form Fields

      +
      +
      + Name * +

      + The name for this type (e.g., "Stylist", "Treatment Room", "Camera") +

      +
      +
      + Description +

      + Optional text to describe what this type represents +

      +
      +
      + Category * +

      + Choose "Staff" if resources need staff assignment, or "Other" for general resources +

      +
      +
      +
      + + {/* Steps */} +
      +

      Steps to Create

      +
        +
      1. Click Add Type button
      2. +
      3. Enter the name (required)
      4. +
      5. Add a description (optional)
      6. +
      7. Select the category (Staff or Other)
      8. +
      9. Click Create to save
      10. +
      +
      +
      +
      +
      + + {/* Use Cases */} +
      +

      + Example Use Cases +

      +
      +

      + Here are some examples of custom resource types for different businesses: +

      +
      -
      - -
      -

      Create New Type

      -

      Add custom resource categories for your business

      -
      +
      +

      Hair Salon

      +
        +
      • • Stylist (Staff)
      • +
      • • Colorist (Staff)
      • +
      • • Wash Station (Other)
      • +
      • • Styling Chair (Other)
      • +
      -
      - -
      -

      Edit Types

      -

      Modify name, icon, and color of existing types

      -
      +
      +

      Medical Clinic

      +
        +
      • • Doctor (Staff)
      • +
      • • Nurse (Staff)
      • +
      • • Exam Room (Other)
      • +
      • • X-Ray Machine (Other)
      • +
      -
      - -
      -

      Custom Colors

      -

      Assign colors for visual distinction

      -
      +
      +

      Photo Studio

      +
        +
      • • Photographer (Staff)
      • +
      • • Assistant (Staff)
      • +
      • • Studio A (Other)
      • +
      • • Lighting Kit (Other)
      • +
      -
      - -
      -

      Assign to Resources

      -

      Categorize each resource under a type

      -
      +
      +

      Fitness Center

      +
        +
      • • Personal Trainer (Staff)
      • +
      • • Yoga Instructor (Staff)
      • +
      • • Training Room (Other)
      • +
      • • Spin Bike (Other)
      • +
      + {/* Tips */}

      - Benefits + Tips

      -
      +
      • - Organization: Group resources logically for easier management + + Create specific types rather than generic ones - "Stylist" is better than just "Staff" +
      • - Visual Clarity: Color-coding helps identify resource types quickly + + Use the Staff category when the resource represents a person who needs a system account +
      • - Filtering: Filter scheduler view by resource type + + Default types cannot be deleted, but you can create alternatives and use those instead + +
      • +
      • + + + Add descriptions to help other team members understand what each type is for +
      + {/* Related Features */}

      Related Features

      - + Resources Guide + + + Staff Guide + + - + Scheduler Guide + + + General Settings + +
      + {/* Help Footer */}

      Need More Help?

      -

      Our support team is ready to help with any questions.

      - +

      + Our support team is ready to help with resource type questions. +

      +
      ); diff --git a/frontend/src/pages/platform/PlatformSettings.tsx b/frontend/src/pages/platform/PlatformSettings.tsx index 4f3d09a..681d9ba 100644 --- a/frontend/src/pages/platform/PlatformSettings.tsx +++ b/frontend/src/pages/platform/PlatformSettings.tsx @@ -37,6 +37,7 @@ import { useUpdateSubscriptionPlan, useDeleteSubscriptionPlan, useSyncPlansWithStripe, + useSyncPlanToTenants, SubscriptionPlan, SubscriptionPlanCreate, } from '../../hooks/usePlatformSettings'; @@ -527,9 +528,12 @@ const TiersSettingsTab: React.FC = () => { const updatePlanMutation = useUpdateSubscriptionPlan(); const deletePlanMutation = useDeleteSubscriptionPlan(); const syncMutation = useSyncPlansWithStripe(); + const syncTenantsMutation = useSyncPlanToTenants(); const [showModal, setShowModal] = useState(false); const [editingPlan, setEditingPlan] = useState(null); + const [showSyncConfirmModal, setShowSyncConfirmModal] = useState(false); + const [savedPlanForSync, setSavedPlanForSync] = useState(null); const handleCreatePlan = () => { setEditingPlan(null); @@ -550,6 +554,9 @@ const TiersSettingsTab: React.FC = () => { const handleSavePlan = async (data: SubscriptionPlanCreate) => { if (editingPlan) { await updatePlanMutation.mutateAsync({ id: editingPlan.id, ...data }); + // After updating an existing plan, ask if they want to sync to tenants + setSavedPlanForSync(editingPlan); + setShowSyncConfirmModal(true); } else { await createPlanMutation.mutateAsync(data); } @@ -557,6 +564,19 @@ const TiersSettingsTab: React.FC = () => { setEditingPlan(null); }; + const handleSyncConfirm = async () => { + if (savedPlanForSync) { + await syncTenantsMutation.mutateAsync(savedPlanForSync.id); + } + setShowSyncConfirmModal(false); + setSavedPlanForSync(null); + }; + + const handleSyncCancel = () => { + setShowSyncConfirmModal(false); + setSavedPlanForSync(null); + }; + if (isLoading) { return (
      @@ -672,6 +692,48 @@ const TiersSettingsTab: React.FC = () => { isLoading={createPlanMutation.isPending || updatePlanMutation.isPending} /> )} + + {/* Sync Confirmation Modal */} + {showSyncConfirmModal && savedPlanForSync && ( +
      +
      +
      +

      + Update All Tenants? +

      +

      + Do you want to sync the updated settings to all tenants currently on the "{savedPlanForSync.name}" plan? +

      +

      + This will update permissions and limits for all businesses on this tier. +

      +
      +
      + + +
      +
      +
      + )}
      ); }; @@ -780,6 +842,8 @@ const PlanModal: React.FC = ({ plan, onSave, onClose, isLoading advanced_reporting: false, priority_support: false, can_use_custom_domain: false, + can_use_plugins: false, + can_use_tasks: false, can_create_plugins: false, can_white_label: false, can_api_access: false, @@ -1470,10 +1534,37 @@ const PlanModal: React.FC = ({ plan, onSave, onClose, isLoading API Access + + - -
      @@ -484,8 +388,8 @@ const BusinessEditModal: React.FC = ({ business, isOpen,
    @@ -515,15 +410,6 @@ const BusinessEditModal: React.FC = ({ business, isOpen,

    Customization

    -
    - {/* Advanced Features */} + {/* Plugins & Automation */}
    -

    Advanced Features

    +

    Plugins & Automation

    + + +
    + {!editForm.can_use_plugins && ( +

    + Enable "Use Plugins" to allow Scheduled Tasks and Create Plugins +

    + )} +
    + + {/* Advanced Features */} +
    +

    Advanced Features

    +
    - {/* Support & Enterprise */} + {/* Enterprise */}
    -

    Support & Enterprise

    +

    Enterprise

    diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9f68c84..d9ced76 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -39,6 +39,7 @@ export interface PlanPermissions { white_label: boolean; custom_oauth: boolean; plugins: boolean; + tasks: boolean; export_data: boolean; video_conferencing: boolean; two_factor_auth: boolean; diff --git a/smoothschedule/core/apps.py b/smoothschedule/core/apps.py index fba924f..764951d 100644 --- a/smoothschedule/core/apps.py +++ b/smoothschedule/core/apps.py @@ -13,6 +13,4 @@ class CoreConfig(AppConfig): """ Import signals and perform app initialization. """ - # Import signals here when needed - # from . import signals - pass + from . import signals # noqa: F401 diff --git a/smoothschedule/core/migrations/0021_add_can_use_plugins.py b/smoothschedule/core/migrations/0021_add_can_use_plugins.py new file mode 100644 index 0000000..6257263 --- /dev/null +++ b/smoothschedule/core/migrations/0021_add_can_use_plugins.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.8 on 2025-12-03 15:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0020_booking_return_url'), + ] + + operations = [ + migrations.AddField( + model_name='tenant', + name='can_use_plugins', + field=models.BooleanField(default=True, help_text='Whether this business can use plugins from the marketplace'), + ), + migrations.AlterField( + model_name='tenant', + name='can_create_plugins', + field=models.BooleanField(default=False, help_text='Whether this business can create custom plugins for automation (requires can_use_plugins)'), + ), + ] diff --git a/smoothschedule/core/migrations/0022_add_can_use_tasks.py b/smoothschedule/core/migrations/0022_add_can_use_tasks.py new file mode 100644 index 0000000..7de336c --- /dev/null +++ b/smoothschedule/core/migrations/0022_add_can_use_tasks.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.8 on 2025-12-03 15:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0021_add_can_use_plugins'), + ] + + operations = [ + migrations.AddField( + model_name='tenant', + name='can_use_tasks', + field=models.BooleanField(default=True, help_text='Whether this business can create scheduled tasks (requires can_use_plugins)'), + ), + ] diff --git a/smoothschedule/core/models.py b/smoothschedule/core/models.py index 44a99ab..5065129 100644 --- a/smoothschedule/core/models.py +++ b/smoothschedule/core/models.py @@ -207,9 +207,17 @@ class Tenant(TenantMixin): default=False, help_text="Whether this business can export data (appointments, customers, etc.)" ) + can_use_plugins = models.BooleanField( + default=True, + help_text="Whether this business can use plugins from the marketplace" + ) + can_use_tasks = models.BooleanField( + default=True, + help_text="Whether this business can create scheduled tasks (requires can_use_plugins)" + ) can_create_plugins = models.BooleanField( default=False, - help_text="Whether this business can create custom plugins for automation" + help_text="Whether this business can create custom plugins for automation (requires can_use_plugins)" ) can_use_webhooks = models.BooleanField( default=False, diff --git a/smoothschedule/core/signals.py b/smoothschedule/core/signals.py new file mode 100644 index 0000000..3950aa8 --- /dev/null +++ b/smoothschedule/core/signals.py @@ -0,0 +1,67 @@ +""" +Core App Signals + +Handles automatic setup tasks when tenants are created. +""" +import logging +from django.db.models.signals import post_save +from django.dispatch import receiver + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender='core.Tenant') +def seed_platform_plugins_on_tenant_create(sender, instance, created, **kwargs): + """ + Seed platform plugins when a new tenant is created. + + This ensures new tenants have access to all marketplace plugins immediately. + """ + if not created: + return + + # Skip public schema + if instance.schema_name == 'public': + return + + # Defer the import to avoid circular imports + from django.db import connection + from django_tenants.utils import schema_context + from schedule.models import PluginTemplate + from django.utils import timezone + + logger.info(f"Seeding platform plugins for new tenant: {instance.schema_name}") + + try: + with schema_context(instance.schema_name): + # Import the plugin definitions from the seed command + from schedule.management.commands.seed_platform_plugins import get_platform_plugins + + plugins_data = get_platform_plugins() + created_count = 0 + + for plugin_data in plugins_data: + # Check if plugin already exists by slug + if PluginTemplate.objects.filter(slug=plugin_data['slug']).exists(): + continue + + # Create the plugin + PluginTemplate.objects.create( + name=plugin_data['name'], + slug=plugin_data['slug'], + category=plugin_data['category'], + short_description=plugin_data['short_description'], + description=plugin_data['description'], + plugin_code=plugin_data['plugin_code'], + logo_url=plugin_data.get('logo_url', ''), + visibility=PluginTemplate.Visibility.PLATFORM, + is_approved=True, + approved_at=timezone.now(), + author_name='Smooth Schedule', + license_type='PLATFORM', + ) + created_count += 1 + + logger.info(f"Created {created_count} platform plugins for tenant: {instance.schema_name}") + except Exception as e: + logger.error(f"Failed to seed plugins for tenant {instance.schema_name}: {e}") diff --git a/smoothschedule/payments/views.py b/smoothschedule/payments/views.py index b997776..29628dc 100644 --- a/smoothschedule/payments/views.py +++ b/smoothschedule/payments/views.py @@ -10,6 +10,7 @@ from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework import status +from core.permissions import HasFeaturePermission from decimal import Decimal from .services import get_stripe_service_for_tenant from .models import TransactionLink @@ -517,7 +518,7 @@ class ApiKeysView(APIView): GET /payments/api-keys/ POST /payments/api-keys/ """ - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, HasFeaturePermission('can_accept_payments')] def get(self, request): """Get current API key configuration.""" @@ -627,7 +628,7 @@ class ApiKeysValidateView(APIView): POST /payments/api-keys/validate/ """ - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, HasFeaturePermission('can_accept_payments')] def post(self, request): """Validate keys without saving.""" @@ -672,7 +673,7 @@ class ApiKeysRevalidateView(APIView): POST /payments/api-keys/revalidate/ """ - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, HasFeaturePermission('can_accept_payments')] def post(self, request): """Re-validate stored keys.""" @@ -721,7 +722,7 @@ class ApiKeysDeleteView(APIView): DELETE /payments/api-keys/delete/ """ - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, HasFeaturePermission('can_accept_payments')] def delete(self, request): """Delete stored keys.""" @@ -792,7 +793,7 @@ class ConnectOnboardView(APIView): POST /payments/connect/onboard/ """ - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, HasFeaturePermission('can_accept_payments')] def post(self, request): """Start Connect onboarding flow.""" @@ -855,7 +856,7 @@ class ConnectRefreshLinkView(APIView): POST /payments/connect/refresh-link/ """ - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, HasFeaturePermission('can_accept_payments')] def post(self, request): """Get a new onboarding link.""" @@ -905,7 +906,7 @@ class ConnectAccountSessionView(APIView): Custom accounts are required for embedded onboarding (Standard accounts require the redirect flow). """ - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, HasFeaturePermission('can_accept_payments')] def post(self, request): """Create account session for embedded components.""" @@ -967,7 +968,7 @@ class ConnectRefreshStatusView(APIView): POST /payments/connect/refresh-status/ """ - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, HasFeaturePermission('can_accept_payments')] def post(self, request): """Sync local status with Stripe.""" @@ -1036,7 +1037,7 @@ class TransactionListView(APIView): GET /payments/transactions/ """ - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, HasFeaturePermission('can_accept_payments')] def get(self, request): """Get paginated list of transactions.""" @@ -1104,7 +1105,7 @@ class TransactionSummaryView(APIView): GET /payments/transactions/summary/ """ - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, HasFeaturePermission('can_accept_payments')] def get(self, request): """Get transaction summary.""" @@ -1159,7 +1160,7 @@ class StripeChargesView(APIView): GET /payments/transactions/charges/ """ - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, HasFeaturePermission('can_accept_payments')] def get(self, request): """Get recent charges from Stripe API.""" @@ -1225,7 +1226,7 @@ class StripePayoutsView(APIView): GET /payments/transactions/payouts/ """ - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, HasFeaturePermission('can_accept_payments')] def get(self, request): """Get payouts from Stripe API.""" @@ -1289,7 +1290,7 @@ class StripeBalanceView(APIView): GET /payments/transactions/balance/ """ - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, HasFeaturePermission('can_accept_payments')] def get(self, request): """Get balance from Stripe API.""" @@ -1362,7 +1363,7 @@ class TransactionExportView(APIView): POST /payments/transactions/export/ """ - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, HasFeaturePermission('can_accept_payments')] def post(self, request): """Export transactions to various formats.""" @@ -1385,7 +1386,7 @@ class CreatePaymentIntentView(APIView): POST /payments/payment-intents/ """ - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, HasFeaturePermission('can_accept_payments')] def post(self, request): """Create payment intent for an event""" @@ -1460,7 +1461,7 @@ class TerminalConnectionTokenView(APIView): POST /payments/terminal/connection-token/ """ - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, HasFeaturePermission('can_accept_payments')] def post(self, request): """Get terminal connection token""" @@ -1488,7 +1489,7 @@ class RefundPaymentView(APIView): POST /payments/refunds/ """ - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, HasFeaturePermission('can_accept_payments')] def post(self, request): """Create refund""" diff --git a/smoothschedule/platform_admin/apps.py b/smoothschedule/platform_admin/apps.py index 1d1c7eb..8a2d18b 100644 --- a/smoothschedule/platform_admin/apps.py +++ b/smoothschedule/platform_admin/apps.py @@ -5,3 +5,7 @@ class PlatformAdminConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'platform_admin' verbose_name = 'Platform Management' + + def ready(self): + # Import signals to register them + from . import signals # noqa: F401 diff --git a/smoothschedule/platform_admin/serializers.py b/smoothschedule/platform_admin/serializers.py index 7bce429..4630dd7 100644 --- a/smoothschedule/platform_admin/serializers.py +++ b/smoothschedule/platform_admin/serializers.py @@ -202,6 +202,27 @@ class TenantSerializer(serializers.ModelSerializer): 'max_resources', 'contact_email', 'phone', # Platform permissions 'can_manage_oauth_credentials', + 'can_accept_payments', + 'can_use_custom_domain', + 'can_white_label', + 'can_api_access', + # Feature permissions + 'can_add_video_conferencing', + 'can_connect_to_api', + 'can_book_repeated_events', + 'can_require_2fa', + 'can_download_logs', + 'can_delete_data', + 'can_use_sms_reminders', + 'can_use_masked_phone_numbers', + 'can_use_pos', + 'can_use_mobile_app', + 'can_export_data', + 'can_use_plugins', + 'can_use_tasks', + 'can_create_plugins', + 'can_use_webhooks', + 'can_use_calendar_sync', ] read_only_fields = fields @@ -256,6 +277,23 @@ class TenantUpdateSerializer(serializers.ModelSerializer): 'can_use_custom_domain', 'can_white_label', 'can_api_access', + # Feature permissions + 'can_add_video_conferencing', + 'can_connect_to_api', + 'can_book_repeated_events', + 'can_require_2fa', + 'can_download_logs', + 'can_delete_data', + 'can_use_sms_reminders', + 'can_use_masked_phone_numbers', + 'can_use_pos', + 'can_use_mobile_app', + 'can_export_data', + 'can_use_plugins', + 'can_use_tasks', + 'can_create_plugins', + 'can_use_webhooks', + 'can_use_calendar_sync', ] read_only_fields = ['id'] diff --git a/smoothschedule/platform_admin/signals.py b/smoothschedule/platform_admin/signals.py new file mode 100644 index 0000000..1a5cbbe --- /dev/null +++ b/smoothschedule/platform_admin/signals.py @@ -0,0 +1,28 @@ +""" +Django signals for platform admin operations. +""" +import logging +from django.db.models.signals import post_save +from django.dispatch import receiver + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender='platform_admin.SubscriptionPlan') +def subscription_plan_updated(sender, instance, created, **kwargs): + """ + When a SubscriptionPlan is updated (not created), trigger a Celery task + to sync the plan's permissions and limits to all tenants using that plan. + """ + if created: + # New plan, no tenants to update yet + return + + # Import here to avoid circular imports + from .tasks import sync_subscription_plan_to_tenants + + logger.info(f"SubscriptionPlan '{instance.name}' (ID: {instance.id}) was updated, " + f"queuing sync task for all tenants on this plan") + + # Queue the task to run asynchronously + sync_subscription_plan_to_tenants.delay(instance.id) diff --git a/smoothschedule/platform_admin/tasks.py b/smoothschedule/platform_admin/tasks.py index 48fd879..9903ec3 100644 --- a/smoothschedule/platform_admin/tasks.py +++ b/smoothschedule/platform_admin/tasks.py @@ -207,3 +207,142 @@ def send_bulk_appointment_reminders(hours_before: int = 24): logger.info(f"Queued {reminders_queued} appointment reminder emails for {hours_before}h window") return {'reminders_queued': reminders_queued, 'hours_before': hours_before} + + +@shared_task(bind=True, max_retries=3) +def sync_subscription_plan_to_tenants(self, plan_id: int): + """ + Sync a subscription plan's permissions and limits to all tenants using that plan. + + This task is triggered when a SubscriptionPlan is updated, ensuring all tenants + on that plan have their permissions and limits updated accordingly. + + Args: + plan_id: ID of the SubscriptionPlan that was updated + """ + from .models import SubscriptionPlan + from core.models import Tenant + + try: + plan = SubscriptionPlan.objects.get(id=plan_id) + except SubscriptionPlan.DoesNotExist: + logger.error(f"SubscriptionPlan {plan_id} not found") + return {'success': False, 'error': 'Plan not found'} + + # Get all tenants using this plan + tenants = Tenant.objects.filter(subscription_plan=plan) + tenant_count = tenants.count() + + if tenant_count == 0: + logger.info(f"No tenants found for plan {plan.name} (ID: {plan_id})") + return {'success': True, 'tenants_updated': 0, 'plan_name': plan.name} + + logger.info(f"Syncing plan '{plan.name}' to {tenant_count} tenant(s)") + + # Mapping from plan.permissions JSON keys to Tenant boolean field names + # Some plan keys differ from tenant field names + permission_mapping = { + # Plan JSON key -> Tenant field name + 'can_manage_oauth_credentials': 'can_manage_oauth_credentials', + 'can_accept_payments': 'can_accept_payments', + 'can_use_custom_domain': 'can_use_custom_domain', + 'can_white_label': 'can_white_label', + 'can_api_access': 'can_api_access', + 'video_conferencing': 'can_add_video_conferencing', + 'can_add_video_conferencing': 'can_add_video_conferencing', + 'can_connect_to_api': 'can_connect_to_api', + 'can_book_repeated_events': 'can_book_repeated_events', + 'can_require_2fa': 'can_require_2fa', + 'can_download_logs': 'can_download_logs', + 'can_delete_data': 'can_delete_data', + # Communication - plan may use short names + 'sms_reminders': 'can_use_sms_reminders', + 'can_use_sms_reminders': 'can_use_sms_reminders', + 'masked_calling': 'can_use_masked_phone_numbers', + 'can_use_masked_phone_numbers': 'can_use_masked_phone_numbers', + 'can_use_pos': 'can_use_pos', + 'can_use_mobile_app': 'can_use_mobile_app', + # Advanced features - plan may use short names + 'export_data': 'can_export_data', + 'can_export_data': 'can_export_data', + 'plugins': 'can_use_plugins', + 'can_use_plugins': 'can_use_plugins', + 'tasks': 'can_use_tasks', + 'can_use_tasks': 'can_use_tasks', + 'can_create_plugins': 'can_create_plugins', + 'webhooks': 'can_use_webhooks', + 'can_use_webhooks': 'can_use_webhooks', + 'calendar_sync': 'can_use_calendar_sync', + 'can_use_calendar_sync': 'can_use_calendar_sync', + } + + # Limit field mappings from plan.limits JSON to Tenant fields + limit_fields = [ + 'max_users', + 'max_resources', + ] + + plan_permissions = plan.permissions or {} + plan_limits = plan.limits or {} + + updated_count = 0 + errors = [] + + for tenant in tenants: + try: + changed = False + + # Update permission fields using the mapping + for plan_key, tenant_field in permission_mapping.items(): + if plan_key in plan_permissions: + new_value = bool(plan_permissions[plan_key]) + if getattr(tenant, tenant_field, None) != new_value: + setattr(tenant, tenant_field, new_value) + changed = True + + # Update limit fields + for field in limit_fields: + if field in plan_limits: + new_value = int(plan_limits[field]) + if getattr(tenant, field, None) != new_value: + setattr(tenant, field, new_value) + changed = True + + # Update subscription tier if plan has a business_tier + if plan.business_tier: + tier_mapping = { + 'Free': 'FREE', + 'Starter': 'STARTER', + 'Professional': 'PROFESSIONAL', + 'Business': 'PROFESSIONAL', # Map Business to Professional + 'Enterprise': 'ENTERPRISE', + } + new_tier = tier_mapping.get(plan.business_tier) + if new_tier and tenant.subscription_tier != new_tier: + tenant.subscription_tier = new_tier + changed = True + + if changed: + tenant.save() + updated_count += 1 + logger.debug(f"Updated tenant '{tenant.name}' (ID: {tenant.id})") + + except Exception as e: + error_msg = f"Failed to update tenant {tenant.id}: {str(e)}" + logger.error(error_msg) + errors.append(error_msg) + + result = { + 'success': True, + 'plan_id': plan_id, + 'plan_name': plan.name, + 'tenants_found': tenant_count, + 'tenants_updated': updated_count, + } + + if errors: + result['errors'] = errors + result['success'] = len(errors) < tenant_count # Partial success + + logger.info(f"Completed sync for plan '{plan.name}': {updated_count}/{tenant_count} tenants updated") + return result diff --git a/smoothschedule/platform_admin/views.py b/smoothschedule/platform_admin/views.py index 18868c2..bfbea22 100644 --- a/smoothschedule/platform_admin/views.py +++ b/smoothschedule/platform_admin/views.py @@ -687,6 +687,32 @@ class SubscriptionPlanViewSet(viewsets.ModelViewSet): return SubscriptionPlanCreateSerializer return SubscriptionPlanSerializer + @action(detail=True, methods=['post']) + def sync_tenants(self, request, pk=None): + """ + Sync this plan's permissions to all tenants on this plan. + This is called explicitly by the admin after confirming they want to sync. + """ + plan = self.get_object() + + from .tasks import sync_subscription_plan_to_tenants + import logging + logger = logging.getLogger(__name__) + logger.info(f"SubscriptionPlan '{plan.name}' (ID: {plan.id}) - " + f"sync to tenants requested by platform admin") + + # Run the sync task + sync_subscription_plan_to_tenants.delay(plan.id) + + # Count tenants on this plan + from core.models import Tenant + tenant_count = Tenant.objects.filter(subscription_plan=plan).count() + + return Response({ + 'message': f'Syncing permissions to {tenant_count} tenant(s) on the "{plan.name}" plan', + 'tenant_count': tenant_count + }) + @action(detail=False, methods=['post']) def sync_with_stripe(self, request): """ diff --git a/smoothschedule/schedule/api_views.py b/smoothschedule/schedule/api_views.py index f82bf8e..c26d2b2 100644 --- a/smoothschedule/schedule/api_views.py +++ b/smoothschedule/schedule/api_views.py @@ -176,7 +176,8 @@ def current_business_view(request): 'custom_domain': tenant.can_use_custom_domain or plan_permissions.get('custom_domain', False), 'white_label': tenant.can_white_label or plan_permissions.get('white_label', False), 'custom_oauth': tenant.can_manage_oauth_credentials or plan_permissions.get('custom_oauth', False), - 'plugins': tenant.can_create_plugins or plan_permissions.get('plugins', False), + 'plugins': tenant.can_use_plugins or plan_permissions.get('plugins', False), + 'tasks': tenant.can_use_tasks or plan_permissions.get('tasks', False), 'export_data': tenant.can_export_data or plan_permissions.get('export_data', False), 'video_conferencing': tenant.can_add_video_conferencing or plan_permissions.get('video_conferencing', False), 'two_factor_auth': tenant.can_require_2fa or plan_permissions.get('two_factor_auth', False), diff --git a/smoothschedule/schedule/management/commands/seed_platform_plugins.py b/smoothschedule/schedule/management/commands/seed_platform_plugins.py index 5d4c6da..bb92fb4 100644 --- a/smoothschedule/schedule/management/commands/seed_platform_plugins.py +++ b/smoothschedule/schedule/management/commands/seed_platform_plugins.py @@ -3,17 +3,20 @@ from django.utils import timezone from schedule.models import PluginTemplate -class Command(BaseCommand): - help = 'Seed platform-owned plugins into the database' +def get_platform_plugins(): + """ + Returns the list of platform plugin definitions. - def handle(self, *args, **options): - plugins_data = [ - { - 'name': 'Daily Appointment Summary Email', - 'slug': 'daily-appointment-summary', - 'category': PluginTemplate.Category.EMAIL, - 'short_description': 'Send daily email summary of appointments', - 'description': '''Stay on top of your schedule with automated daily appointment summaries. + This function is shared between the management command and the signal + that auto-seeds plugins on tenant creation. + """ + return [ + { + 'name': 'Daily Appointment Summary Email', + 'slug': 'daily-appointment-summary', + 'category': PluginTemplate.Category.EMAIL, + 'short_description': 'Send daily email summary of appointments', + 'description': '''Stay on top of your schedule with automated daily appointment summaries. This plugin sends a comprehensive email digest every morning with: - List of all appointments for the day @@ -23,7 +26,7 @@ This plugin sends a comprehensive email digest every morning with: - Any special notes or requirements Perfect for managers and staff who want to start their day informed and prepared.''', - 'plugin_code': '''from datetime import datetime, timedelta + 'plugin_code': '''from datetime import datetime, timedelta # Get today's appointments today = datetime.now().date() @@ -51,14 +54,14 @@ api.send_email( body=summary ) ''', - 'logo_url': '/plugin-logos/daily-appointment-summary.png', - }, - { - 'name': 'No-Show Customer Tracker', - 'slug': 'no-show-tracker', - 'category': PluginTemplate.Category.REPORTS, - 'short_description': 'Track customers who miss appointments', - 'description': '''Identify patterns of missed appointments and reduce no-shows. + 'logo_url': '/plugin-logos/daily-appointment-summary.png', + }, + { + 'name': 'No-Show Customer Tracker', + 'slug': 'no-show-tracker', + 'category': PluginTemplate.Category.REPORTS, + 'short_description': 'Track customers who miss appointments', + 'description': '''Identify patterns of missed appointments and reduce no-shows. This plugin automatically tracks and reports on: - Customers who didn\'t show up for scheduled appointments @@ -67,7 +70,7 @@ This plugin automatically tracks and reports on: - Trends over time Helps you identify customers who may need reminder calls or deposits, improving your booking efficiency and revenue.''', - 'plugin_code': '''from datetime import datetime, timedelta + 'plugin_code': '''from datetime import datetime, timedelta # Get configuration days_back = int('{{PROMPT:days_back|Days to Look Back|7}}') @@ -108,14 +111,14 @@ api.send_email( body=report ) ''', - 'logo_url': '/plugin-logos/no-show-tracker.png', - }, - { - 'name': 'Birthday Greeting Campaign', - 'slug': 'birthday-greetings', - 'category': PluginTemplate.Category.CUSTOMER, - 'short_description': 'Send birthday emails with offers', - 'description': '''Delight your customers with personalized birthday greetings and special offers. + 'logo_url': '/plugin-logos/no-show-tracker.png', + }, + { + 'name': 'Birthday Greeting Campaign', + 'slug': 'birthday-greetings', + 'category': PluginTemplate.Category.CUSTOMER, + 'short_description': 'Send birthday emails with offers', + 'description': '''Delight your customers with personalized birthday greetings and special offers. This plugin automatically: - Identifies customers with birthdays today @@ -124,7 +127,7 @@ This plugin automatically: - Helps drive repeat bookings and customer loyalty A simple way to show customers you care while encouraging them to book their next appointment.''', - 'plugin_code': '''# Get all customers with email addresses + 'plugin_code': '''# Get all customers with email addresses customers = api.get_customers(has_email=True, limit=1000) # Get customizable email template @@ -146,14 +149,14 @@ for customer in customers: api.log(f"Sent {len(customers)} birthday greetings") ''', - 'logo_url': '/plugin-logos/birthday-greetings.png', - }, - { - 'name': 'Monthly Revenue Report', - 'slug': 'monthly-revenue-report', - 'category': PluginTemplate.Category.REPORTS, - 'short_description': 'Monthly business statistics', - 'description': '''Get comprehensive monthly insights into your business performance. + 'logo_url': '/plugin-logos/birthday-greetings.png', + }, + { + 'name': 'Monthly Revenue Report', + 'slug': 'monthly-revenue-report', + 'category': PluginTemplate.Category.REPORTS, + 'short_description': 'Monthly business statistics', + 'description': '''Get comprehensive monthly insights into your business performance. This plugin generates detailed reports including: - Total revenue and number of appointments @@ -164,7 +167,7 @@ This plugin generates detailed reports including: - Year-over-year comparisons Perfect for owners and managers who want to track business growth and identify opportunities.''', - 'plugin_code': '''from datetime import datetime, timedelta + 'plugin_code': '''from datetime import datetime, timedelta # Get last month's date range today = datetime.now() @@ -212,14 +215,14 @@ api.send_email( body=report ) ''', - 'logo_url': '/plugin-logos/monthly-revenue-report.png', - }, - { - 'name': 'Appointment Reminder (24hr)', - 'slug': 'appointment-reminder-24hr', - 'category': PluginTemplate.Category.BOOKING, - 'short_description': 'Remind customers 24hrs before appointments', - 'description': '''Reduce no-shows with automated appointment reminders. + 'logo_url': '/plugin-logos/monthly-revenue-report.png', + }, + { + 'name': 'Appointment Reminder (24hr)', + 'slug': 'appointment-reminder-24hr', + 'category': PluginTemplate.Category.BOOKING, + 'short_description': 'Remind customers 24hrs before appointments', + 'description': '''Reduce no-shows with automated appointment reminders. This plugin sends friendly reminder emails to customers 24 hours before their scheduled appointments, including: - Appointment date and time @@ -229,7 +232,7 @@ This plugin sends friendly reminder emails to customers 24 hours before their sc - Cancellation policy reminder Studies show that appointment reminders can reduce no-shows by up to 90%.''', - 'plugin_code': '''from datetime import datetime, timedelta + 'plugin_code': '''from datetime import datetime, timedelta # Get appointments 24 hours from now tomorrow = (datetime.now() + timedelta(days=1)).date() @@ -255,14 +258,14 @@ for apt in appointments: api.log(f"Sent {len(appointments)} appointment reminders") ''', - 'logo_url': '/plugin-logos/appointment-reminder-24hr.png', - }, - { - 'name': 'Inactive Customer Re-engagement', - 'slug': 'inactive-customer-reengagement', - 'category': PluginTemplate.Category.CUSTOMER, - 'short_description': 'Email inactive customers with offers', - 'description': '''Win back customers who haven\'t booked in a while. + 'logo_url': '/plugin-logos/appointment-reminder-24hr.png', + }, + { + 'name': 'Inactive Customer Re-engagement', + 'slug': 'inactive-customer-reengagement', + 'category': PluginTemplate.Category.CUSTOMER, + 'short_description': 'Email inactive customers with offers', + 'description': '''Win back customers who haven\'t booked in a while. This plugin automatically identifies customers who haven\'t made an appointment recently and sends them: - Personalized "we miss you" messages @@ -271,7 +274,7 @@ This plugin automatically identifies customers who haven\'t made an appointment - Easy booking links Configurable inactivity period (default: 60 days). A proven strategy for increasing customer lifetime value and reducing churn.''', - 'plugin_code': '''from datetime import datetime, timedelta + 'plugin_code': '''from datetime import datetime, timedelta # Get configuration inactive_days = int('{{PROMPT:inactive_days|Days Inactive|60}}') @@ -308,20 +311,74 @@ for customer in all_customers: api.log(f"Sent re-engagement emails to {inactive_count} inactive customers") ''', - 'logo_url': '/plugin-logos/inactive-customer-reengagement.png', - }, - ] + 'logo_url': '/plugin-logos/inactive-customer-reengagement.png', + }, + ] + + +class Command(BaseCommand): + help = 'Seed or update platform-owned plugins in the database' + + def add_arguments(self, parser): + parser.add_argument( + '--update', + action='store_true', + default=True, + help='Update existing plugins if they have changed (default: True)', + ) + parser.add_argument( + '--no-update', + action='store_true', + help='Skip existing plugins instead of updating them', + ) + + def handle(self, *args, **options): + plugins_data = get_platform_plugins() + update_existing = not options.get('no_update', False) created_count = 0 + updated_count = 0 skipped_count = 0 for plugin_data in plugins_data: - # Check if plugin already exists by slug - if PluginTemplate.objects.filter(slug=plugin_data['slug']).exists(): - self.stdout.write( - self.style.WARNING(f"Skipping '{plugin_data['name']}' - already exists") - ) - skipped_count += 1 + existing = PluginTemplate.objects.filter(slug=plugin_data['slug']).first() + + if existing: + if update_existing: + # Check if plugin needs updating by comparing key fields + needs_update = ( + existing.name != plugin_data['name'] or + existing.short_description != plugin_data['short_description'] or + existing.description != plugin_data['description'] or + existing.plugin_code != plugin_data['plugin_code'] or + existing.category != plugin_data['category'] or + existing.logo_url != plugin_data.get('logo_url', '') + ) + + if needs_update: + existing.name = plugin_data['name'] + existing.short_description = plugin_data['short_description'] + existing.description = plugin_data['description'] + existing.plugin_code = plugin_data['plugin_code'] + existing.category = plugin_data['category'] + existing.logo_url = plugin_data.get('logo_url', '') + existing.updated_at = timezone.now() + existing.save() + + self.stdout.write( + self.style.SUCCESS(f"Updated plugin: '{plugin_data['name']}'") + ) + updated_count += 1 + else: + self.stdout.write( + self.style.WARNING(f"Skipping '{plugin_data['name']}' - no changes") + ) + skipped_count += 1 + else: + self.stdout.write( + self.style.WARNING(f"Skipping '{plugin_data['name']}' - already exists") + ) + skipped_count += 1 continue # Create the plugin @@ -348,6 +405,6 @@ api.log(f"Sent re-engagement emails to {inactive_count} inactive customers") # Summary self.stdout.write( self.style.SUCCESS( - f'\nSuccessfully created {created_count} plugin(s), {skipped_count} already existed.' + f'\nSuccessfully created {created_count}, updated {updated_count}, skipped {skipped_count} plugin(s).' ) ) diff --git a/smoothschedule/schedule/views.py b/smoothschedule/schedule/views.py index b9dfdeb..48364c7 100644 --- a/smoothschedule/schedule/views.py +++ b/smoothschedule/schedule/views.py @@ -433,6 +433,7 @@ class ScheduledTaskViewSet(viewsets.ModelViewSet): - Must be authenticated - Only owners/managers can create/update/delete - Subject to MAX_AUTOMATED_TASKS quota (hard block on creation) + - Requires can_use_plugins AND can_use_tasks features Features: - List all scheduled tasks @@ -448,8 +449,52 @@ class ScheduledTaskViewSet(viewsets.ModelViewSet): permission_classes = [AllowAny, HasQuota('MAX_AUTOMATED_TASKS')] # TODO: Change to IsAuthenticated for production ordering = ['-created_at'] + def _check_tasks_permission(self): + """Check if tenant has permission to access scheduled tasks.""" + from rest_framework.exceptions import PermissionDenied + + tenant = getattr(self.request, 'tenant', None) + if tenant: + if not tenant.has_feature('can_use_plugins'): + raise PermissionDenied( + "Your current plan does not include Plugin access. " + "Please upgrade your subscription to use scheduled tasks." + ) + if not tenant.has_feature('can_use_tasks'): + raise PermissionDenied( + "Your current plan does not include Scheduled Tasks. " + "Please upgrade your subscription to view or create scheduled tasks." + ) + + def list(self, request, *args, **kwargs): + """List scheduled tasks with permission check.""" + self._check_tasks_permission() + return super().list(request, *args, **kwargs) + + def retrieve(self, request, *args, **kwargs): + """Retrieve a scheduled task with permission check.""" + self._check_tasks_permission() + return super().retrieve(request, *args, **kwargs) + def perform_create(self, serializer): - """Set created_by to current user""" + """Set created_by to current user and check permissions""" + from rest_framework.exceptions import PermissionDenied + + tenant = getattr(self.request, 'tenant', None) + if tenant: + # Check permission to use plugins (tasks require plugin access) + if not tenant.has_feature('can_use_plugins'): + raise PermissionDenied( + "Your current plan does not include Plugin access. " + "Please upgrade your subscription to use scheduled tasks." + ) + # Check permission to use scheduled tasks + if not tenant.has_feature('can_use_tasks'): + raise PermissionDenied( + "Your current plan does not include Scheduled Tasks. " + "Please upgrade your subscription to create scheduled tasks." + ) + # TODO: Uncomment when auth is enabled # serializer.save(created_by=self.request.user) serializer.save() @@ -628,6 +673,12 @@ class PluginTemplateViewSet(viewsets.ModelViewSet): - Install a template as a ScheduledTask - Request approval (for marketplace publishing) - Approve/reject templates (platform admins only) + + Permissions: + - Marketplace view: Always accessible (for discovery) + - My Plugins view: Requires can_use_plugins feature + - Install action: Requires can_use_plugins feature + - Create: Requires can_use_plugins AND can_create_plugins features """ queryset = PluginTemplate.objects.all() serializer_class = PluginTemplateSerializer @@ -636,12 +687,19 @@ class PluginTemplateViewSet(viewsets.ModelViewSet): filterset_fields = ['visibility', 'category', 'is_approved'] search_fields = ['name', 'short_description', 'description', 'tags'] + def _has_plugins_permission(self): + """Check if tenant has permission to use plugins.""" + tenant = getattr(self.request, 'tenant', None) + if tenant: + return tenant.has_feature('can_use_plugins') + return True # Allow if no tenant context + def get_queryset(self): """ Filter templates based on user permissions. - - Marketplace view: Only approved PUBLIC templates - - My Plugins: User's own templates (all visibilities) + - Marketplace view: Only approved PUBLIC templates (always accessible) + - My Plugins: User's own templates (requires can_use_plugins) - Platform admins: All templates """ queryset = super().get_queryset() @@ -649,19 +707,22 @@ class PluginTemplateViewSet(viewsets.ModelViewSet): if view_mode == 'marketplace': # Public marketplace - platform official + approved public templates + # Always accessible for discovery/marketing purposes from django.db.models import Q queryset = queryset.filter( Q(visibility=PluginTemplate.Visibility.PLATFORM) | Q(visibility=PluginTemplate.Visibility.PUBLIC, is_approved=True) ) elif view_mode == 'my_plugins': - # User's own templates - if self.request.user.is_authenticated: + # User's own templates - requires plugin permission + if not self._has_plugins_permission(): + queryset = queryset.none() + elif self.request.user.is_authenticated: queryset = queryset.filter(author=self.request.user) else: queryset = queryset.none() elif view_mode == 'platform': - # Platform official plugins + # Platform official plugins - always accessible for discovery queryset = queryset.filter(visibility=PluginTemplate.Visibility.PLATFORM) # else: all templates (for platform admins) @@ -694,8 +755,15 @@ class PluginTemplateViewSet(viewsets.ModelViewSet): from .template_parser import TemplateVariableParser from rest_framework.exceptions import PermissionDenied - # Check permission to create plugins + # Check permission to use plugins first tenant = getattr(self.request, 'tenant', None) + if tenant and not tenant.has_feature('can_use_plugins'): + raise PermissionDenied( + "Your current plan does not include Plugin access. " + "Please upgrade your subscription to use plugins." + ) + + # Check permission to create plugins (requires can_use_plugins) if tenant and not tenant.has_feature('can_create_plugins'): raise PermissionDenied( "Your current plan does not include Plugin Creation. " @@ -773,6 +841,14 @@ class PluginTemplateViewSet(viewsets.ModelViewSet): "cron_expression": "0 0 * * *" } """ + # Check permission to use plugins + tenant = getattr(request, 'tenant', None) + if tenant and not tenant.has_feature('can_use_plugins'): + return Response( + {'error': 'Your current plan does not include Plugin access. Please upgrade your subscription to install plugins.'}, + status=status.HTTP_403_FORBIDDEN + ) + template = self.get_object() # Check if template is accessible @@ -957,12 +1033,36 @@ class PluginInstallationViewSet(viewsets.ModelViewSet): - Update installation (update to latest version) - Uninstall plugin - Rate and review plugin + + Permissions: + - Requires can_use_plugins feature for all operations """ queryset = PluginInstallation.objects.select_related('template', 'scheduled_task').all() serializer_class = PluginInstallationSerializer permission_classes = [AllowAny] # TODO: Change to IsAuthenticated for production ordering = ['-installed_at'] + def _check_plugins_permission(self): + """Check if tenant has permission to access plugin installations.""" + from rest_framework.exceptions import PermissionDenied + + tenant = getattr(self.request, 'tenant', None) + if tenant and not tenant.has_feature('can_use_plugins'): + raise PermissionDenied( + "Your current plan does not include Plugin access. " + "Please upgrade your subscription to use plugins." + ) + + def list(self, request, *args, **kwargs): + """List plugin installations with permission check.""" + self._check_plugins_permission() + return super().list(request, *args, **kwargs) + + def retrieve(self, request, *args, **kwargs): + """Retrieve a plugin installation with permission check.""" + self._check_plugins_permission() + return super().retrieve(request, *args, **kwargs) + def get_queryset(self): """Return installations for current user/tenant""" queryset = super().get_queryset() @@ -973,6 +1073,20 @@ class PluginInstallationViewSet(viewsets.ModelViewSet): return queryset + def perform_create(self, serializer): + """Check permission to use plugins before installing""" + from rest_framework.exceptions import PermissionDenied + + # Check permission to use plugins + tenant = getattr(self.request, 'tenant', None) + if tenant and not tenant.has_feature('can_use_plugins'): + raise PermissionDenied( + "Your current plan does not include Plugin access. " + "Please upgrade your subscription to use plugins." + ) + + serializer.save() + @action(detail=True, methods=['post']) def update_to_latest(self, request, pk=None): """Update installed plugin to latest template version""" @@ -1080,6 +1194,19 @@ class EventPluginViewSet(viewsets.ModelViewSet): return queryset.order_by('execution_order', 'created_at') + def perform_create(self, serializer): + """Check permission to use plugins before attaching to event""" + from rest_framework.exceptions import PermissionDenied + + tenant = getattr(self.request, 'tenant', None) + if tenant and not tenant.has_feature('can_use_plugins'): + raise PermissionDenied( + "Your current plan does not include Plugin access. " + "Please upgrade your subscription to use plugins." + ) + + serializer.save() + def list(self, request): """ List event plugins. @@ -1195,7 +1322,16 @@ class GlobalEventPluginViewSet(viewsets.ModelViewSet): return queryset.order_by('execution_order', 'created_at') def perform_create(self, serializer): - """Set created_by on creation""" + """Check permission to use plugins and set created_by on creation""" + from rest_framework.exceptions import PermissionDenied + + tenant = getattr(self.request, 'tenant', None) + if tenant and not tenant.has_feature('can_use_plugins'): + raise PermissionDenied( + "Your current plan does not include Plugin access. " + "Please upgrade your subscription to use plugins." + ) + user = self.request.user if self.request.user.is_authenticated else None serializer.save(created_by=user) diff --git a/smoothschedule/smoothschedule/comms_credits/views.py b/smoothschedule/smoothschedule/comms_credits/views.py index 27572ab..7147a9f 100644 --- a/smoothschedule/smoothschedule/comms_credits/views.py +++ b/smoothschedule/smoothschedule/comms_credits/views.py @@ -812,6 +812,13 @@ def list_phone_numbers_view(request): status=status.HTTP_400_BAD_REQUEST ) + # Check if tenant has masked calling feature + if not tenant.has_feature('can_use_masked_phone_numbers'): + return Response( + {'error': 'Masked calling feature not available on your plan'}, + status=status.HTTP_403_FORBIDDEN + ) + numbers = ProxyPhoneNumber.objects.filter( assigned_tenant=tenant, is_active=True, diff --git a/smoothschedule/smoothschedule/public_api/views.py b/smoothschedule/smoothschedule/public_api/views.py index 45dc554..924102c 100644 --- a/smoothschedule/smoothschedule/public_api/views.py +++ b/smoothschedule/smoothschedule/public_api/views.py @@ -135,11 +135,23 @@ class APITokenViewSet(viewsets.ViewSet): This endpoint requires regular user authentication (not API token auth) and is intended for business owners to manage their API tokens. + + Requires can_api_access permission. """ # Use session/token auth for token management, not API token auth authentication_classes = [SessionAuthentication, TokenAuthentication] permission_classes = [IsAuthenticated] + def _check_api_access_permission(self, tenant): + """Check if tenant has permission to use API access.""" + from rest_framework.exceptions import PermissionDenied + + if tenant and not tenant.has_feature('can_api_access'): + raise PermissionDenied( + "Your current plan does not include API Access. " + "Please upgrade your subscription to manage API tokens." + ) + def list(self, request): """List all API tokens for the current business.""" user = request.user @@ -154,6 +166,9 @@ class APITokenViewSet(viewsets.ViewSet): status=status.HTTP_403_FORBIDDEN ) + # Check API access permission + self._check_api_access_permission(tenant) + # Only owners can manage API tokens (roles are uppercase in DB) allowed_roles = ['OWNER', 'TENANT_OWNER', 'SUPERUSER', 'PLATFORM_MANAGER', 'TENANT_MANAGER'] if user.role.upper() not in allowed_roles: @@ -180,6 +195,9 @@ class APITokenViewSet(viewsets.ViewSet): status=status.HTTP_403_FORBIDDEN ) + # Check API access permission + self._check_api_access_permission(tenant) + allowed_roles = ['OWNER', 'TENANT_OWNER', 'SUPERUSER', 'PLATFORM_MANAGER', 'TENANT_MANAGER'] if user.role.upper() not in allowed_roles: return Response( @@ -1116,18 +1134,10 @@ class WebhookViewSet(PublicAPIViewMixin, viewsets.ViewSet): """ permission_classes = [HasAPIToken, CanManageWebhooks] - def list(self, request): - """List webhook subscriptions for the current API token.""" - token = request.api_token - subscriptions = WebhookSubscription.objects.filter(api_token=token) - serializer = WebhookSubscriptionSerializer(subscriptions, many=True) - return Response(serializer.data) - - def create(self, request): - """Create a new webhook subscription.""" + def _check_webhooks_permission(self, request): + """Check if tenant has permission to use webhooks.""" from rest_framework.exceptions import PermissionDenied - # Check permission to use webhooks token = request.api_token tenant = token.tenant if tenant and not tenant.has_feature('can_use_webhooks'): @@ -1136,6 +1146,18 @@ class WebhookViewSet(PublicAPIViewMixin, viewsets.ViewSet): "Please upgrade your subscription to use webhooks." ) + def list(self, request): + """List webhook subscriptions for the current API token.""" + self._check_webhooks_permission(request) + token = request.api_token + subscriptions = WebhookSubscription.objects.filter(api_token=token) + serializer = WebhookSubscriptionSerializer(subscriptions, many=True) + return Response(serializer.data) + + def create(self, request): + """Create a new webhook subscription.""" + self._check_webhooks_permission(request) + serializer = WebhookSubscriptionCreateSerializer(data=request.data) if not serializer.is_valid(): return Response( @@ -1160,6 +1182,7 @@ class WebhookViewSet(PublicAPIViewMixin, viewsets.ViewSet): def retrieve(self, request, pk=None): """Get webhook subscription details.""" + self._check_webhooks_permission(request) token = request.api_token try: