diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e116800d..0457e2ba 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -66,10 +66,12 @@ const PlatformStaff = React.lazy(() => import('./pages/platform/PlatformStaff')) const PlatformSettings = React.lazy(() => import('./pages/platform/PlatformSettings')); const BillingManagement = React.lazy(() => import('./pages/platform/BillingManagement')); const PlatformStaffEmail = React.lazy(() => import('./pages/platform/PlatformStaffEmail')); +const PlatformEmailTemplates = React.lazy(() => import('./pages/platform/PlatformEmailTemplates')); const ProfileSettings = React.lazy(() => import('./pages/ProfileSettings')); const VerifyEmail = React.lazy(() => import('./pages/VerifyEmail')); const EmailVerificationRequired = React.lazy(() => import('./pages/EmailVerificationRequired')); const AcceptInvitePage = React.lazy(() => import('./pages/AcceptInvitePage')); +const PlatformStaffInvitePage = React.lazy(() => import('./pages/platform/PlatformStaffInvitePage')); const TenantOnboardPage = React.lazy(() => import('./pages/TenantOnboardPage')); const TenantLandingPage = React.lazy(() => import('./pages/TenantLandingPage')); const Tickets = React.lazy(() => import('./pages/Tickets')); // Import Tickets page @@ -375,6 +377,7 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> @@ -411,6 +414,7 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> @@ -419,10 +423,10 @@ const AppContent: React.FC = () => { ); } - // For platform subdomain, only /platform/login exists - everything else renders nothing + // For platform subdomain, only specific paths exist - everything else renders nothing if (isPlatformSubdomain) { const path = window.location.pathname; - const allowedPaths = ['/platform/login', '/mfa-verify', '/verify-email']; + const allowedPaths = ['/platform/login', '/mfa-verify', '/verify-email', '/platform-staff-invite']; // If not an allowed path, render nothing if (!allowedPaths.includes(path)) { @@ -435,6 +439,7 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> ); @@ -460,6 +465,7 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> @@ -599,6 +605,7 @@ const AppContent: React.FC = () => { <> } /> } /> + } /> )} } /> diff --git a/frontend/src/billing/components/FeaturePicker.tsx b/frontend/src/billing/components/FeaturePicker.tsx index a8f4f62a..62fb9e78 100644 --- a/frontend/src/billing/components/FeaturePicker.tsx +++ b/frontend/src/billing/components/FeaturePicker.tsx @@ -9,6 +9,7 @@ import React, { useState, useMemo } from 'react'; import { Check, Sliders, Search, X } from 'lucide-react'; import type { Feature, PlanFeatureWrite } from '../../hooks/useBillingAdmin'; +import { isWipFeature } from '../featureCatalog'; export interface FeaturePickerProps { /** Available features from the API */ @@ -168,8 +169,13 @@ export const FeaturePicker: React.FC = ({ className="mt-0.5 rounded border-gray-300 dark:border-gray-600" />
- + {feature.name} + {isWipFeature(feature.code) && ( + + WIP + + )} {feature.code} @@ -219,8 +225,13 @@ export const FeaturePicker: React.FC = ({ className="mt-0.5 rounded border-gray-300 dark:border-gray-600" />
- + {feature.name} + {isWipFeature(feature.code) && ( + + WIP + + )} {feature.code} diff --git a/frontend/src/billing/featureCatalog.ts b/frontend/src/billing/featureCatalog.ts index db98db96..2133c3c2 100644 --- a/frontend/src/billing/featureCatalog.ts +++ b/frontend/src/billing/featureCatalog.ts @@ -34,6 +34,8 @@ export interface FeatureCatalogEntry { description: string; type: FeatureType; category: FeatureCategory; + /** Feature is work-in-progress and not yet enforced */ + wip?: boolean; } export type FeatureCategory = @@ -66,13 +68,6 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [ type: 'boolean', category: 'communication', }, - { - code: 'proxy_number_enabled', - name: 'Proxy Phone Numbers', - description: 'Use proxy phone numbers for customer communication', - type: 'boolean', - category: 'communication', - }, // Payments & Commerce { @@ -88,6 +83,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [ description: 'Use Point of Sale (POS) system', type: 'boolean', category: 'access', + wip: true, }, // Scheduling & Booking @@ -97,27 +93,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [ description: 'Schedule recurring appointments', type: 'boolean', category: 'scheduling', - }, - { - code: 'group_bookings', - name: 'Group Bookings', - description: 'Allow multiple customers per appointment', - type: 'boolean', - category: 'scheduling', - }, - { - code: 'waitlist', - name: 'Waitlist', - description: 'Enable waitlist for fully booked slots', - type: 'boolean', - category: 'scheduling', - }, - { - code: 'can_add_video_conferencing', - name: 'Video Conferencing', - description: 'Add video conferencing to events', - type: 'boolean', - category: 'scheduling', + wip: true, }, // Access & Features @@ -127,13 +103,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [ description: 'Access the public API for integrations', type: 'boolean', category: 'access', - }, - { - code: 'can_use_analytics', - name: 'Analytics Dashboard', - description: 'Access business analytics and reporting', - type: 'boolean', - category: 'access', + wip: true, }, { code: 'can_use_tasks', @@ -149,19 +119,13 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [ type: 'boolean', category: 'access', }, - { - code: 'customer_portal', - name: 'Customer Portal', - description: 'Branded self-service portal for customers', - type: 'boolean', - category: 'access', - }, { code: 'custom_fields', name: 'Custom Fields', - description: 'Create custom data fields for resources and events', + description: 'Add custom intake fields to services for customer booking', type: 'boolean', category: 'access', + wip: true, }, { code: 'can_export_data', @@ -169,44 +133,26 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [ description: 'Export data (appointments, customers, etc.)', type: 'boolean', category: 'access', + wip: true, }, { - code: 'can_use_mobile_app', + code: 'mobile_app_access', name: 'Mobile App', description: 'Access the mobile app for field employees', type: 'boolean', category: 'access', + wip: true, + }, + { + code: 'proxy_number_enabled', + name: 'Proxy Phone Numbers', + description: 'Assign dedicated phone numbers to staff for customer communication', + type: 'boolean', + category: 'communication', + wip: true, }, // Integrations - { - code: 'calendar_sync', - name: 'Calendar Sync', - description: 'Sync with Google Calendar, Outlook, etc.', - type: 'boolean', - category: 'integrations', - }, - { - code: 'webhooks_enabled', - name: 'Webhooks', - description: 'Send webhook notifications for events', - type: 'boolean', - category: 'integrations', - }, - { - code: 'can_use_plugins', - name: 'Plugin Integrations', - description: 'Use third-party plugin integrations', - type: 'boolean', - category: 'integrations', - }, - { - code: 'can_create_plugins', - name: 'Create Plugins', - description: 'Create custom plugins for automation', - type: 'boolean', - category: 'integrations', - }, { code: 'can_manage_oauth_credentials', name: 'Manage OAuth', @@ -217,21 +163,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [ // Branding { - code: 'custom_branding', - name: 'Custom Branding', - description: 'Customize branding colors, logo, and styling', - type: 'boolean', - category: 'branding', - }, - { - code: 'remove_branding', - name: 'Remove Branding', - description: 'Remove SmoothSchedule branding from customer-facing pages', - type: 'boolean', - category: 'branding', - }, - { - code: 'can_use_custom_domain', + code: 'custom_domain', name: 'Custom Domain', description: 'Configure a custom domain for your booking page', type: 'boolean', @@ -245,6 +177,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [ description: 'Get priority customer support response', type: 'boolean', category: 'support', + wip: true, }, // Security & Compliance @@ -254,6 +187,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [ description: 'Require two-factor authentication for users', type: 'boolean', category: 'security', + wip: true, }, { code: 'sso_enabled', @@ -261,20 +195,15 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [ description: 'Enable SSO authentication for team members', type: 'boolean', category: 'security', + wip: true, }, { - code: 'can_delete_data', - name: 'Delete Data', - description: 'Permanently delete data', - type: 'boolean', - category: 'security', - }, - { - code: 'can_download_logs', - name: 'Download Logs', - description: 'Download system logs', + code: 'audit_logs', + name: 'Audit Logs', + description: 'Track changes and download audit logs', type: 'boolean', category: 'security', + wip: true, }, ]; @@ -406,6 +335,14 @@ export const isCanonicalFeature = (code: string): boolean => { return featureMap.has(code); }; +/** + * Check if a feature is work-in-progress (not yet enforced) + */ +export const isWipFeature = (code: string): boolean => { + const feature = featureMap.get(code); + return feature?.wip ?? false; +}; + /** * Get all features by type */ diff --git a/frontend/src/components/AppointmentQuotaBanner.tsx b/frontend/src/components/AppointmentQuotaBanner.tsx new file mode 100644 index 00000000..29b2d3f5 --- /dev/null +++ b/frontend/src/components/AppointmentQuotaBanner.tsx @@ -0,0 +1,150 @@ +/** + * AppointmentQuotaBanner Component + * + * Shows a warning banner when the user has reached 90% of their monthly + * appointment quota. Dismissable per-user per-billing-period. + * + * This is different from QuotaWarningBanner which handles grace period + * overages for permanent limits like max_users. + */ + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { AlertTriangle, X, TrendingUp, Calendar } from 'lucide-react'; +import { useQuotaStatus, useDismissQuotaBanner } from '../hooks/useQuotaStatus'; + +interface AppointmentQuotaBannerProps { + /** Only show for owners/managers who can take action */ + userRole?: string; +} + +const AppointmentQuotaBanner: React.FC = ({ userRole }) => { + const { t } = useTranslation(); + const { data: quotaStatus, isLoading } = useQuotaStatus(); + const dismissMutation = useDismissQuotaBanner(); + + // Don't show while loading or if no data + if (isLoading || !quotaStatus) { + return null; + } + + // Don't show if banner shouldn't be shown + if (!quotaStatus.warning.show_banner) { + return null; + } + + // Only show for owners and managers who can take action + if (userRole && !['owner', 'manager'].includes(userRole)) { + return null; + } + + const { appointments, billing_period } = quotaStatus; + + // Don't show if unlimited + if (appointments.is_unlimited) { + return null; + } + + const isOverQuota = appointments.is_over_quota; + const percentage = Math.round(appointments.usage_percentage); + + const handleDismiss = () => { + dismissMutation.mutate(); + }; + + // Format billing period for display + const billingPeriodDisplay = new Date( + billing_period.year, + billing_period.month - 1 + ).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); + + return ( +
+
+
+ {/* Left: Warning Info */} +
+
+ {isOverQuota ? ( + + ) : ( + + )} +
+
+ + {isOverQuota + ? t('quota.appointmentBanner.overTitle', 'Appointment Quota Exceeded') + : t('quota.appointmentBanner.warningTitle', 'Approaching Appointment Limit')} + + + + {t('quota.appointmentBanner.usage', '{{used}} of {{limit}} ({{percentage}}%)', { + used: appointments.count, + limit: appointments.limit, + percentage, + })} + {' • '} + {billingPeriodDisplay} + +
+
+ + {/* Right: Actions */} +
+ {isOverQuota && appointments.overage_count > 0 && ( + + {t('quota.appointmentBanner.overage', '+{{count}} @ $0.10 each', { + count: appointments.overage_count, + })} + + )} + + {t('quota.appointmentBanner.upgrade', 'Upgrade Plan')} + + +
+
+ + {/* Additional info for over-quota */} + {isOverQuota && ( +
+ {t( + 'quota.appointmentBanner.overageInfo', + 'Appointments over your limit will be billed at $0.10 each at the end of your billing cycle.' + )} +
+ )} +
+
+ ); +}; + +export default AppointmentQuotaBanner; diff --git a/frontend/src/components/PlatformSidebar.tsx b/frontend/src/components/PlatformSidebar.tsx index 92eb1be2..ca9cb42f 100644 --- a/frontend/src/components/PlatformSidebar.tsx +++ b/frontend/src/components/PlatformSidebar.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useLocation } from 'react-router-dom'; -import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail, CreditCard, Inbox } from 'lucide-react'; +import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail, CreditCard, Inbox, FileText } from 'lucide-react'; import { User } from '../types'; import SmoothScheduleLogo from './SmoothScheduleLogo'; @@ -81,6 +81,10 @@ const PlatformSidebar: React.FC = ({ user, isCollapsed, to {!isCollapsed && {t('nav.staff')}} + + + {!isCollapsed && {t('nav.emailTemplates', 'Email Templates')}} + {!isCollapsed && Billing} diff --git a/frontend/src/components/StorageQuotaBanner.tsx b/frontend/src/components/StorageQuotaBanner.tsx new file mode 100644 index 00000000..421597a7 --- /dev/null +++ b/frontend/src/components/StorageQuotaBanner.tsx @@ -0,0 +1,179 @@ +/** + * StorageQuotaBanner Component + * + * Shows a warning banner when the user has reached 90% of their database + * storage quota. This helps business owners be aware of storage usage + * and potential overage charges. + * + * Storage is measured periodically by a backend task and cached. + */ + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { AlertTriangle, X, Database, HardDrive } from 'lucide-react'; +import { useQuotaStatus } from '../hooks/useQuotaStatus'; + +interface StorageQuotaBannerProps { + /** Only show for owners/managers who can take action */ + userRole?: string; + /** Callback when banner is dismissed */ + onDismiss?: () => void; +} + +const StorageQuotaBanner: React.FC = ({ userRole, onDismiss }) => { + const { t } = useTranslation(); + const { data: quotaStatus, isLoading } = useQuotaStatus(); + + // Don't show while loading or if no data + if (isLoading || !quotaStatus) { + return null; + } + + const { storage, billing_period } = quotaStatus; + + // Don't show if unlimited + if (storage.is_unlimited) { + return null; + } + + // Don't show if not at warning threshold + if (!storage.is_at_warning_threshold) { + return null; + } + + // Only show for owners and managers who can take action + if (userRole && !['owner', 'manager'].includes(userRole)) { + return null; + } + + const isOverQuota = storage.is_over_quota; + const percentage = Math.round(storage.usage_percentage); + + // Format storage sizes for display + const formatSize = (mb: number): string => { + if (mb >= 1024) { + return `${(mb / 1024).toFixed(1)} GB`; + } + return `${mb.toFixed(1)} MB`; + }; + + const currentDisplay = formatSize(storage.current_size_mb); + const limitDisplay = formatSize(storage.quota_limit_mb); + const overageDisplay = storage.overage_mb > 0 ? formatSize(storage.overage_mb) : null; + + // Format billing period for display + const billingPeriodDisplay = new Date( + billing_period.year, + billing_period.month - 1 + ).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); + + // Format last measured time + const lastMeasuredDisplay = storage.last_measured_at + ? new Date(storage.last_measured_at).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) + : null; + + return ( +
+
+
+ {/* Left: Warning Info */} +
+
+ {isOverQuota ? ( + + ) : ( + + )} +
+
+ + {isOverQuota + ? t('quota.storageBanner.overTitle', 'Storage Quota Exceeded') + : t('quota.storageBanner.warningTitle', 'Approaching Storage Limit')} + + + + {t('quota.storageBanner.usage', '{{used}} of {{limit}} ({{percentage}}%)', { + used: currentDisplay, + limit: limitDisplay, + percentage, + })} + {' • '} + {billingPeriodDisplay} + +
+
+ + {/* Right: Actions */} +
+ {isOverQuota && overageDisplay && ( + + {t('quota.storageBanner.overage', '+{{size}} @ $0.50/GB', { + size: overageDisplay, + })} + + )} + + {t('quota.storageBanner.upgrade', 'Upgrade Plan')} + + {onDismiss && ( + + )} +
+
+ + {/* Additional info for over-quota */} + {isOverQuota && ( +
+ {t( + 'quota.storageBanner.overageInfo', + 'Storage over your limit will be billed at $0.50 per GB at the end of your billing cycle.' + )} +
+ )} + + {/* Last measured timestamp */} + {lastMeasuredDisplay && ( +
+ {t('quota.storageBanner.lastMeasured', 'Last measured: {{time}}', { + time: lastMeasuredDisplay, + })} +
+ )} +
+
+ ); +}; + +export default StorageQuotaBanner; diff --git a/frontend/src/components/marketing/FeatureComparisonTable.tsx b/frontend/src/components/marketing/FeatureComparisonTable.tsx index e3b91944..0f8acf07 100644 --- a/frontend/src/components/marketing/FeatureComparisonTable.tsx +++ b/frontend/src/components/marketing/FeatureComparisonTable.tsx @@ -52,8 +52,7 @@ const FEATURE_CATEGORIES = [ key: 'branding', features: [ { code: 'custom_domain', label: 'Custom domain' }, - { code: 'custom_branding', label: 'Custom branding' }, - { code: 'remove_branding', label: 'Remove branding' }, + { code: 'can_white_label', label: 'White label branding' }, ], }, { diff --git a/frontend/src/components/platform/DynamicFeaturesEditor.tsx b/frontend/src/components/platform/DynamicFeaturesEditor.tsx index c71a8208..a853249c 100644 --- a/frontend/src/components/platform/DynamicFeaturesEditor.tsx +++ b/frontend/src/components/platform/DynamicFeaturesEditor.tsx @@ -12,6 +12,7 @@ import React, { useMemo } from 'react'; import { Key, AlertCircle } from 'lucide-react'; import { useBillingFeatures, BillingFeature, FEATURE_CATEGORY_META } from '../../hooks/useBillingPlans'; +import { isWipFeature } from '../../billing/featureCatalog'; export interface DynamicFeaturesEditorProps { /** @@ -62,6 +63,11 @@ export interface DynamicFeaturesEditorProps { * Number of columns (default: 3) */ columns?: 2 | 3 | 4; + + /** + * Disable all inputs (for read-only mode) + */ + disabled?: boolean; } const DynamicFeaturesEditor: React.FC = ({ @@ -74,6 +80,7 @@ const DynamicFeaturesEditor: React.FC = ({ headerTitle = 'Features & Permissions', showDescriptions = false, columns = 3, + disabled = false, }) => { const { data: features, isLoading, error } = useBillingFeatures(); @@ -223,12 +230,13 @@ const DynamicFeaturesEditor: React.FC = ({ if (feature.feature_type === 'boolean') { const isChecked = currentValue === true; + const isInputDisabled = isDisabled || disabled; return (