diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0d89960 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,102 @@ +# SmoothSchedule - Multi-Tenant Scheduling Platform + +## Quick Reference + +### Project Structure +``` +/home/poduck/Desktop/smoothschedule2/ +├── frontend/ # React + Vite + TypeScript frontend +│ └── CLAUDE.md # Frontend-specific docs +│ +├── smoothschedule/ # Django backend (RUNS IN DOCKER!) +│ └── CLAUDE.md # Backend-specific docs +│ +└── legacy_reference/ # Old code for reference (do not modify) +``` + +### Development URLs +- **Frontend:** `http://demo.lvh.me:5173` (or any business subdomain) +- **Platform Frontend:** `http://platform.lvh.me:5173` +- **Backend API:** `http://lvh.me:8000/api/` + +Note: `lvh.me` resolves to `127.0.0.1` - required for subdomain cookies to work. + +## CRITICAL: Backend Runs in Docker + +**NEVER run Django commands directly.** Always use Docker Compose: + +```bash +cd /home/poduck/Desktop/smoothschedule2/smoothschedule + +# Run migrations +docker compose -f docker-compose.local.yml exec django python manage.py migrate + +# Django shell +docker compose -f docker-compose.local.yml exec django python manage.py shell + +# View logs +docker compose -f docker-compose.local.yml logs -f django + +# Any management command +docker compose -f docker-compose.local.yml exec django python manage.py +``` + +## Key Configuration Files + +### Backend (Django) +| File | Purpose | +|------|---------| +| `smoothschedule/docker-compose.local.yml` | Docker services config | +| `smoothschedule/.envs/.local/.django` | Django env vars (SECRET_KEY, etc.) | +| `smoothschedule/.envs/.local/.postgres` | Database credentials | +| `smoothschedule/config/settings/local.py` | Local Django settings | +| `smoothschedule/config/settings/base.py` | Base Django settings | +| `smoothschedule/config/urls.py` | URL routing | + +### Frontend (React) +| File | Purpose | +|------|---------| +| `frontend/.env.development` | Vite env vars | +| `frontend/vite.config.ts` | Vite configuration | +| `frontend/src/api/client.ts` | Axios API client | +| `frontend/src/types.ts` | TypeScript interfaces | +| `frontend/src/i18n/locales/en.json` | Translations | + +## Key Django Apps + +| App | Location | Purpose | +|-----|----------|---------| +| `schedule` | `smoothschedule/smoothschedule/schedule/` | Resources, Events, Services | +| `users` | `smoothschedule/smoothschedule/users/` | Authentication, User model | +| `tenants` | `smoothschedule/smoothschedule/tenants/` | Multi-tenancy (Business model) | + +## Common Tasks + +### After modifying Django models: +```bash +cd /home/poduck/Desktop/smoothschedule2/smoothschedule +docker compose -f docker-compose.local.yml exec django python manage.py makemigrations +docker compose -f docker-compose.local.yml exec django python manage.py migrate +``` + +### After modifying frontend: +Frontend hot-reloads automatically. If issues, restart: +```bash +cd /home/poduck/Desktop/smoothschedule2/frontend +npm run dev +``` + +### Debugging 500 errors: +```bash +cd /home/poduck/Desktop/smoothschedule2/smoothschedule +docker compose -f docker-compose.local.yml logs django --tail=100 +``` + +### Testing API directly: +```bash +curl -s "http://lvh.me:8000/api/resources/" | jq +``` + +## Git Branch +Currently on: `feature/platform-superuser-ui` +Main branch: `main` diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..67febae --- /dev/null +++ b/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,354 @@ +# Resource Types & Logo Upload - Implementation Summary + +## ✅ Completed + +### Backend Models + +1. **ResourceType Model** (`smoothschedule/schedule/models.py`) + - Custom resource type definitions (e.g., "Hair Stylist", "Massage Room") + - Category field: STAFF (requires staff assignment) or OTHER + - `is_default` flag to prevent deletion of core types + - Validation to prevent deletion when in use + - Database migration created and applied ✅ + +2. **Resource Model Updates** (`smoothschedule/schedule/models.py`) + - Added `resource_type` ForeignKey to ResourceType model + - Kept legacy `type` field for backwards compatibility + - Migration created and applied ✅ + +3. **Tenant Model Updates** (`smoothschedule/core/models.py`) + - Added `logo` ImageField for business logo upload + - Added `logo_display_mode` field with choices: + - `logo-only`: Show only logo in sidebar + - `text-only`: Show only business name (default) + - `logo-and-text`: Show both logo and name + - Added `primary_color` and `secondary_color` fields + - Database migration created and applied ✅ + +### Frontend Updates + +1. **TypeScript Types** (`frontend/src/types.ts`) + - Added `ResourceTypeDefinition` interface + - Added `ResourceTypeCategory` type + - Added `logoDisplayMode` to Business interface + - Updated Resource interface with `typeId` field + +2. **React Hooks** (`frontend/src/hooks/useResourceTypes.ts`) + - `useResourceTypes()` - Fetch resource types + - `useCreateResourceType()` - Create new type + - `useUpdateResourceType()` - Update existing type + - `useDeleteResourceType()` - Delete type (with validation) + - Includes placeholder data for default types + +3. **Sidebar Component** (`frontend/src/components/Sidebar.tsx`) + - Updated to display logo based on `logoDisplayMode` + - Shows logo image when mode is `logo-only` or `logo-and-text` + - Hides business name text when mode is `logo-only` + - Maintains fallback to initials when no logo + +4. **Resource Modal** (`frontend/src/pages/Resources.tsx`) + - Resource type dropdown now works (no longer disabled) + - Staff autocomplete fully functional with keyboard navigation + - Debounced search to reduce API calls + - All three default types available (Staff, Room, Equipment) + +## ⏳ Next Steps + +### 1. Data Migration for Default Types +Create a data migration to populate default ResourceType records for existing tenants: + +```bash +cd /home/poduck/Desktop/smoothschedule2/smoothschedule +docker compose -f docker-compose.local.yml exec django python manage.py makemigrations schedule --empty --name create_default_resource_types +``` + +Then edit the migration to add: +```python +def create_default_types(apps, schema_editor): + ResourceType = apps.get_model('schedule', 'ResourceType') + + # Create default types + ResourceType.objects.get_or_create( + name='Staff', + defaults={ + 'category': 'STAFF', + 'is_default': True, + 'icon_name': 'user' + } + ) + ResourceType.objects.get_or_create( + name='Room', + defaults={ + 'category': 'OTHER', + 'is_default': True, + 'icon_name': 'home' + } + ) + ResourceType.objects.get_or_create( + name='Equipment', + defaults={ + 'category': 'OTHER', + 'is_default': True, + 'icon_name': 'wrench' + } + ) +``` + +### 2. API Serializers & Views +Create Django REST Framework serializers and viewsets: + +**File**: `smoothschedule/schedule/serializers.py` +```python +class ResourceTypeSerializer(serializers.ModelSerializer): + class Meta: + model = ResourceType + fields = ['id', 'name', 'category', 'is_default', 'icon_name', 'created_at'] + read_only_fields = ['id', 'created_at'] + + def validate_delete(self, instance): + if instance.is_default: + raise serializers.ValidationError("Cannot delete default resource types.") + if instance.resources.exists(): + raise serializers.ValidationError(f"Cannot delete - in use by {instance.resources.count()} resources.") +``` + +**File**: `smoothschedule/schedule/views.py` +```python +class ResourceTypeViewSet(viewsets.ModelViewSet): + queryset = ResourceType.objects.all() + serializer_class = ResourceTypeSerializer + permission_classes = [IsAuthenticated] + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + self.serializer_class().validate_delete(instance) + return super().destroy(request, *args, **kwargs) +``` + +**File**: `smoothschedule/schedule/urls.py` +```python +router.register(r'resource-types', ResourceTypeViewSet, basename='resourcetype') +``` + +### 3. Logo Upload API +Add logo upload endpoint to tenant/business API: + +**File**: `smoothschedule/core/api_views.py` (or similar) +```python +class TenantLogoUploadView(APIView): + permission_classes = [IsAuthenticated] + parser_classes = [MultiPartParser, FormParser] + + def post(self, request): + tenant = request.tenant # From django-tenants middleware + logo_file = request.FILES.get('logo') + + if not logo_file: + return Response({'error': 'No logo file provided'}, status=400) + + # Validate file size (5MB max) + if logo_file.size > 5 * 1024 * 1024: + return Response({'error': 'Logo file too large (max 5MB)'}, status=400) + + # Validate file type + if not logo_file.content_type.startswith('image/'): + return Response({'error': 'File must be an image'}, status=400) + + tenant.logo = logo_file + tenant.save() + + return Response({ + 'logoUrl': tenant.logo.url, + 'message': 'Logo uploaded successfully' + }) + + def delete(self, request): + tenant = request.tenant + tenant.logo.delete() + tenant.logo = None + tenant.save() + return Response({'message': 'Logo removed successfully'}) +``` + +### 4. Settings Page UI Components +Add to `frontend/src/pages/Settings.tsx`: + +#### A. Resource Types Tab +```tsx +{activeTab === 'resources' && ( +
+
+

Resource Types

+

Customize how you categorize your bookable resources.

+
+ + +
+)} +``` + +#### B. Logo Upload in Branding Section +```tsx +
+

Business Logo

+ + {/* Preview */} + {business.logoUrl ? ( +
+ Logo + +
+ ) : ( +
+ +
+ )} + + {/* Upload */} + + + + {/* Display Mode */} +
+ + +
+ + {/* Preview with menu background */} +
+ +
+
+ {business.logoUrl && (business.logoDisplayMode === 'logo-only' || business.logoDisplayMode === 'logo-and-text') ? ( + Logo + ) : ( +
+ {business.name.substring(0, 2).toUpperCase()} +
+ )} + {business.logoDisplayMode !== 'logo-only' && ( +
+

{business.name}

+

{business.subdomain}.smoothschedule.com

+
+ )} +
+
+
+
+``` + +### 5. Update Resources Page +Modify `frontend/src/pages/Resources.tsx` to use custom resource types: + +```tsx +const { data: resourceTypes = [] } = useResourceTypes(); + +// In the modal: + + +{/* Show staff selector if selected type is STAFF category */} +{selectedType?.category === 'STAFF' && ( + +)} +``` + +## Testing Checklist + +- [ ] Create a custom resource type (e.g., "Massage Therapist") +- [ ] Delete a custom type (should work if not in use) +- [ ] Try to delete a default type (should fail) +- [ ] Try to delete a type in use (should fail) +- [ ] Upload a business logo +- [ ] Change logo display mode and verify sidebar updates +- [ ] Remove logo +- [ ] Create a resource using custom type +- [ ] Verify staff assignment required for STAFF category types + +## Files Modified/Created + +### Backend +- ✅ `smoothschedule/schedule/models.py` - Added ResourceType model, updated Resource +- ✅ `smoothschedule/core/models.py` - Added logo fields to Tenant +- ✅ `smoothschedule/schedule/migrations/0007_*.py` - ResourceType migration +- ✅ `smoothschedule/core/migrations/0003_*.py` - Tenant logo migration +- ⏳ `smoothschedule/schedule/serializers.py` - Need ResourceTypeSerializer +- ⏳ `smoothschedule/schedule/views.py` - Need ResourceTypeViewSet +- ⏳ `smoothschedule/schedule/urls.py` - Need router registration +- ⏳ `smoothschedule/core/api_views.py` - Need logo upload endpoint + +### Frontend +- ✅ `frontend/src/types.ts` - Added ResourceType interfaces +- ✅ `frontend/src/hooks/useResourceTypes.ts` - CRUD hooks +- ✅ `frontend/src/components/Sidebar.tsx` - Logo display logic +- ✅ `frontend/src/pages/Resources.tsx` - Fixed dropdown, autocomplete +- ⏳ `frontend/src/pages/Settings.tsx` - Need resource types tab & logo upload UI +- ⏳ `frontend/src/components/ResourceTypesManager.tsx` - Need new component + +## Database Schema + +### ResourceType Table +```sql +CREATE TABLE schedule_resourcetype ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + category VARCHAR(10) NOT NULL DEFAULT 'OTHER', + is_default BOOLEAN NOT NULL DEFAULT FALSE, + icon_name VARCHAR(50), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); +``` + +### Tenant Table Updates +```sql +ALTER TABLE core_tenant ADD COLUMN logo VARCHAR(100); +ALTER TABLE core_tenant ADD COLUMN logo_display_mode VARCHAR(20) DEFAULT 'text-only'; +ALTER TABLE core_tenant ADD COLUMN primary_color VARCHAR(7) DEFAULT '#2563eb'; +ALTER TABLE core_tenant ADD COLUMN secondary_color VARCHAR(7) DEFAULT '#0ea5e9'; +``` + +### Resource Table Updates +```sql +ALTER TABLE schedule_resource ADD COLUMN resource_type_id INTEGER REFERENCES schedule_resourcetype(id) ON DELETE PROTECT; +``` + +## Notes + +- Logo files stored in `MEDIA_ROOT/tenant_logos/` +- Max file size: 5MB +- Supported formats: PNG, JPG, SVG +- Recommended dimensions: 500x500px (square) or 500x200px (wide) +- Default resource types are created via data migration +- Legacy `Resource.type` field kept for backwards compatibility diff --git a/RESOURCE_TYPES_PLAN.md b/RESOURCE_TYPES_PLAN.md new file mode 100644 index 0000000..05b5706 --- /dev/null +++ b/RESOURCE_TYPES_PLAN.md @@ -0,0 +1,281 @@ +# Custom Resource Types & Logo Upload - Implementation Plan + +## Overview +Allow businesses to create custom resource types (e.g., "Stylist", "Massage Therapist", "Treatment Room") instead of hardcoded types. Also add logo upload to business branding. + +## Features + +### 1. Custom Resource Types +- **Default Types**: Always include one "Staff" type (cannot be deleted) +- **Custom Names**: Users can name types whatever they want (e.g., "Hair Stylist", "Nail Technician", "Massage Room") +- **Categories**: Each type has a category: + - `STAFF`: Requires staff member assignment + - `OTHER`: No staff assignment needed +- **Management**: Add, edit, delete custom types in Business Settings + +### 2. Logo Upload +- Upload business logo in branding section +- Support PNG, JPG, SVG +- Preview before saving +- Used in customer-facing pages + +## Database Changes + +### Backend Models + +```python +# smoothschedule/schedule/models.py + +class ResourceType(models.Model): + """Custom resource type definitions per business""" + business = models.ForeignKey('tenants.Business', on_delete=models.CASCADE, related_name='resource_types') + name = models.CharField(max_length=100) # "Stylist", "Treatment Room", etc. + category = models.CharField(max_length=10, choices=[ + ('STAFF', 'Staff'), + ('OTHER', 'Other'), + ]) + is_default = models.BooleanField(default=False) # Cannot be deleted + icon_name = models.CharField(max_length=50, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ['business', 'name'] + ordering = ['name'] + + def __str__(self): + return f"{self.business.name} - {self.name}" + + +class Resource(models.Model): + """Update existing Resource model""" + # ... existing fields ... + + # NEW FIELD - references custom resource type + resource_type = models.ForeignKey( + ResourceType, + on_delete=models.PROTECT, # Cannot delete type if resources use it + related_name='resources', + null=True, # For migration + blank=True + ) + + # DEPRECATED - keep for backwards compatibility during migration + type = models.CharField( + max_length=20, + choices=[('STAFF', 'Staff'), ('ROOM', 'Room'), ('EQUIPMENT', 'Equipment')], + default='STAFF' + ) + + +# smoothschedule/tenants/models.py + +class Business(models.Model): + # ... existing fields ... + + # NEW FIELD - logo upload + logo = models.ImageField(upload_to='business_logos/', null=True, blank=True) +``` + +### Migration Strategy + +1. Create `ResourceType` model +2. For each business, create default resource types: + - "Staff" (category=STAFF, is_default=True) + - "Room" (category=OTHER, is_default=True) + - "Equipment" (category=OTHER, is_default=True) +3. Migrate existing resources to use default types based on their `type` field +4. Eventually deprecate `Resource.type` field + +## API Endpoints + +### Resource Types + +``` +GET /api/resource-types/ # List business resource types +POST /api/resource-types/ # Create new type +PATCH /api/resource-types/{id}/ # Update type +DELETE /api/resource-types/{id}/ # Delete type (if not in use & not default) +``` + +### Logo Upload + +``` +POST /api/business/logo/ # Upload logo +DELETE /api/business/logo/ # Remove logo +``` + +## Frontend Implementation + +### 1. Business Settings - Resource Types Tab + +```tsx +// Add new tab to Settings.tsx + + + + +``` + +### ResourceTypesManager Component + +```tsx +interface ResourceTypesManagerProps {} + +const ResourceTypesManager: React.FC = () => { + const { data: types = [] } = useResourceTypes(); + const createMutation = useCreateResourceType(); + const updateMutation = useUpdateResourceType(); + const deleteMutation = useDeleteResourceType(); + + return ( +
+

Resource Types

+

Customize how you categorize your bookable resources.

+ + {/* List of types */} + {types.map(type => ( + + ))} + + {/* Add new type button */} + + + {/* Create/Edit Modal */} + +
+ ); +}; +``` + +### 2. Logo Upload in Branding Section + +```tsx +// In Settings.tsx, Branding section + +
+

Business Logo

+ + {/* Logo preview */} + {business.logoUrl && ( +
+ Business logo + +
+ )} + + {/* Upload button */} + + +
+``` + +### 3. Update Resources.tsx to use custom types + +```tsx +// Resources.tsx + +const Resources: React.FC = () => { + const { data: resourceTypes = [] } = useResourceTypes(); + + return ( + + + + {/* Show staff selector if selected type is STAFF category */} + {selectedType?.category === 'STAFF' && ( + + )} + + ); +}; +``` + +## Implementation Steps + +### Phase 1: Backend Foundation +1. ✅ Create TypeScript types (DONE) +2. ⏳ Create Django models (`ResourceType`, add `Business.logo`) +3. ⏳ Create migrations with default data +4. ⏳ Create serializers +5. ⏳ Create API views and URLs +6. ⏳ Add file upload handling for logos + +### Phase 2: Frontend Hooks & Types +1. ✅ Create `useResourceTypes` hook (DONE) +2. ⏳ Create logo upload utilities +3. ⏳ Update `Resource` type to include `typeId` + +### Phase 3: UI Components +1. ⏳ Add "Resource Types" tab to Settings +2. ⏳ Create ResourceTypesManager component +3. ⏳ Create ResourceTypeModal component +4. ⏳ Add logo upload to Branding section +5. ⏳ Update Resources page to use custom types + +### Phase 4: Migration & Testing +1. ⏳ Test creating/editing/deleting types +2. ⏳ Test logo upload/removal +3. ⏳ Ensure backwards compatibility +4. ⏳ Test resource creation with custom types + +## User Experience + +### Creating a Custom Resource Type +1. Navigate to Business Settings → Resource Types +2. Click "+ Add Resource Type" +3. Enter name (e.g., "Massage Therapist") +4. Select category: Staff or Other +5. Click Save +6. New type appears in list and is available when creating resources + +### Uploading a Logo +1. Navigate to Business Settings → General → Branding +2. Click "Upload Logo" +3. Select image file (PNG, JPG, or SVG) +4. Preview appears +5. Click "Save Changes" +6. Logo appears in header and customer-facing pages + +## Benefits + +1. **Flexibility**: Businesses can name types to match their industry +2. **Clarity**: "Massage Therapist" is clearer than "Staff" for a spa +3. **Scalability**: Add new types as business grows +4. **Branding**: Logo upload improves professional appearance +5. **User-Friendly**: Simple UI for non-technical users + +## Notes + +- Default "Staff" type cannot be deleted (ensures at least one staff type exists) +- Cannot delete types that are in use by existing resources +- Logo file size limit: 5MB +- Logo recommended dimensions: 500x500px (square) or 500x200px (wide) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 7f08de0..1ae582c 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -1,5 +1,36 @@ # SmoothSchedule Frontend Development Guide +## Project Overview + +This is the React frontend for SmoothSchedule, a multi-tenant scheduling platform. + +**See also:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/CLAUDE.md` for backend documentation. + +## Key Paths + +``` +/home/poduck/Desktop/smoothschedule2/ +├── frontend/ # This React frontend +│ ├── src/ +│ │ ├── api/client.ts # Axios API client +│ │ ├── components/ # Reusable components +│ │ ├── hooks/ # React Query hooks (useResources, useAuth, etc.) +│ │ ├── pages/ # Page components +│ │ ├── types.ts # TypeScript interfaces +│ │ ├── i18n/locales/en.json # English translations +│ │ └── utils/cookies.ts # Cookie utilities +│ ├── .env.development # Frontend env vars +│ └── vite.config.ts # Vite configuration +│ +└── smoothschedule/ # Django backend (runs in Docker!) + ├── docker-compose.local.yml # Docker config + ├── .envs/.local/ # Backend env vars + ├── config/settings/ # Django settings + └── smoothschedule/ + ├── schedule/ # Core scheduling app + └── users/ # User management +``` + ## Local Development Domain Setup ### Why lvh.me instead of localhost? diff --git a/frontend/playwright-report/data/2a18c07f8cb8b46b200c1a5f81ddde2ae1b0547c.md b/frontend/playwright-report/data/2a18c07f8cb8b46b200c1a5f81ddde2ae1b0547c.md new file mode 100644 index 0000000..aebc645 --- /dev/null +++ b/frontend/playwright-report/data/2a18c07f8cb8b46b200c1a5f81ddde2ae1b0547c.md @@ -0,0 +1,268 @@ +# Page snapshot + +```yaml +- generic [ref=e1]: + - generic [ref=e3]: + - generic [ref=e5]: + - button "Collapse sidebar" [ref=e6]: + - generic [ref=e7]: DE + - generic [ref=e8]: + - heading "Demo Company" [level=1] [ref=e9] + - paragraph [ref=e10]: demo.smoothschedule.com + - navigation [ref=e11]: + - link "Dashboard" [ref=e12] [cursor=pointer]: + - /url: "#/" + - img [ref=e13] + - generic [ref=e18]: Dashboard + - link "Scheduler" [ref=e19] [cursor=pointer]: + - /url: "#/scheduler" + - img [ref=e20] + - generic [ref=e22]: Scheduler + - link "Customers" [ref=e23] [cursor=pointer]: + - /url: "#/customers" + - img [ref=e24] + - generic [ref=e29]: Customers + - link "Services" [ref=e30] [cursor=pointer]: + - /url: "#/services" + - img [ref=e31] + - generic [ref=e34]: Services + - link "Resources" [ref=e35] [cursor=pointer]: + - /url: "#/resources" + - img [ref=e36] + - generic [ref=e39]: Resources + - generic "Payments are disabled. Enable them in Business Settings to accept payments from customers." [ref=e40]: + - img [ref=e41] + - generic [ref=e43]: Payments + - link "Messages" [ref=e44] [cursor=pointer]: + - /url: "#/messages" + - img [ref=e45] + - generic [ref=e47]: Messages + - link "Staff" [ref=e48] [cursor=pointer]: + - /url: "#/staff" + - img [ref=e49] + - generic [ref=e54]: Staff + - link "Business Settings" [ref=e56] [cursor=pointer]: + - /url: "#/settings" + - img [ref=e57] + - generic [ref=e60]: Business Settings + - generic [ref=e61]: + - generic [ref=e62]: + - img [ref=e63] + - generic [ref=e69]: + - generic [ref=e70]: Powered by + - text: Smooth Schedule + - button "Sign Out" [ref=e71]: + - img [ref=e72] + - generic [ref=e75]: Sign Out + - generic [ref=e76]: + - banner [ref=e77]: + - generic [ref=e79]: + - img [ref=e81] + - textbox "Search" [ref=e84] + - generic [ref=e85]: + - button "🇺🇸 English" [ref=e87]: + - img [ref=e88] + - generic [ref=e91]: 🇺🇸 + - generic [ref=e92]: English + - img [ref=e93] + - button [ref=e95]: + - img [ref=e96] + - button [ref=e98]: + - img [ref=e99] + - button "Business Owner Owner BO" [ref=e104]: + - generic [ref=e105]: + - paragraph [ref=e106]: Business Owner + - paragraph [ref=e107]: Owner + - generic [ref=e108]: BO + - img [ref=e109] + - main [active] [ref=e111]: + - generic [ref=e112]: + - generic [ref=e113]: + - heading "Dashboard" [level=2] [ref=e114] + - paragraph [ref=e115]: Today's Overview + - generic [ref=e116]: + - generic [ref=e117]: + - paragraph [ref=e118]: Total Appointments + - generic [ref=e119]: + - generic [ref=e120]: "50" + - generic [ref=e121]: + - img [ref=e122] + - text: +12% + - generic [ref=e125]: + - paragraph [ref=e126]: Customers + - generic [ref=e127]: + - generic [ref=e128]: "1" + - generic [ref=e129]: + - img [ref=e130] + - text: +8% + - generic [ref=e133]: + - paragraph [ref=e134]: Services + - generic [ref=e135]: + - generic [ref=e136]: "5" + - generic [ref=e137]: + - img [ref=e138] + - text: 0% + - generic [ref=e139]: + - paragraph [ref=e140]: Resources + - generic [ref=e141]: + - generic [ref=e142]: "4" + - generic [ref=e143]: + - img [ref=e144] + - text: +3% + - generic [ref=e147]: + - generic [ref=e149]: + - generic [ref=e150]: + - img [ref=e152] + - heading "Quick Add Appointment" [level=3] [ref=e154] + - generic [ref=e155]: + - generic [ref=e156]: + - generic [ref=e157]: + - img [ref=e158] + - text: Customer + - combobox [ref=e161]: + - option "Walk-in / No customer" [selected] + - option "Customer User (customer@demo.com)" + - generic [ref=e162]: + - generic [ref=e163]: + - img [ref=e164] + - text: Service * + - combobox [ref=e167]: + - option "Select service..." [selected] + - option "Beard Trim (15 min - $15)" + - option "Consultation (30 min - $0)" + - option "Full Styling (60 min - $75)" + - option "Hair Coloring (90 min - $120)" + - option "Haircut (30 min - $35)" + - generic [ref=e168]: + - generic [ref=e169]: + - img [ref=e170] + - text: Resource + - combobox [ref=e173]: + - option "Unassigned" [selected] + - option "Conference Room A" + - option "Dental Chair 1" + - option "Meeting Room B" + - option "Meeting Room B" + - generic [ref=e174]: + - generic [ref=e175]: + - generic [ref=e176]: Date * + - textbox [ref=e177]: 2025-11-27 + - generic [ref=e178]: + - generic [ref=e179]: + - img [ref=e180] + - text: Time * + - combobox [ref=e183]: + - option "06:00" + - option "06:15" + - option "06:30" + - option "06:45" + - option "07:00" + - option "07:15" + - option "07:30" + - option "07:45" + - option "08:00" + - option "08:15" + - option "08:30" + - option "08:45" + - option "09:00" [selected] + - option "09:15" + - option "09:30" + - option "09:45" + - option "10:00" + - option "10:15" + - option "10:30" + - option "10:45" + - option "11:00" + - option "11:15" + - option "11:30" + - option "11:45" + - option "12:00" + - option "12:15" + - option "12:30" + - option "12:45" + - option "13:00" + - option "13:15" + - option "13:30" + - option "13:45" + - option "14:00" + - option "14:15" + - option "14:30" + - option "14:45" + - option "15:00" + - option "15:15" + - option "15:30" + - option "15:45" + - option "16:00" + - option "16:15" + - option "16:30" + - option "16:45" + - option "17:00" + - option "17:15" + - option "17:30" + - option "17:45" + - option "18:00" + - option "18:15" + - option "18:30" + - option "18:45" + - option "19:00" + - option "19:15" + - option "19:30" + - option "19:45" + - option "20:00" + - option "20:15" + - option "20:30" + - option "20:45" + - option "21:00" + - option "21:15" + - option "21:30" + - option "21:45" + - option "22:00" + - option "22:15" + - option "22:30" + - option "22:45" + - generic [ref=e184]: + - generic [ref=e185]: + - img [ref=e186] + - text: Notes + - textbox "Optional notes..." [ref=e189] + - button "Add Appointment" [disabled] [ref=e190]: + - img [ref=e191] + - text: Add Appointment + - generic [ref=e193]: + - heading "Total Revenue" [level=3] [ref=e194] + - application [ref=e198]: + - generic [ref=e202]: + - generic [ref=e203]: + - generic [ref=e205]: Mon + - generic [ref=e207]: Tue + - generic [ref=e209]: Wed + - generic [ref=e211]: Thu + - generic [ref=e213]: Fri + - generic [ref=e215]: Sat + - generic [ref=e217]: Sun + - generic [ref=e218]: + - generic [ref=e220]: $0 + - generic [ref=e222]: $1 + - generic [ref=e224]: $2 + - generic [ref=e226]: $3 + - generic [ref=e228]: $4 + - generic [ref=e229]: + - heading "Upcoming Appointments" [level=3] [ref=e230] + - application [ref=e234]: + - generic [ref=e250]: + - generic [ref=e251]: + - generic [ref=e253]: Mon + - generic [ref=e255]: Tue + - generic [ref=e257]: Wed + - generic [ref=e259]: Thu + - generic [ref=e261]: Fri + - generic [ref=e263]: Sat + - generic [ref=e265]: Sun + - generic [ref=e266]: + - generic [ref=e268]: "0" + - generic [ref=e270]: "3" + - generic [ref=e272]: "6" + - generic [ref=e274]: "9" + - generic [ref=e276]: "12" + - generic [ref=e277]: "0" +``` \ No newline at end of file diff --git a/frontend/playwright-report/data/54c066d04c391fc33d77982244596792cc0cf7ab.png b/frontend/playwright-report/data/54c066d04c391fc33d77982244596792cc0cf7ab.png new file mode 100644 index 0000000..79646ab Binary files /dev/null and b/frontend/playwright-report/data/54c066d04c391fc33d77982244596792cc0cf7ab.png differ diff --git a/frontend/playwright-report/index.html b/frontend/playwright-report/index.html index cd8f8ba..90dc1ff 100644 --- a/frontend/playwright-report/index.html +++ b/frontend/playwright-report/index.html @@ -82,4 +82,4 @@ Error generating stack: `+n.message+`
- \ No newline at end of file + \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8bb1a53..c63b3af 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -194,29 +194,29 @@ const AppContent: React.FC = () => { return hostname === 'lvh.me' || hostname === 'localhost' || hostname === '127.0.0.1'; }; - // Not authenticated - show public routes - if (!user) { - // On root domain, show marketing site - if (isRootDomain()) { - return ( - - }> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - } /> - } /> - } /> - - ); - } + // On root domain, ALWAYS show marketing site (even if logged in) + // Logged-in users will see a "Go to Dashboard" link in the navbar + if (isRootDomain()) { + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + + ); + } - // On business subdomain, show login + // Not authenticated on subdomain - show login + if (!user) { return ( } /> @@ -232,6 +232,43 @@ const AppContent: React.FC = () => { return ; } + // Subdomain validation for logged-in users + const currentHostname = window.location.hostname; + const isPlatformDomain = currentHostname === 'platform.lvh.me'; + const currentSubdomain = currentHostname.split('.')[0]; + const isBusinessSubdomain = !isRootDomain() && !isPlatformDomain && currentSubdomain !== 'api'; + + const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role); + const isBusinessUser = ['owner', 'manager', 'staff', 'resource'].includes(user.role); + const isCustomer = user.role === 'customer'; + + // RULE: Platform users must be on platform subdomain (not business subdomains) + if (isPlatformUser && isBusinessSubdomain) { + const port = window.location.port ? `:${window.location.port}` : ''; + window.location.href = `http://platform.lvh.me${port}/`; + return ; + } + + // RULE: Business users must be on their own business subdomain + if (isBusinessUser && isBusinessSubdomain && user.business_subdomain && user.business_subdomain !== currentSubdomain) { + const port = window.location.port ? `:${window.location.port}` : ''; + window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`; + return ; + } + + // RULE: Customers must be on their business subdomain + if (isCustomer && isPlatformDomain && user.business_subdomain) { + const port = window.location.port ? `:${window.location.port}` : ''; + window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`; + return ; + } + + if (isCustomer && isBusinessSubdomain && user.business_subdomain && user.business_subdomain !== currentSubdomain) { + const port = window.location.port ? `:${window.location.port}` : ''; + window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`; + return ; + } + // Handlers const toggleTheme = () => setDarkMode((prev) => !prev); const handleSignOut = () => { @@ -242,22 +279,20 @@ const AppContent: React.FC = () => { }; const handleMasquerade = (targetUser: any) => { - // Call the masquerade API with the target user's username - // Fallback to email prefix if username is not available - const username = targetUser.username || targetUser.email?.split('@')[0]; - if (!username) { - console.error('Cannot masquerade: no username or email available', targetUser); + // Call the masquerade API with the target user's id + const userId = targetUser.id; + if (!userId) { + console.error('Cannot masquerade: no user id available', targetUser); return; } - masqueradeMutation.mutate(username); + // Ensure userId is a number + const userPk = typeof userId === 'string' ? parseInt(userId, 10) : userId; + masqueradeMutation.mutate(userPk); }; // Helper to check access based on roles const hasAccess = (allowedRoles: string[]) => allowedRoles.includes(user.role); - // Platform users (superuser, platform_manager, platform_support) - const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role); - if (isPlatformUser) { return ( @@ -329,54 +364,16 @@ const AppContent: React.FC = () => { return ; } - // Check if we're on root/platform domain without proper business context - const currentHostname = window.location.hostname; - const isRootOrPlatform = currentHostname === 'lvh.me' || currentHostname === 'localhost' || currentHostname === 'platform.lvh.me'; - // Business error or no business found if (businessError || !business) { - // If user is a business owner on root domain, redirect to their business - if (isRootOrPlatform && user.role === 'owner' && user.business_subdomain) { + // If user has a business subdomain, redirect them there + if (user.business_subdomain) { const port = window.location.port ? `:${window.location.port}` : ''; window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`; return ; } - // If on root/platform and shouldn't be here, show appropriate message - if (isRootOrPlatform) { - return ( -
-
-

Wrong Location

-

- {user.business_subdomain - ? `Please access the app at your business subdomain: ${user.business_subdomain}.lvh.me` - : 'Your account is not associated with a business. Please contact support.'} -

-
- {user.business_subdomain && ( - - )} - -
-
-
- ); - } - + // No business subdomain - show error return (
diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 9c1b27d..ad66a94 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -86,15 +86,15 @@ export const refreshToken = async (refresh: string): Promise<{ access: string }> }; /** - * Masquerade as another user + * Masquerade as another user (hijack) */ export const masquerade = async ( - username: string, - masquerade_stack?: MasqueradeStackEntry[] + user_pk: number, + hijack_history?: MasqueradeStackEntry[] ): Promise => { const response = await apiClient.post( - `/api/users/${username}/masquerade/`, - { masquerade_stack } + '/api/auth/hijack/acquire/', + { user_pk, hijack_history } ); return response.data; }; @@ -106,7 +106,7 @@ export const stopMasquerade = async ( masquerade_stack: MasqueradeStackEntry[] ): Promise => { const response = await apiClient.post( - '/api/users/stop_masquerade/', + '/api/auth/hijack/release/', { masquerade_stack } ); return response.data; diff --git a/frontend/src/api/platform.ts b/frontend/src/api/platform.ts index 8364881..30e1699 100644 --- a/frontend/src/api/platform.ts +++ b/frontend/src/api/platform.ts @@ -5,14 +5,23 @@ import apiClient from './client'; +export interface PlatformBusinessOwner { + id: number; + username: string; + full_name: string; + email: string; + role: string; +} + export interface PlatformBusiness { id: number; name: string; subdomain: string; tier: string; is_active: boolean; - created_at: string; + created_on: string; user_count: number; + owner: PlatformBusinessOwner | null; } export interface PlatformUser { diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 852645d..c9c8568 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -34,7 +34,7 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo ? location.pathname === path : location.pathname.startsWith(path); - const baseClasses = `flex items-center gap-3 py-3 text-sm font-medium rounded-lg transition-colors`; + const baseClasses = `flex items-center gap-3 py-3 text-base font-medium rounded-lg transition-colors`; const collapsedClasses = isCollapsed ? 'px-3 justify-center' : 'px-4'; const activeClasses = 'bg-white/10 text-white'; const inactiveClasses = 'text-white/70 hover:text-white hover:bg-white/5'; @@ -70,14 +70,40 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo className={`flex items-center gap-3 w-full text-left px-6 py-8 ${isCollapsed ? 'justify-center' : ''} hover:bg-white/5 transition-colors focus:outline-none`} aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"} > -
- {business.name.substring(0, 2).toUpperCase()} -
- {!isCollapsed && ( -
-

{business.name}

-

{business.subdomain}.smoothschedule.com

+ {/* Logo-only mode: full width */} + {business.logoDisplayMode === 'logo-only' && business.logoUrl ? ( +
+ {business.name}
+ ) : ( + <> + {/* Logo/Icon display */} + {business.logoUrl && business.logoDisplayMode !== 'text-only' ? ( +
+ {business.name} +
+ ) : business.logoDisplayMode !== 'logo-only' && ( +
+ {business.name.substring(0, 2).toUpperCase()} +
+ )} + + {/* Text display */} + {!isCollapsed && business.logoDisplayMode !== 'logo-only' && ( +
+

{business.name}

+

{business.subdomain}.smoothschedule.com

+
+ )} + )} @@ -111,19 +137,22 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo {canViewAdminPages && ( <> - {business.paymentsEnabled ? ( - - - {!isCollapsed && {t('nav.payments')}} - - ) : ( -
- - {!isCollapsed && {t('nav.payments')}} -
+ {/* Payments link: always visible for owners, only visible for others if enabled */} + {(role === 'owner' || business.paymentsEnabled) && ( + business.paymentsEnabled ? ( + + + {!isCollapsed && {t('nav.payments')}} + + ) : ( +
+ + {!isCollapsed && {t('nav.payments')}} +
+ ) )} @@ -149,7 +178,12 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo
- + {/* Login Button - Hidden on mobile */} - - {t('marketing.nav.login')} - + {user ? ( + + {t('marketing.nav.login')} + + ) : ( + + {t('marketing.nav.login')} + + )} {/* Get Started CTA */} = ({ darkMode, toggleTheme }) => { ))}
- - {t('marketing.nav.login')} - + {user ? ( + + {t('marketing.nav.login')} + + ) : ( + + {t('marketing.nav.login')} + + )} { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (username: string) => { + mutationFn: async (user_pk: number) => { // Get current masquerading stack from localStorage const stackJson = localStorage.getItem('masquerade_stack'); const currentStack: MasqueradeStackEntry[] = stackJson ? JSON.parse(stackJson) : []; // Call masquerade API with current stack - return masquerade(username, currentStack); + return masquerade(user_pk, currentStack); }, onSuccess: async (data) => { // Store the updated masquerading stack diff --git a/frontend/src/hooks/useBusiness.ts b/frontend/src/hooks/useBusiness.ts index bb0e26c..5610631 100644 --- a/frontend/src/hooks/useBusiness.ts +++ b/frontend/src/hooks/useBusiness.ts @@ -33,6 +33,8 @@ export const useCurrentBusiness = () => { primaryColor: data.primary_color || '#3B82F6', // Blue-500 default secondaryColor: data.secondary_color || '#1E40AF', // Blue-800 default logoUrl: data.logo_url, + emailLogoUrl: data.email_logo_url, + logoDisplayMode: data.logo_display_mode || 'text-only', whitelabelEnabled: data.whitelabel_enabled, plan: data.tier, // Map tier to plan status: data.status, @@ -64,6 +66,8 @@ export const useUpdateBusiness = () => { if (updates.primaryColor) backendData.primary_color = updates.primaryColor; if (updates.secondaryColor) backendData.secondary_color = updates.secondaryColor; if (updates.logoUrl !== undefined) backendData.logo_url = updates.logoUrl; + if (updates.emailLogoUrl !== undefined) backendData.email_logo_url = updates.emailLogoUrl; + if (updates.logoDisplayMode !== undefined) backendData.logo_display_mode = updates.logoDisplayMode; if (updates.whitelabelEnabled !== undefined) { backendData.whitelabel_enabled = updates.whitelabelEnabled; } @@ -136,7 +140,7 @@ export const useBusinessUsers = () => { return useQuery({ queryKey: ['businessUsers'], queryFn: async () => { - const { data } = await apiClient.get('/api/business/users/'); + const { data } = await apiClient.get('/api/staff/'); return data; }, staleTime: 5 * 60 * 1000, // 5 minutes diff --git a/frontend/src/hooks/useResourceTypes.ts b/frontend/src/hooks/useResourceTypes.ts new file mode 100644 index 0000000..41735d0 --- /dev/null +++ b/frontend/src/hooks/useResourceTypes.ts @@ -0,0 +1,91 @@ +/** + * Resource Types Management Hooks + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import apiClient from '../api/client'; +import { ResourceTypeDefinition } from '../types'; + +/** + * Hook to fetch resource types for the current business + */ +export const useResourceTypes = () => { + return useQuery({ + queryKey: ['resourceTypes'], + queryFn: async () => { + const { data } = await apiClient.get('/api/resource-types/'); + return data; + }, + // Provide default types if API doesn't have them yet + placeholderData: [ + { + id: 'default-staff', + name: 'Staff', + category: 'STAFF', + isDefault: true, + }, + { + id: 'default-room', + name: 'Room', + category: 'OTHER', + isDefault: true, + }, + { + id: 'default-equipment', + name: 'Equipment', + category: 'OTHER', + isDefault: true, + }, + ] as ResourceTypeDefinition[], + }); +}; + +/** + * Hook to create a new resource type + */ +export const useCreateResourceType = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (newType: Omit) => { + const { data } = await apiClient.post('/api/resource-types/', newType); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['resourceTypes'] }); + }, + }); +}; + +/** + * Hook to update a resource type + */ +export const useUpdateResourceType = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, updates }: { id: string; updates: Partial }) => { + const { data } = await apiClient.patch(`/api/resource-types/${id}/`, updates); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['resourceTypes'] }); + }, + }); +}; + +/** + * Hook to delete a resource type + */ +export const useDeleteResourceType = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + await apiClient.delete(`/api/resource-types/${id}/`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['resourceTypes'] }); + }, + }); +}; diff --git a/frontend/src/hooks/useResources.ts b/frontend/src/hooks/useResources.ts index f83164c..afae0f2 100644 --- a/frontend/src/hooks/useResources.ts +++ b/frontend/src/hooks/useResources.ts @@ -28,6 +28,8 @@ export const useResources = (filters?: ResourceFilters) => { name: r.name, type: r.type as ResourceType, userId: r.user_id ? String(r.user_id) : undefined, + maxConcurrentEvents: r.max_concurrent_events ?? 1, + savedLaneCount: r.saved_lane_count, })); }, }); @@ -47,6 +49,8 @@ export const useResource = (id: string) => { name: data.name, type: data.type as ResourceType, userId: data.user_id ? String(data.user_id) : undefined, + maxConcurrentEvents: data.max_concurrent_events ?? 1, + savedLaneCount: data.saved_lane_count, }; }, enabled: !!id, @@ -91,6 +95,12 @@ export const useUpdateResource = () => { if (updates.userId !== undefined) { backendData.user = updates.userId ? parseInt(updates.userId) : null; } + if (updates.maxConcurrentEvents !== undefined) { + backendData.max_concurrent_events = updates.maxConcurrentEvents; + } + if (updates.savedLaneCount !== undefined) { + backendData.saved_lane_count = updates.savedLaneCount; + } const { data } = await apiClient.patch(`/api/resources/${id}/`, backendData); return data; diff --git a/frontend/src/hooks/useServices.ts b/frontend/src/hooks/useServices.ts index dfc1f2a..94c3ebb 100644 --- a/frontend/src/hooks/useServices.ts +++ b/frontend/src/hooks/useServices.ts @@ -22,6 +22,8 @@ export const useServices = () => { durationMinutes: s.duration || s.duration_minutes, price: parseFloat(s.price), description: s.description || '', + displayOrder: s.display_order ?? 0, + photos: s.photos || [], })); }, retry: false, // Don't retry on 404 - endpoint may not exist yet @@ -43,6 +45,8 @@ export const useService = (id: string) => { durationMinutes: data.duration || data.duration_minutes, price: parseFloat(data.price), description: data.description || '', + displayOrder: data.display_order ?? 0, + photos: data.photos || [], }; }, enabled: !!id, @@ -63,6 +67,7 @@ export const useCreateService = () => { duration: serviceData.durationMinutes, price: serviceData.price.toString(), description: serviceData.description, + photos: serviceData.photos || [], }; const { data } = await apiClient.post('/api/services/', backendData); @@ -87,6 +92,7 @@ export const useUpdateService = () => { if (updates.durationMinutes) backendData.duration = updates.durationMinutes; if (updates.price) backendData.price = updates.price.toString(); if (updates.description !== undefined) backendData.description = updates.description; + if (updates.photos !== undefined) backendData.photos = updates.photos; const { data } = await apiClient.patch(`/api/services/${id}/`, backendData); return data; @@ -112,3 +118,22 @@ export const useDeleteService = () => { }, }); }; + +/** + * Hook to reorder services (drag and drop) + */ +export const useReorderServices = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (orderedIds: string[]) => { + // Convert string IDs to numbers for the backend + const order = orderedIds.map(id => parseInt(id, 10)); + const { data } = await apiClient.post('/api/services/reorder/', { order }); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['services'] }); + }, + }); +}; diff --git a/frontend/src/hooks/useStaff.ts b/frontend/src/hooks/useStaff.ts new file mode 100644 index 0000000..c9f580b --- /dev/null +++ b/frontend/src/hooks/useStaff.ts @@ -0,0 +1,42 @@ +/** + * Staff Management Hooks + */ + +import { useQuery } from '@tanstack/react-query'; +import apiClient from '../api/client'; + +export interface StaffMember { + id: string; + name: string; + email: string; + phone?: string; +} + +interface StaffFilters { + search?: string; +} + +/** + * Hook to fetch staff members with optional filters + * Staff members are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF + */ +export const useStaff = (filters?: StaffFilters) => { + return useQuery({ + queryKey: ['staff', filters], + queryFn: async () => { + const params = new URLSearchParams(); + if (filters?.search) params.append('search', filters.search); + + const { data } = await apiClient.get(`/api/staff/?${params}`); + + // Transform backend format to frontend format + return data.map((s: any) => ({ + id: String(s.id), + name: s.name || `${s.first_name || ''} ${s.last_name || ''}`.trim() || s.email, + email: s.email || '', + phone: s.phone || '', + })); + }, + retry: false, + }); +}; diff --git a/frontend/src/layouts/MarketingLayout.tsx b/frontend/src/layouts/MarketingLayout.tsx index 3a064ba..abc49ae 100644 --- a/frontend/src/layouts/MarketingLayout.tsx +++ b/frontend/src/layouts/MarketingLayout.tsx @@ -3,8 +3,13 @@ import { Outlet } from 'react-router-dom'; import Navbar from '../components/marketing/Navbar'; import Footer from '../components/marketing/Footer'; import { useScrollToTop } from '../hooks/useScrollToTop'; +import { User } from '../api/auth'; -const MarketingLayout: React.FC = () => { +interface MarketingLayoutProps { + user?: User | null; +} + +const MarketingLayout: React.FC = ({ user }) => { useScrollToTop(); const [darkMode, setDarkMode] = useState(() => { @@ -28,7 +33,7 @@ const MarketingLayout: React.FC = () => { return (
- + {/* Main Content - with padding for fixed navbar */}
diff --git a/frontend/src/pages/Customers.tsx b/frontend/src/pages/Customers.tsx index a146fde..72291f5 100644 --- a/frontend/src/pages/Customers.tsx +++ b/frontend/src/pages/Customers.tsx @@ -99,7 +99,8 @@ const Customers: React.FC = ({ onMasquerade, effectiveUser }) => return sorted; }, [customers, searchTerm, sortConfig]); - const canMasquerade = ['owner', 'manager', 'staff'].includes(effectiveUser.role); + // Only owners can masquerade as customers (per backend permissions) + const canMasquerade = effectiveUser.role === 'owner'; if (isLoading) { return ( diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 7375506..cc2a65e 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -33,40 +33,68 @@ const LoginPage: React.FC = () => { const user = data.user; const currentHostname = window.location.hostname; const currentPort = window.location.port; + const portStr = currentPort ? `:${currentPort}` : ''; - // Check if we're on the root domain (no subdomain) + // Check domain type const isRootDomain = currentHostname === 'lvh.me' || currentHostname === 'localhost'; + const isPlatformDomain = currentHostname === 'platform.lvh.me'; + const currentSubdomain = currentHostname.split('.')[0]; + const isBusinessSubdomain = !isRootDomain && !isPlatformDomain && currentSubdomain !== 'api'; - // Roles allowed to login at the root domain - const rootAllowedRoles = ['superuser', 'platform_manager', 'platform_support', 'owner']; + // Platform users (superuser, platform_manager, platform_support) + const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role); - // If on root domain, only allow specific roles - if (isRootDomain && !rootAllowedRoles.includes(user.role)) { - setError(t('auth.loginAtSubdomain')); + // Business-associated users (owner, manager, staff, resource) + const isBusinessUser = ['owner', 'manager', 'staff', 'resource'].includes(user.role); + + // Customer users + const isCustomer = user.role === 'customer'; + + // RULE 1: Platform users cannot login on business subdomains + if (isPlatformUser && isBusinessSubdomain) { + setError(t('auth.invalidCredentials')); return; } - // Determine the correct subdomain based on user role + // RULE 2: Business users cannot login on other business subdomains + if (isBusinessUser && isBusinessSubdomain && user.business_subdomain !== currentSubdomain) { + setError(t('auth.invalidCredentials')); + return; + } + + // RULE 3: Customers cannot login on root domain (they must use their business subdomain) + if (isCustomer && isRootDomain) { + setError(t('auth.invalidCredentials')); + return; + } + + // RULE 4: Customers cannot login on platform domain + if (isCustomer && isPlatformDomain) { + setError(t('auth.invalidCredentials')); + return; + } + + // RULE 5: Customers cannot login on a different business subdomain + if (isCustomer && isBusinessSubdomain && user.business_subdomain !== currentSubdomain) { + setError(t('auth.invalidCredentials')); + return; + } + + // Determine target subdomain for redirect let targetSubdomain: string | null = null; - // Platform users (superuser, platform_manager, platform_support) - if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) { + if (isPlatformUser) { targetSubdomain = 'platform'; - } - // Business users - redirect to their business subdomain - else if (user.business_subdomain) { + } else if (user.business_subdomain) { targetSubdomain = user.business_subdomain; } // Check if we need to redirect to a different subdomain - // Need to redirect if we have a target subdomain AND we're not already on it const isOnTargetSubdomain = currentHostname === `${targetSubdomain}.lvh.me`; const needsRedirect = targetSubdomain && !isOnTargetSubdomain; if (needsRedirect) { // Pass tokens in URL to ensure they're available immediately on the new subdomain - // This avoids race conditions where cookies might not be set before the page loads - const portStr = currentPort ? `:${currentPort}` : ''; window.location.href = `http://${targetSubdomain}.lvh.me${portStr}/?access_token=${data.access}&refresh_token=${data.refresh}`; return; } diff --git a/frontend/src/pages/Resources.tsx b/frontend/src/pages/Resources.tsx index b086996..1656f8f 100644 --- a/frontend/src/pages/Resources.tsx +++ b/frontend/src/pages/Resources.tsx @@ -1,5 +1,5 @@ -import React, { useMemo, useEffect, useState, useRef } from 'react'; +import React, { useMemo, useEffect, useState, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { ResourceType, User, Resource } from '../types'; import { useResources, useCreateResource, useUpdateResource } from '../hooks/useResources'; @@ -15,7 +15,8 @@ import { Eye, Calendar, Settings, - X + X, + Pencil } from 'lucide-react'; const ResourceIcon: React.FC<{ type: ResourceType }> = ({ type }) => { @@ -55,17 +56,29 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => // Staff selection state const [selectedStaffId, setSelectedStaffId] = useState(null); const [staffSearchQuery, setStaffSearchQuery] = useState(''); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); const [showStaffDropdown, setShowStaffDropdown] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); const staffInputRef = useRef(null); const staffDropdownRef = useRef(null); - // Fetch staff members for autocomplete - const { data: staffMembers = [] } = useStaff({ search: staffSearchQuery }); + // Debounce search query for API calls + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchQuery(staffSearchQuery); + }, 300); + return () => clearTimeout(timer); + }, [staffSearchQuery]); + + // Fetch staff members for autocomplete (using debounced query) + const { data: staffMembers = [] } = useStaff({ search: debouncedSearchQuery }); // Filter staff members based on search query (client-side filtering for immediate feedback) const filteredStaff = useMemo(() => { - if (!staffSearchQuery) return staffMembers; - const query = staffSearchQuery.toLowerCase(); + // Always show all staff when dropdown is open and no search query + if (!staffSearchQuery.trim()) return staffMembers; + + const query = staffSearchQuery.toLowerCase().trim(); return staffMembers.filter( (s) => s.name.toLowerCase().includes(query) || s.email.toLowerCase().includes(query) ); @@ -76,6 +89,59 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => return staffMembers.find((s) => s.id === selectedStaffId) || null; }, [staffMembers, selectedStaffId]); + // Get the list that's currently displayed in the dropdown + const displayedStaff = useMemo(() => { + return staffSearchQuery.trim() === '' ? staffMembers : filteredStaff; + }, [staffSearchQuery, staffMembers, filteredStaff]); + + // Reset highlighted index when filtered staff changes + useEffect(() => { + setHighlightedIndex(-1); + }, [filteredStaff]); + + // Handle keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!showStaffDropdown || displayedStaff.length === 0) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setHighlightedIndex((prev) => + prev < displayedStaff.length - 1 ? prev + 1 : prev + ); + break; + case 'ArrowUp': + e.preventDefault(); + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : -1)); + break; + case 'Enter': + e.preventDefault(); + if (highlightedIndex >= 0 && highlightedIndex < displayedStaff.length) { + const staff = displayedStaff[highlightedIndex]; + setSelectedStaffId(staff.id); + setStaffSearchQuery(staff.name); + setShowStaffDropdown(false); + setHighlightedIndex(-1); + } + break; + case 'Escape': + e.preventDefault(); + setShowStaffDropdown(false); + setHighlightedIndex(-1); + break; + } + }; + + // Scroll highlighted item into view + useEffect(() => { + if (highlightedIndex >= 0 && staffDropdownRef.current) { + const highlightedElement = staffDropdownRef.current.children[highlightedIndex] as HTMLElement; + if (highlightedElement) { + highlightedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + } + }, [highlightedIndex]); + const createResourceMutation = useCreateResource(); const updateResourceMutation = useUpdateResource(); @@ -97,6 +163,8 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => }, [allAppointments]); // Reset form when modal opens/closes or editing resource changes + // NOTE: Only depend on editingResource and isModalOpen, NOT staffMembers + // to avoid clearing the form when staff data updates during search useEffect(() => { if (editingResource) { setFormType(editingResource.type); @@ -108,28 +176,40 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => // Pre-fill staff if editing a STAFF resource if (editingResource.type === 'STAFF' && editingResource.userId) { setSelectedStaffId(editingResource.userId); - // Find the staff member to set the initial search query (display name) - const staff = staffMembers.find(s => s.id === editingResource.userId); - setStaffSearchQuery(staff ? staff.name : ''); + // We'll set the staff name in a separate effect } else { setSelectedStaffId(null); setStaffSearchQuery(''); } - } else { + } else if (isModalOpen) { + // Only reset when creating new (modal opened without editing resource) setFormType('STAFF'); setFormName(''); setFormDescription(''); setFormMaxConcurrent(1); setFormMultilaneEnabled(false); setFormSavedLaneCount(undefined); - setSelectedStaffId(null); // Clear selected staff when creating new + setSelectedStaffId(null); setStaffSearchQuery(''); + setDebouncedSearchQuery(''); } - }, [editingResource, staffMembers]); + }, [editingResource, isModalOpen]); + + // Separate effect to populate staff name when editing + // This runs when staffMembers loads and we have a selected staff ID + useEffect(() => { + if (editingResource && editingResource.type === 'STAFF' && editingResource.userId && selectedStaffId === editingResource.userId) { + const staff = staffMembers.find(s => s.id === editingResource.userId); + if (staff && !staffSearchQuery) { + // Only set if not already set to avoid overwriting user input + setStaffSearchQuery(staff.name); + } + } + }, [staffMembers, editingResource, selectedStaffId, staffSearchQuery]); const openCreateModal = () => { - setEditingResource(null); setIsModalOpen(true); + setEditingResource(null); }; const openEditModal = (resource: Resource) => { @@ -138,8 +218,8 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => }; const closeModal = () => { - setIsModalOpen(false); setEditingResource(null); + setIsModalOpen(false); }; const handleMultilaneToggle = (enabled: boolean) => { @@ -251,8 +331,7 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => return ( openEditModal(resource)} + className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group" >
@@ -297,7 +376,7 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => -
e.stopPropagation()}> +
+
@@ -324,7 +410,7 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => {isModalOpen && (
-
+

{editingResource ? t('resources.editResource') : t('resources.addNewResource')} @@ -338,7 +424,7 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => {/* Resource Type */}
setFormData({ ...formData, name: e.target.value })} - required - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500" - placeholder={t('services.namePlaceholder', 'e.g., Haircut, Massage, Consultation')} - /> -
- -
+
+
setFormData({ ...formData, durationMinutes: parseInt(e.target.value) || 0 })} + type="text" + value={formData.name} + onChange={(e) => setFormData({ ...formData, name: e.target.value })} required - min={5} - step={5} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500" + placeholder={t('services.namePlaceholder', 'e.g., Haircut, Massage, Consultation')} />
+ +
+
+ + setFormData({ ...formData, durationMinutes: parseInt(e.target.value) || 0 })} + required + min={5} + step={5} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500" + /> +
+
+ + setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })} + required + min={0} + step={0.01} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500" + /> +
+
+
- setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })} - required - min={0} - step={0.01} - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500" +