diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 93788b5..9e49375 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -22,6 +22,7 @@ import { Business, User } from '../types'; import { useLogout } from '../hooks/useAuth'; import { usePlanFeatures } from '../hooks/usePlanFeatures'; import SmoothScheduleLogo from './SmoothScheduleLogo'; +import UnfinishedBadge from './ui/UnfinishedBadge'; import { SidebarSection, SidebarItem, @@ -127,6 +128,7 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo label={t('nav.tasks', 'Tasks')} isCollapsed={isCollapsed} locked={!canUse('plugins') || !canUse('tasks')} + badgeElement={} /> )} {isStaff && ( @@ -155,6 +157,7 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo icon={Users} label={t('nav.customers')} isCollapsed={isCollapsed} + badgeElement={} /> = ({ business, user, isCollapsed, toggleCo icon={Users} label={t('nav.staff')} isCollapsed={isCollapsed} + badgeElement={} /> {canUse('contracts') && ( = ({ business, user, isCollapsed, toggleCo icon={FileSignature} label={t('nav.contracts', 'Contracts')} isCollapsed={isCollapsed} + badgeElement={} /> )} = ({ business, user, isCollapsed, toggleCo label={t('nav.plugins', 'Plugins')} isCollapsed={isCollapsed} locked={!canUse('plugins')} + badgeElement={} /> )} diff --git a/frontend/src/components/navigation/SidebarComponents.tsx b/frontend/src/components/navigation/SidebarComponents.tsx index 7e29650..57a2a0e 100644 --- a/frontend/src/components/navigation/SidebarComponents.tsx +++ b/frontend/src/components/navigation/SidebarComponents.tsx @@ -47,6 +47,7 @@ interface SidebarItemProps { exact?: boolean; disabled?: boolean; badge?: string | number; + badgeElement?: React.ReactNode; variant?: 'default' | 'settings'; locked?: boolean; } @@ -62,6 +63,7 @@ export const SidebarItem: React.FC = ({ exact = false, disabled = false, badge, + badgeElement, variant = 'default', locked = false, }) => { @@ -97,8 +99,10 @@ export const SidebarItem: React.FC = ({
{!isCollapsed && {label}} - {badge && !isCollapsed && ( - {badge} + {(badge || badgeElement) && !isCollapsed && ( + badgeElement || ( + {badge} + ) )}
); @@ -113,10 +117,12 @@ export const SidebarItem: React.FC = ({ {locked && } )} - {badge && !isCollapsed && ( - - {badge} - + {(badge || badgeElement) && !isCollapsed && ( + badgeElement || ( + + {badge} + + ) )} ); @@ -256,6 +262,7 @@ interface SettingsSidebarItemProps { label: string; description?: string; locked?: boolean; + badgeElement?: React.ReactNode; } /** @@ -267,6 +274,7 @@ export const SettingsSidebarItem: React.FC = ({ label, description, locked = false, + badgeElement, }) => { const location = useLocation(); const isActive = location.pathname === to || location.pathname.startsWith(to + '/'); @@ -289,6 +297,7 @@ export const SettingsSidebarItem: React.FC = ({ {locked && ( )} + {badgeElement} {description && (

diff --git a/frontend/src/components/services/CustomerPreview.tsx b/frontend/src/components/services/CustomerPreview.tsx new file mode 100644 index 0000000..73357fb --- /dev/null +++ b/frontend/src/components/services/CustomerPreview.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Clock, + MapPin, + User, + Calendar, + CheckCircle2, + AlertCircle +} from 'lucide-react'; +import { Service, Business } from '../../types'; +import Card from '../ui/Card'; +import Badge from '../ui/Badge'; + +interface CustomerPreviewProps { + service: Service | null; // Null when creating new + business: Business; + previewData?: Partial; // Live form data +} + +export const CustomerPreview: React.FC = ({ + service, + business, + previewData +}) => { + const { t } = useTranslation(); + + // Merge existing service data with live form preview + const data = { + ...service, + ...previewData, + price: previewData?.price ?? service?.price ?? 0, + name: previewData?.name || service?.name || 'New Service', + description: previewData?.description || service?.description || 'Service description will appear here...', + durationMinutes: previewData?.durationMinutes ?? service?.durationMinutes ?? 30, + }; + + const formatPrice = (price: number | string) => { + const numPrice = typeof price === 'string' ? parseFloat(price) : price; + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(numPrice); + }; + + const formatDuration = (minutes: number) => { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + if (hours > 0) return `${hours}h ${mins > 0 ? `${mins}m` : ''}`; + return `${mins}m`; + }; + + return ( +

+
+

+ Customer Preview +

+ Live Preview +
+ + {/* Booking Page Card Simulation */} +
+ {/* Cover Image Placeholder */} +
+
+ + {data.name.charAt(0)} + +
+
+ +
+
+
+

+ {data.name} +

+
+ + {formatDuration(data.durationMinutes)} + + {data.category?.name || 'General'} +
+
+
+
+ {data.variable_pricing ? ( + 'Variable' + ) : ( + formatPrice(data.price) + )} +
+ {data.deposit_amount && data.deposit_amount > 0 && ( +
+ {formatPrice(data.deposit_amount)} deposit +
+ )} +
+
+ +

+ {data.description} +

+ +
+
+
+ +
+ Online booking available +
+ + {(data.resource_ids?.length || 0) > 0 && !data.all_resources && ( +
+
+ +
+ Specific staff only +
+ )} +
+ +
+ +
+
+
+ +
+ +

+ This is how your service will appear to customers on your booking page. +

+
+
+ ); +}; + +export default CustomerPreview; diff --git a/frontend/src/components/services/ResourceSelector.tsx b/frontend/src/components/services/ResourceSelector.tsx new file mode 100644 index 0000000..91bd8ca --- /dev/null +++ b/frontend/src/components/services/ResourceSelector.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Users, + Search, + Check, + X, + AlertCircle +} from 'lucide-react'; +import { Resource } from '../../types'; +import FormInput from '../ui/FormInput'; +import Badge from '../ui/Badge'; + +interface ResourceSelectorProps { + resources: Resource[]; + selectedIds: string[]; + allSelected: boolean; + onChange: (ids: string[], all: boolean) => void; +} + +export const ResourceSelector: React.FC = ({ + resources, + selectedIds, + allSelected, + onChange +}) => { + const { t } = useTranslation(); + const [search, setSearch] = React.useState(''); + + const filteredResources = resources.filter(r => + r.name.toLowerCase().includes(search.toLowerCase()) + ); + + const handleToggle = (id: string) => { + if (allSelected) { + // If switching from All to Specific, start with just this one selected? + // Or keep all others selected? + // Better UX: "All" is a special mode. If you uncheck one, you enter "Specific" mode with all-minus-one selected. + // But we don't have all IDs readily available without mapping. + // Let's assume typical toggle logic. + + // Actually, if "All" is true, we should probably toggle it OFF and select just this ID? + // Or select all EXCEPT this ID? + // Let's simplify: Toggle "All Staff" switch separately. + return; + } + + const newIds = selectedIds.includes(id) + ? selectedIds.filter(i => i !== id) + : [...selectedIds, id]; + + onChange(newIds, false); + }; + + const handleAllToggle = () => { + if (!allSelected) { + onChange([], true); + } else { + onChange([], false); // Clear selection or keep? Let's clear for now. + } + }; + + return ( +
+ {/* Header / All Toggle */} +
+
+
+ +
+
+

All Staff Available

+

+ Automatically include current and future staff +

+
+
+ +
+ + {!allSelected && ( +
+
+
+ + setSearch(e.target.value)} + placeholder="Search staff..." + className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-brand-500 outline-none" + /> +
+
+ +
+ {filteredResources.length === 0 ? ( +
+ No staff found matching "{search}" +
+ ) : ( + filteredResources.map(resource => ( + + )) + )} +
+ +
+ {selectedIds.length} staff selected + {selectedIds.length === 0 && ( + + At least one required + + )} +
+
+ )} +
+ ); +}; + +export default ResourceSelector; diff --git a/frontend/src/components/services/ServiceListItem.tsx b/frontend/src/components/services/ServiceListItem.tsx new file mode 100644 index 0000000..0b6cb68 --- /dev/null +++ b/frontend/src/components/services/ServiceListItem.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Clock, + Users, + MoreVertical, + Pencil, + Trash2, + GripVertical, + DollarSign, + AlertCircle +} from 'lucide-react'; +import { Service } from '../../types'; +import Badge from '../ui/Badge'; +import Card from '../ui/Card'; + +interface ServiceListItemProps { + service: Service; + onEdit: (service: Service) => void; + onDelete: (service: Service) => void; + dragHandleProps?: any; +} + +export const ServiceListItem: React.FC = ({ + service, + onEdit, + onDelete, + dragHandleProps +}) => { + const { t } = useTranslation(); + + const formatPrice = (price: number | string) => { + const numPrice = typeof price === 'string' ? parseFloat(price) : price; + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(numPrice); + }; + + const formatDuration = (minutes: number) => { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + if (hours > 0) { + return `${hours}h ${mins > 0 ? `${mins}m` : ''}`; + } + return `${mins}m`; + }; + + return ( +
+ {/* Drag Handle */} +
+ +
+ + {/* Content */} +
+ {/* Name & Description */} +
+
+

+ {service.name} +

+ {service.category && ( + + {service.category.name} + + )} +
+

+ {service.description || 'No description provided'} +

+
+ + {/* Stats */} +
+
+ + {formatDuration(service.durationMinutes)} +
+
+ + + {service.variable_pricing ? ( + Variable + ) : ( + formatPrice(service.price) + )} + +
+
+ + {/* Meta */} +
+ {service.all_resources ? ( + + All Staff + + ) : ( + + {service.resource_ids?.length || 0} Staff + + )} +
+
+ + {/* Actions */} +
+ + +
+
+ ); +}; + +export default ServiceListItem; diff --git a/frontend/src/components/services/__tests__/ServiceListItem.test.tsx b/frontend/src/components/services/__tests__/ServiceListItem.test.tsx new file mode 100644 index 0000000..411e499 --- /dev/null +++ b/frontend/src/components/services/__tests__/ServiceListItem.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import ServiceListItem from '../ServiceListItem'; +import { Service } from '../../../types'; + +// Mock dependencies +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string, val: string) => val || key }), +})); + +// Mock Lucide icons +vi.mock('lucide-react', () => ({ + Clock: () => , + Users: () => , + MoreVertical: () => , + Pencil: () => , + Trash2: () => , + GripVertical: () => , + DollarSign: () => , + AlertCircle: () => , +})); + +const mockService: Service = { + id: '1', + name: 'Test Service', + description: 'Test Description', + durationMinutes: 60, + price: 50, + variable_pricing: false, + all_resources: true, + resource_ids: [], + category: { id: 'cat1', name: 'Category 1' } +} as any; // Cast to avoid strict type checks on missing optional fields + +describe('ServiceListItem', () => { + it('renders service details correctly', () => { + render( + + ); + + expect(screen.getByText('Test Service')).toBeInTheDocument(); + expect(screen.getByText('Test Description')).toBeInTheDocument(); + expect(screen.getByText('Category 1')).toBeInTheDocument(); + expect(screen.getByText('1h')).toBeInTheDocument(); // 60 mins + expect(screen.getByText('$50.00')).toBeInTheDocument(); + }); + + it('renders variable pricing correctly', () => { + const variableService = { ...mockService, variable_pricing: true }; + render( + + ); + + expect(screen.getByText('Variable')).toBeInTheDocument(); + }); + + it('triggers action callbacks', () => { + const onEdit = vi.fn(); + const onDelete = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByTitle('Edit')); + expect(onEdit).toHaveBeenCalledWith(mockService); + + fireEvent.click(screen.getByTitle('Delete')); + expect(onDelete).toHaveBeenCalledWith(mockService); + }); +}); diff --git a/frontend/src/components/ui/UnfinishedBadge.tsx b/frontend/src/components/ui/UnfinishedBadge.tsx new file mode 100644 index 0000000..6d63ad5 --- /dev/null +++ b/frontend/src/components/ui/UnfinishedBadge.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Badge from './Badge'; + +export const UnfinishedBadge: React.FC = () => { + return ( + + WIP + + ); +}; + +export default UnfinishedBadge; diff --git a/frontend/src/layouts/SettingsLayout.tsx b/frontend/src/layouts/SettingsLayout.tsx index 34ad844..2cd6652 100644 --- a/frontend/src/layouts/SettingsLayout.tsx +++ b/frontend/src/layouts/SettingsLayout.tsx @@ -26,6 +26,7 @@ import { SettingsSidebarSection, SettingsSidebarItem, } from '../components/navigation/SidebarComponents'; +import UnfinishedBadge from '../components/ui/UnfinishedBadge'; import { Business, User, PlanPermissions } from '../types'; import { usePlanFeatures, FeatureKey } from '../hooks/usePlanFeatures'; @@ -100,6 +101,7 @@ const SettingsLayout: React.FC = () => { icon={Layers} label={t('settings.resourceTypes.title', 'Resource Types')} description={t('settings.resourceTypes.description', 'Staff, rooms, equipment')} + badgeElement={} /> void; + icon: React.ElementType; + label: string; + description?: string; +} + +const SelectionTile: React.FC = ({ + selected, + onClick, + icon: Icon, + label, + description +}) => ( +
+
+ +
+ + {label} + + {description && ( + + {description} + + )} + {selected && ( +
+ +
+ )} +
+); + const Messages: React.FC = () => { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -194,17 +250,17 @@ const Messages: React.FC = () => { // Computed const roleOptions = [ - { value: 'owner', label: 'All Owners', icon: Users }, - { value: 'manager', label: 'All Managers', icon: Users }, - { value: 'staff', label: 'All Staff', icon: Users }, - { value: 'customer', label: 'All Customers', icon: Users }, + { value: 'owner', label: 'Owners', icon: Users, description: 'Business owners' }, + { value: 'manager', label: 'Managers', icon: Users, description: 'Team leads' }, + { value: 'staff', label: 'Staff', icon: Users, description: 'Employees' }, + { value: 'customer', label: 'Customers', icon: Users, description: 'Clients' }, ]; const deliveryMethodOptions = [ - { value: 'IN_APP' as const, label: 'In-App Only', icon: Bell }, - { value: 'EMAIL' as const, label: 'Email Only', icon: Mail }, - { value: 'SMS' as const, label: 'SMS Only', icon: Smartphone }, - { value: 'ALL' as const, label: 'All Channels', icon: MessageSquare }, + { value: 'IN_APP' as const, label: 'In-App', icon: Bell, description: 'Notifications only' }, + { value: 'EMAIL' as const, label: 'Email', icon: Mail, description: 'Send via email' }, + { value: 'SMS' as const, label: 'SMS', icon: Smartphone, description: 'Text message' }, + { value: 'ALL' as const, label: 'All Channels', icon: MessageSquare, description: 'Maximum reach' }, ]; const filteredMessages = useMemo(() => { @@ -281,34 +337,10 @@ const Messages: React.FC = () => { const getStatusBadge = (status: string) => { switch (status) { - case 'SENT': - return ( - - - Sent - - ); - case 'SENDING': - return ( - - - Sending - - ); - case 'FAILED': - return ( - - - Failed - - ); - default: - return ( - - - Draft - - ); + case 'SENT': return Sent; + case 'SENDING': return Sending; + case 'FAILED': return Failed; + default: return Draft; } }; @@ -335,502 +367,467 @@ const Messages: React.FC = () => { } if (message.target_users.length > 0) { - parts.push(`${message.target_users.length} individual user(s)`); + parts.push(`${message.target_users.length} user(s)`); } return parts.join(', '); }; return ( -
+
{/* Header */}
-

Broadcast Messages

-

- Send messages to staff and customers +

Broadcast Messages

+

+ Reach your staff and customers across multiple channels.

{/* Tabs */} -
- -
+ + }, + { + id: 'sent', + label: `Sent History ${messages.length > 0 ? `(${messages.length})` : ''}`, + icon: + } + ]} + activeTab={activeTab} + onChange={(id) => setActiveTab(id as TabType)} + className="w-full sm:w-auto" + /> {/* Compose Tab */} {activeTab === 'compose' && ( -
-
- {/* Subject */} -
- - setSubject(e.target.value)} - className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-transparent dark:bg-gray-700 dark:text-white" - placeholder="Enter message subject..." - required - /> -
- - {/* Body */} -
- -