feat: Add photo galleries to services, resource types management, and UI improvements

Major features:
- Add drag-and-drop photo gallery to Service create/edit modals
- Add Resource Types management section to Settings (CRUD for custom types)
- Add edit icon consistency to Resources table (pencil icon in actions)
- Improve Services page with drag-to-reorder and customer preview mockup

Backend changes:
- Add photos JSONField to Service model with migration
- Add ResourceType model with category (STAFF/OTHER), description fields
- Add ResourceTypeViewSet with CRUD operations
- Add service reorder endpoint for display order

Frontend changes:
- Services page: two-column layout, drag-reorder, photo upload
- Settings page: Resource Types tab with full CRUD modal
- Resources page: Edit icon in actions column instead of row click
- Sidebar: Payments link visibility based on role and paymentsEnabled
- Update types.ts with Service.photos and ResourceTypeDefinition

Note: Removed photos from ResourceType (kept only for Service)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-11-28 01:11:53 -05:00
parent a7c756a8ec
commit b10426fbdb
52 changed files with 4259 additions and 356 deletions

102
CLAUDE.md Normal file
View File

@@ -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 <command>
```
## 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`

354
IMPLEMENTATION_SUMMARY.md Normal file
View File

@@ -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' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold">Resource Types</h3>
<p className="text-gray-600">Customize how you categorize your bookable resources.</p>
</div>
<ResourceTypesManager />
</div>
)}
```
#### B. Logo Upload in Branding Section
```tsx
<div className="space-y-4">
<h4 className="font-semibold">Business Logo</h4>
{/* Preview */}
{business.logoUrl ? (
<div className="relative inline-block">
<img src={business.logoUrl} alt="Logo" className="w-32 h-32 object-contain border rounded" />
<button
onClick={handleRemoveLogo}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1"
>
<X size={16} />
</button>
</div>
) : (
<div className="w-32 h-32 border-2 border-dashed rounded flex items-center justify-center text-gray-400">
<Image size={32} />
</div>
)}
{/* Upload */}
<input
type="file"
id="logo-upload"
className="hidden"
accept="image/*"
onChange={handleLogoUpload}
/>
<label htmlFor="logo-upload" className="btn-secondary">
<Upload size={16} />
Upload Logo
</label>
{/* Display Mode */}
<div>
<label className="block font-medium mb-2">Logo Display Mode</label>
<select
value={business.logoDisplayMode}
onChange={(e) => handleUpdateBusiness({ logoDisplayMode: e.target.value })}
className="form-select"
>
<option value="text-only">Text Only</option>
<option value="logo-only">Logo Only</option>
<option value="logo-and-text">Logo and Text</option>
</select>
</div>
{/* Preview with menu background */}
<div>
<label className="block font-medium mb-2">Preview in Sidebar</label>
<div
className="w-64 p-4 rounded-lg"
style={{ backgroundColor: business.primaryColor }}
>
<div className="flex items-center gap-3">
{business.logoUrl && (business.logoDisplayMode === 'logo-only' || business.logoDisplayMode === 'logo-and-text') ? (
<img src={business.logoUrl} alt="Logo" className="w-10 h-10 object-contain" />
) : (
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center text-brand-600 font-bold">
{business.name.substring(0, 2).toUpperCase()}
</div>
)}
{business.logoDisplayMode !== 'logo-only' && (
<div>
<h1 className="font-bold text-white truncate">{business.name}</h1>
<p className="text-xs text-white/60 truncate">{business.subdomain}.smoothschedule.com</p>
</div>
)}
</div>
</div>
</div>
</div>
```
### 5. Update Resources Page
Modify `frontend/src/pages/Resources.tsx` to use custom resource types:
```tsx
const { data: resourceTypes = [] } = useResourceTypes();
// In the modal:
<select value={selectedTypeId} onChange={handleTypeChange}>
{resourceTypes.map(type => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
{/* Show staff selector if selected type is STAFF category */}
{selectedType?.category === 'STAFF' && (
<StaffAutocomplete />
)}
```
## 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

281
RESOURCE_TYPES_PLAN.md Normal file
View File

@@ -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
<Tab name="Resource Types">
<ResourceTypesManager />
</Tab>
```
### ResourceTypesManager Component
```tsx
interface ResourceTypesManagerProps {}
const ResourceTypesManager: React.FC = () => {
const { data: types = [] } = useResourceTypes();
const createMutation = useCreateResourceType();
const updateMutation = useUpdateResourceType();
const deleteMutation = useDeleteResourceType();
return (
<div>
<h3>Resource Types</h3>
<p>Customize how you categorize your bookable resources.</p>
{/* List of types */}
{types.map(type => (
<TypeCard
key={type.id}
type={type}
onEdit={handleEdit}
onDelete={handleDelete}
canDelete={!type.isDefault && !type.inUse}
/>
))}
{/* Add new type button */}
<button onClick={openCreateModal}>
+ Add Resource Type
</button>
{/* Create/Edit Modal */}
<ResourceTypeModal
type={editingType}
onSave={handleSave}
onClose={closeModal}
/>
</div>
);
};
```
### 2. Logo Upload in Branding Section
```tsx
// In Settings.tsx, Branding section
<div className="space-y-4">
<h4>Business Logo</h4>
{/* Logo preview */}
{business.logoUrl && (
<div className="relative w-32 h-32">
<img src={business.logoUrl} alt="Business logo" />
<button onClick={removeLogo}>
<X size={16} />
</button>
</div>
)}
{/* Upload button */}
<input
type="file"
accept="image/png,image/jpeg,image/svg+xml"
onChange={handleLogoUpload}
className="hidden"
id="logo-upload"
/>
<label htmlFor="logo-upload">
<Upload size={16} />
Upload Logo
</label>
</div>
```
### 3. Update Resources.tsx to use custom types
```tsx
// Resources.tsx
const Resources: React.FC = () => {
const { data: resourceTypes = [] } = useResourceTypes();
return (
<ResourceModal>
<select name="resourceType">
{resourceTypes.map(type => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
{/* Show staff selector if selected type is STAFF category */}
{selectedType?.category === 'STAFF' && (
<StaffSelector required />
)}
</ResourceModal>
);
};
```
## 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)

View File

@@ -1,5 +1,36 @@
# SmoothSchedule Frontend Development Guide # 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 ## Local Development Domain Setup
### Why lvh.me instead of localhost? ### Why lvh.me instead of localhost?

View File

@@ -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"
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

File diff suppressed because one or more lines are too long

View File

@@ -194,29 +194,29 @@ const AppContent: React.FC = () => {
return hostname === 'lvh.me' || hostname === 'localhost' || hostname === '127.0.0.1'; return hostname === 'lvh.me' || hostname === 'localhost' || hostname === '127.0.0.1';
}; };
// Not authenticated - show public routes // On root domain, ALWAYS show marketing site (even if logged in)
if (!user) { // Logged-in users will see a "Go to Dashboard" link in the navbar
// On root domain, show marketing site if (isRootDomain()) {
if (isRootDomain()) { return (
return ( <Routes>
<Routes> <Route element={<MarketingLayout user={user} />}>
<Route element={<MarketingLayout />}> <Route path="/" element={<HomePage />} />
<Route path="/" element={<HomePage />} /> <Route path="/features" element={<FeaturesPage />} />
<Route path="/features" element={<FeaturesPage />} /> <Route path="/pricing" element={<PricingPage />} />
<Route path="/pricing" element={<PricingPage />} /> <Route path="/about" element={<AboutPage />} />
<Route path="/about" element={<AboutPage />} /> <Route path="/contact" element={<ContactPage />} />
<Route path="/contact" element={<ContactPage />} /> <Route path="/signup" element={<SignupPage />} />
<Route path="/signup" element={<SignupPage />} /> </Route>
</Route> <Route path="/login" element={<LoginPage />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} /> <Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/verify-email" element={<VerifyEmail />} /> <Route path="*" element={<Navigate to="/" replace />} />
<Route path="*" element={<Navigate to="/" replace />} /> </Routes>
</Routes> );
); }
}
// On business subdomain, show login // Not authenticated on subdomain - show login
if (!user) {
return ( return (
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
@@ -232,6 +232,43 @@ const AppContent: React.FC = () => {
return <ErrorScreen error={userError as Error} />; return <ErrorScreen error={userError as Error} />;
} }
// 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 <LoadingScreen />;
}
// 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 <LoadingScreen />;
}
// 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 <LoadingScreen />;
}
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 <LoadingScreen />;
}
// Handlers // Handlers
const toggleTheme = () => setDarkMode((prev) => !prev); const toggleTheme = () => setDarkMode((prev) => !prev);
const handleSignOut = () => { const handleSignOut = () => {
@@ -242,22 +279,20 @@ const AppContent: React.FC = () => {
}; };
const handleMasquerade = (targetUser: any) => { const handleMasquerade = (targetUser: any) => {
// Call the masquerade API with the target user's username // Call the masquerade API with the target user's id
// Fallback to email prefix if username is not available const userId = targetUser.id;
const username = targetUser.username || targetUser.email?.split('@')[0]; if (!userId) {
if (!username) { console.error('Cannot masquerade: no user id available', targetUser);
console.error('Cannot masquerade: no username or email available', targetUser);
return; 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 // Helper to check access based on roles
const hasAccess = (allowedRoles: string[]) => allowedRoles.includes(user.role); 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) { if (isPlatformUser) {
return ( return (
<Routes> <Routes>
@@ -329,54 +364,16 @@ const AppContent: React.FC = () => {
return <LoadingScreen />; return <LoadingScreen />;
} }
// 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 // Business error or no business found
if (businessError || !business) { if (businessError || !business) {
// If user is a business owner on root domain, redirect to their business // If user has a business subdomain, redirect them there
if (isRootOrPlatform && user.role === 'owner' && user.business_subdomain) { if (user.business_subdomain) {
const port = window.location.port ? `:${window.location.port}` : ''; const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`; window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
return <LoadingScreen />; return <LoadingScreen />;
} }
// If on root/platform and shouldn't be here, show appropriate message // No business subdomain - show error
if (isRootOrPlatform) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center max-w-md p-6">
<h2 className="text-2xl font-bold text-amber-600 dark:text-amber-400 mb-4">Wrong Location</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{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.'}
</p>
<div className="flex gap-4 justify-center">
{user.business_subdomain && (
<button
onClick={() => {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
}}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Go to Business
</button>
)}
<button
onClick={handleSignOut}
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
Sign Out
</button>
</div>
</div>
</div>
);
}
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900"> <div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center max-w-md p-6"> <div className="text-center max-w-md p-6">

View File

@@ -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 ( export const masquerade = async (
username: string, user_pk: number,
masquerade_stack?: MasqueradeStackEntry[] hijack_history?: MasqueradeStackEntry[]
): Promise<LoginResponse> => { ): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>( const response = await apiClient.post<LoginResponse>(
`/api/users/${username}/masquerade/`, '/api/auth/hijack/acquire/',
{ masquerade_stack } { user_pk, hijack_history }
); );
return response.data; return response.data;
}; };
@@ -106,7 +106,7 @@ export const stopMasquerade = async (
masquerade_stack: MasqueradeStackEntry[] masquerade_stack: MasqueradeStackEntry[]
): Promise<LoginResponse> => { ): Promise<LoginResponse> => {
const response = await apiClient.post<LoginResponse>( const response = await apiClient.post<LoginResponse>(
'/api/users/stop_masquerade/', '/api/auth/hijack/release/',
{ masquerade_stack } { masquerade_stack }
); );
return response.data; return response.data;

View File

@@ -5,14 +5,23 @@
import apiClient from './client'; import apiClient from './client';
export interface PlatformBusinessOwner {
id: number;
username: string;
full_name: string;
email: string;
role: string;
}
export interface PlatformBusiness { export interface PlatformBusiness {
id: number; id: number;
name: string; name: string;
subdomain: string; subdomain: string;
tier: string; tier: string;
is_active: boolean; is_active: boolean;
created_at: string; created_on: string;
user_count: number; user_count: number;
owner: PlatformBusinessOwner | null;
} }
export interface PlatformUser { export interface PlatformUser {

View File

@@ -34,7 +34,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
? location.pathname === path ? location.pathname === path
: location.pathname.startsWith(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 collapsedClasses = isCollapsed ? 'px-3 justify-center' : 'px-4';
const activeClasses = 'bg-white/10 text-white'; const activeClasses = 'bg-white/10 text-white';
const inactiveClasses = 'text-white/70 hover:text-white hover:bg-white/5'; const inactiveClasses = 'text-white/70 hover:text-white hover:bg-white/5';
@@ -70,14 +70,40 @@ const Sidebar: React.FC<SidebarProps> = ({ 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`} 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"} aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
> >
<div className="flex items-center justify-center w-10 h-10 bg-white rounded-lg text-brand-600 font-bold text-xl shrink-0" style={{ color: business.primaryColor }}> {/* Logo-only mode: full width */}
{business.name.substring(0, 2).toUpperCase()} {business.logoDisplayMode === 'logo-only' && business.logoUrl ? (
</div> <div className="flex items-center justify-center w-full">
{!isCollapsed && ( <img
<div className="overflow-hidden"> src={business.logoUrl}
<h1 className="font-bold leading-tight truncate">{business.name}</h1> alt={business.name}
<p className="text-xs text-white/60 truncate">{business.subdomain}.smoothschedule.com</p> className="max-w-full max-h-16 object-contain"
/>
</div> </div>
) : (
<>
{/* Logo/Icon display */}
{business.logoUrl && business.logoDisplayMode !== 'text-only' ? (
<div className="flex items-center justify-center w-10 h-10 shrink-0">
<img
src={business.logoUrl}
alt={business.name}
className="w-full h-full object-contain"
/>
</div>
) : business.logoDisplayMode !== 'logo-only' && (
<div className="flex items-center justify-center w-10 h-10 bg-white rounded-lg text-brand-600 font-bold text-xl shrink-0" style={{ color: business.primaryColor }}>
{business.name.substring(0, 2).toUpperCase()}
</div>
)}
{/* Text display */}
{!isCollapsed && business.logoDisplayMode !== 'logo-only' && (
<div className="overflow-hidden">
<h1 className="font-bold leading-tight truncate">{business.name}</h1>
<p className="text-xs text-white/60 truncate">{business.subdomain}.smoothschedule.com</p>
</div>
)}
</>
)} )}
</button> </button>
@@ -111,19 +137,22 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
{canViewAdminPages && ( {canViewAdminPages && (
<> <>
{business.paymentsEnabled ? ( {/* Payments link: always visible for owners, only visible for others if enabled */}
<Link to="/payments" className={getNavClass('/payments')} title={t('nav.payments')}> {(role === 'owner' || business.paymentsEnabled) && (
<CreditCard size={20} className="shrink-0" /> business.paymentsEnabled ? (
{!isCollapsed && <span>{t('nav.payments')}</span>} <Link to="/payments" className={getNavClass('/payments')} title={t('nav.payments')}>
</Link> <CreditCard size={20} className="shrink-0" />
) : ( {!isCollapsed && <span>{t('nav.payments')}</span>}
<div </Link>
className={getNavClass('/payments', false, true)} ) : (
title={t('nav.paymentsDisabledTooltip')} <div
> className={getNavClass('/payments', false, true)}
<CreditCard size={20} className="shrink-0" /> title={t('nav.paymentsDisabledTooltip')}
{!isCollapsed && <span>{t('nav.payments')}</span>} >
</div> <CreditCard size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.payments')}</span>}
</div>
)
)} )}
<Link to="/messages" className={getNavClass('/messages')} title={t('nav.messages')}> <Link to="/messages" className={getNavClass('/messages')} title={t('nav.messages')}>
<MessageSquare size={20} className="shrink-0" /> <MessageSquare size={20} className="shrink-0" />
@@ -149,7 +178,12 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
</nav> </nav>
<div className="p-4 border-t border-white/10"> <div className="p-4 border-t border-white/10">
<div className={`flex items-center gap-2 text-xs text-white/60 mb-4 ${isCollapsed ? 'justify-center' : ''}`}> <a
href={`${window.location.protocol}//${window.location.host.split('.').slice(-2).join('.')}`}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center gap-2 text-xs text-white/60 mb-4 hover:text-white/80 transition-colors ${isCollapsed ? 'justify-center' : ''}`}
>
<SmoothScheduleLogo className="w-6 h-6 text-white" /> <SmoothScheduleLogo className="w-6 h-6 text-white" />
{!isCollapsed && ( {!isCollapsed && (
<div> <div>
@@ -157,7 +191,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
<span className="font-semibold text-white/80">Smooth Schedule</span> <span className="font-semibold text-white/80">Smooth Schedule</span>
</div> </div>
)} )}
</div> </a>
<button <button
onClick={handleSignOut} onClick={handleSignOut}
disabled={logoutMutation.isPending} disabled={logoutMutation.isPending}

View File

@@ -4,13 +4,15 @@ import { useTranslation } from 'react-i18next';
import { Menu, X, Sun, Moon } from 'lucide-react'; import { Menu, X, Sun, Moon } from 'lucide-react';
import SmoothScheduleLogo from '../SmoothScheduleLogo'; import SmoothScheduleLogo from '../SmoothScheduleLogo';
import LanguageSelector from '../LanguageSelector'; import LanguageSelector from '../LanguageSelector';
import { User } from '../../api/auth';
interface NavbarProps { interface NavbarProps {
darkMode: boolean; darkMode: boolean;
toggleTheme: () => void; toggleTheme: () => void;
user?: User | null;
} }
const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme }) => { const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme, user }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -38,6 +40,21 @@ const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme }) => {
const isActive = (path: string) => location.pathname === path; const isActive = (path: string) => location.pathname === path;
// Get the dashboard URL based on user role
const getDashboardUrl = (): string => {
if (!user) return '/login';
const port = window.location.port ? `:${window.location.port}` : '';
const protocol = window.location.protocol;
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
return `${protocol}//platform.lvh.me${port}/`;
}
if (user.business_subdomain) {
return `${protocol}//${user.business_subdomain}.lvh.me${port}/`;
}
return '/login';
};
return ( return (
<nav <nav
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${ className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
@@ -90,12 +107,21 @@ const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme }) => {
</button> </button>
{/* Login Button - Hidden on mobile */} {/* Login Button - Hidden on mobile */}
<Link {user ? (
to="/login" <a
className="hidden md:inline-flex px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-brand-600 dark:hover:text-brand-400 transition-colors" href={getDashboardUrl()}
> className="hidden md:inline-flex px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
{t('marketing.nav.login')} >
</Link> {t('marketing.nav.login')}
</a>
) : (
<Link
to="/login"
className="hidden md:inline-flex px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
>
{t('marketing.nav.login')}
</Link>
)}
{/* Get Started CTA */} {/* Get Started CTA */}
<Link <Link
@@ -139,12 +165,21 @@ const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme }) => {
</Link> </Link>
))} ))}
<hr className="my-2 border-gray-200 dark:border-gray-800" /> <hr className="my-2 border-gray-200 dark:border-gray-800" />
<Link {user ? (
to="/login" <a
className="px-4 py-3 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors" href={getDashboardUrl()}
> className="px-4 py-3 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
{t('marketing.nav.login')} >
</Link> {t('marketing.nav.login')}
</a>
) : (
<Link
to="/login"
className="px-4 py-3 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
{t('marketing.nav.login')}
</Link>
)}
<Link <Link
to="/signup" to="/signup"
className="px-4 py-3 rounded-lg text-sm font-medium text-center text-white bg-brand-600 hover:bg-brand-700 transition-colors" className="px-4 py-3 rounded-lg text-sm font-medium text-center text-white bg-brand-600 hover:bg-brand-700 transition-colors"

View File

@@ -101,13 +101,13 @@ export const useMasquerade = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (username: string) => { mutationFn: async (user_pk: number) => {
// Get current masquerading stack from localStorage // Get current masquerading stack from localStorage
const stackJson = localStorage.getItem('masquerade_stack'); const stackJson = localStorage.getItem('masquerade_stack');
const currentStack: MasqueradeStackEntry[] = stackJson ? JSON.parse(stackJson) : []; const currentStack: MasqueradeStackEntry[] = stackJson ? JSON.parse(stackJson) : [];
// Call masquerade API with current stack // Call masquerade API with current stack
return masquerade(username, currentStack); return masquerade(user_pk, currentStack);
}, },
onSuccess: async (data) => { onSuccess: async (data) => {
// Store the updated masquerading stack // Store the updated masquerading stack

View File

@@ -33,6 +33,8 @@ export const useCurrentBusiness = () => {
primaryColor: data.primary_color || '#3B82F6', // Blue-500 default primaryColor: data.primary_color || '#3B82F6', // Blue-500 default
secondaryColor: data.secondary_color || '#1E40AF', // Blue-800 default secondaryColor: data.secondary_color || '#1E40AF', // Blue-800 default
logoUrl: data.logo_url, logoUrl: data.logo_url,
emailLogoUrl: data.email_logo_url,
logoDisplayMode: data.logo_display_mode || 'text-only',
whitelabelEnabled: data.whitelabel_enabled, whitelabelEnabled: data.whitelabel_enabled,
plan: data.tier, // Map tier to plan plan: data.tier, // Map tier to plan
status: data.status, status: data.status,
@@ -64,6 +66,8 @@ export const useUpdateBusiness = () => {
if (updates.primaryColor) backendData.primary_color = updates.primaryColor; if (updates.primaryColor) backendData.primary_color = updates.primaryColor;
if (updates.secondaryColor) backendData.secondary_color = updates.secondaryColor; if (updates.secondaryColor) backendData.secondary_color = updates.secondaryColor;
if (updates.logoUrl !== undefined) backendData.logo_url = updates.logoUrl; 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) { if (updates.whitelabelEnabled !== undefined) {
backendData.whitelabel_enabled = updates.whitelabelEnabled; backendData.whitelabel_enabled = updates.whitelabelEnabled;
} }
@@ -136,7 +140,7 @@ export const useBusinessUsers = () => {
return useQuery({ return useQuery({
queryKey: ['businessUsers'], queryKey: ['businessUsers'],
queryFn: async () => { queryFn: async () => {
const { data } = await apiClient.get('/api/business/users/'); const { data } = await apiClient.get('/api/staff/');
return data; return data;
}, },
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 5 * 60 * 1000, // 5 minutes

View File

@@ -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<ResourceTypeDefinition[]>({
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<ResourceTypeDefinition, 'id' | 'isDefault'>) => {
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<ResourceTypeDefinition> }) => {
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'] });
},
});
};

View File

@@ -28,6 +28,8 @@ export const useResources = (filters?: ResourceFilters) => {
name: r.name, name: r.name,
type: r.type as ResourceType, type: r.type as ResourceType,
userId: r.user_id ? String(r.user_id) : undefined, 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, name: data.name,
type: data.type as ResourceType, type: data.type as ResourceType,
userId: data.user_id ? String(data.user_id) : undefined, userId: data.user_id ? String(data.user_id) : undefined,
maxConcurrentEvents: data.max_concurrent_events ?? 1,
savedLaneCount: data.saved_lane_count,
}; };
}, },
enabled: !!id, enabled: !!id,
@@ -91,6 +95,12 @@ export const useUpdateResource = () => {
if (updates.userId !== undefined) { if (updates.userId !== undefined) {
backendData.user = updates.userId ? parseInt(updates.userId) : null; 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); const { data } = await apiClient.patch(`/api/resources/${id}/`, backendData);
return data; return data;

View File

@@ -22,6 +22,8 @@ export const useServices = () => {
durationMinutes: s.duration || s.duration_minutes, durationMinutes: s.duration || s.duration_minutes,
price: parseFloat(s.price), price: parseFloat(s.price),
description: s.description || '', description: s.description || '',
displayOrder: s.display_order ?? 0,
photos: s.photos || [],
})); }));
}, },
retry: false, // Don't retry on 404 - endpoint may not exist yet 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, durationMinutes: data.duration || data.duration_minutes,
price: parseFloat(data.price), price: parseFloat(data.price),
description: data.description || '', description: data.description || '',
displayOrder: data.display_order ?? 0,
photos: data.photos || [],
}; };
}, },
enabled: !!id, enabled: !!id,
@@ -63,6 +67,7 @@ export const useCreateService = () => {
duration: serviceData.durationMinutes, duration: serviceData.durationMinutes,
price: serviceData.price.toString(), price: serviceData.price.toString(),
description: serviceData.description, description: serviceData.description,
photos: serviceData.photos || [],
}; };
const { data } = await apiClient.post('/api/services/', backendData); const { data } = await apiClient.post('/api/services/', backendData);
@@ -87,6 +92,7 @@ export const useUpdateService = () => {
if (updates.durationMinutes) backendData.duration = updates.durationMinutes; if (updates.durationMinutes) backendData.duration = updates.durationMinutes;
if (updates.price) backendData.price = updates.price.toString(); if (updates.price) backendData.price = updates.price.toString();
if (updates.description !== undefined) backendData.description = updates.description; 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); const { data } = await apiClient.patch(`/api/services/${id}/`, backendData);
return data; 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'] });
},
});
};

View File

@@ -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<StaffMember[]>({
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,
});
};

View File

@@ -3,8 +3,13 @@ import { Outlet } from 'react-router-dom';
import Navbar from '../components/marketing/Navbar'; import Navbar from '../components/marketing/Navbar';
import Footer from '../components/marketing/Footer'; import Footer from '../components/marketing/Footer';
import { useScrollToTop } from '../hooks/useScrollToTop'; import { useScrollToTop } from '../hooks/useScrollToTop';
import { User } from '../api/auth';
const MarketingLayout: React.FC = () => { interface MarketingLayoutProps {
user?: User | null;
}
const MarketingLayout: React.FC<MarketingLayoutProps> = ({ user }) => {
useScrollToTop(); useScrollToTop();
const [darkMode, setDarkMode] = useState(() => { const [darkMode, setDarkMode] = useState(() => {
@@ -28,7 +33,7 @@ const MarketingLayout: React.FC = () => {
return ( return (
<div className="min-h-screen flex flex-col bg-white dark:bg-gray-900 transition-colors duration-200"> <div className="min-h-screen flex flex-col bg-white dark:bg-gray-900 transition-colors duration-200">
<Navbar darkMode={darkMode} toggleTheme={toggleTheme} /> <Navbar darkMode={darkMode} toggleTheme={toggleTheme} user={user} />
{/* Main Content - with padding for fixed navbar */} {/* Main Content - with padding for fixed navbar */}
<main className="flex-1 pt-16 lg:pt-20"> <main className="flex-1 pt-16 lg:pt-20">

View File

@@ -99,7 +99,8 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
return sorted; return sorted;
}, [customers, searchTerm, sortConfig]); }, [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) { if (isLoading) {
return ( return (

View File

@@ -33,40 +33,68 @@ const LoginPage: React.FC = () => {
const user = data.user; const user = data.user;
const currentHostname = window.location.hostname; const currentHostname = window.location.hostname;
const currentPort = window.location.port; 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 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 // Platform users (superuser, platform_manager, platform_support)
const rootAllowedRoles = ['superuser', 'platform_manager', 'platform_support', 'owner']; const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
// If on root domain, only allow specific roles // Business-associated users (owner, manager, staff, resource)
if (isRootDomain && !rootAllowedRoles.includes(user.role)) { const isBusinessUser = ['owner', 'manager', 'staff', 'resource'].includes(user.role);
setError(t('auth.loginAtSubdomain'));
// Customer users
const isCustomer = user.role === 'customer';
// RULE 1: Platform users cannot login on business subdomains
if (isPlatformUser && isBusinessSubdomain) {
setError(t('auth.invalidCredentials'));
return; 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; let targetSubdomain: string | null = null;
// Platform users (superuser, platform_manager, platform_support) if (isPlatformUser) {
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
targetSubdomain = 'platform'; targetSubdomain = 'platform';
} } else if (user.business_subdomain) {
// Business users - redirect to their business subdomain
else if (user.business_subdomain) {
targetSubdomain = user.business_subdomain; targetSubdomain = user.business_subdomain;
} }
// Check if we need to redirect to a different 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 isOnTargetSubdomain = currentHostname === `${targetSubdomain}.lvh.me`;
const needsRedirect = targetSubdomain && !isOnTargetSubdomain; const needsRedirect = targetSubdomain && !isOnTargetSubdomain;
if (needsRedirect) { if (needsRedirect) {
// Pass tokens in URL to ensure they're available immediately on the new subdomain // 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}`; window.location.href = `http://${targetSubdomain}.lvh.me${portStr}/?access_token=${data.access}&refresh_token=${data.refresh}`;
return; return;
} }

View File

@@ -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 { useTranslation } from 'react-i18next';
import { ResourceType, User, Resource } from '../types'; import { ResourceType, User, Resource } from '../types';
import { useResources, useCreateResource, useUpdateResource } from '../hooks/useResources'; import { useResources, useCreateResource, useUpdateResource } from '../hooks/useResources';
@@ -15,7 +15,8 @@ import {
Eye, Eye,
Calendar, Calendar,
Settings, Settings,
X X,
Pencil
} from 'lucide-react'; } from 'lucide-react';
const ResourceIcon: React.FC<{ type: ResourceType }> = ({ type }) => { const ResourceIcon: React.FC<{ type: ResourceType }> = ({ type }) => {
@@ -55,17 +56,29 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
// Staff selection state // Staff selection state
const [selectedStaffId, setSelectedStaffId] = useState<string | null>(null); const [selectedStaffId, setSelectedStaffId] = useState<string | null>(null);
const [staffSearchQuery, setStaffSearchQuery] = useState(''); const [staffSearchQuery, setStaffSearchQuery] = useState('');
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
const [showStaffDropdown, setShowStaffDropdown] = useState(false); const [showStaffDropdown, setShowStaffDropdown] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const staffInputRef = useRef<HTMLInputElement>(null); const staffInputRef = useRef<HTMLInputElement>(null);
const staffDropdownRef = useRef<HTMLDivElement>(null); const staffDropdownRef = useRef<HTMLDivElement>(null);
// Fetch staff members for autocomplete // Debounce search query for API calls
const { data: staffMembers = [] } = useStaff({ search: staffSearchQuery }); 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) // Filter staff members based on search query (client-side filtering for immediate feedback)
const filteredStaff = useMemo(() => { const filteredStaff = useMemo(() => {
if (!staffSearchQuery) return staffMembers; // Always show all staff when dropdown is open and no search query
const query = staffSearchQuery.toLowerCase(); if (!staffSearchQuery.trim()) return staffMembers;
const query = staffSearchQuery.toLowerCase().trim();
return staffMembers.filter( return staffMembers.filter(
(s) => s.name.toLowerCase().includes(query) || s.email.toLowerCase().includes(query) (s) => s.name.toLowerCase().includes(query) || s.email.toLowerCase().includes(query)
); );
@@ -76,6 +89,59 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
return staffMembers.find((s) => s.id === selectedStaffId) || null; return staffMembers.find((s) => s.id === selectedStaffId) || null;
}, [staffMembers, selectedStaffId]); }, [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<HTMLInputElement>) => {
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 createResourceMutation = useCreateResource();
const updateResourceMutation = useUpdateResource(); const updateResourceMutation = useUpdateResource();
@@ -97,6 +163,8 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
}, [allAppointments]); }, [allAppointments]);
// Reset form when modal opens/closes or editing resource changes // 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(() => { useEffect(() => {
if (editingResource) { if (editingResource) {
setFormType(editingResource.type); setFormType(editingResource.type);
@@ -108,28 +176,40 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
// Pre-fill staff if editing a STAFF resource // Pre-fill staff if editing a STAFF resource
if (editingResource.type === 'STAFF' && editingResource.userId) { if (editingResource.type === 'STAFF' && editingResource.userId) {
setSelectedStaffId(editingResource.userId); setSelectedStaffId(editingResource.userId);
// Find the staff member to set the initial search query (display name) // We'll set the staff name in a separate effect
const staff = staffMembers.find(s => s.id === editingResource.userId);
setStaffSearchQuery(staff ? staff.name : '');
} else { } else {
setSelectedStaffId(null); setSelectedStaffId(null);
setStaffSearchQuery(''); setStaffSearchQuery('');
} }
} else { } else if (isModalOpen) {
// Only reset when creating new (modal opened without editing resource)
setFormType('STAFF'); setFormType('STAFF');
setFormName(''); setFormName('');
setFormDescription(''); setFormDescription('');
setFormMaxConcurrent(1); setFormMaxConcurrent(1);
setFormMultilaneEnabled(false); setFormMultilaneEnabled(false);
setFormSavedLaneCount(undefined); setFormSavedLaneCount(undefined);
setSelectedStaffId(null); // Clear selected staff when creating new setSelectedStaffId(null);
setStaffSearchQuery(''); 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 = () => { const openCreateModal = () => {
setEditingResource(null);
setIsModalOpen(true); setIsModalOpen(true);
setEditingResource(null);
}; };
const openEditModal = (resource: Resource) => { const openEditModal = (resource: Resource) => {
@@ -138,8 +218,8 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
}; };
const closeModal = () => { const closeModal = () => {
setIsModalOpen(false);
setEditingResource(null); setEditingResource(null);
setIsModalOpen(false);
}; };
const handleMultilaneToggle = (enabled: boolean) => { const handleMultilaneToggle = (enabled: boolean) => {
@@ -251,8 +331,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
return ( return (
<tr <tr
key={resource.id} key={resource.id}
className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group cursor-pointer" className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group"
onClick={() => openEditModal(resource)}
> >
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -297,7 +376,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
</span> </span>
</td> </td>
<td className="px-6 py-4 text-right"> <td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2" onClick={(e) => e.stopPropagation()}> <div className="flex items-center justify-end gap-2">
<button <button
onClick={() => setCalendarResource({ id: resource.id, name: resource.name })} onClick={() => setCalendarResource({ id: resource.id, name: resource.name })}
className="text-brand-600 hover:text-brand-500 dark:text-brand-400 dark:hover:text-brand-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-brand-200 dark:border-brand-800 rounded-lg hover:bg-brand-50 dark:hover:bg-brand-900/30 transition-colors" className="text-brand-600 hover:text-brand-500 dark:text-brand-400 dark:hover:text-brand-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-brand-200 dark:border-brand-800 rounded-lg hover:bg-brand-50 dark:hover:bg-brand-900/30 transition-colors"
@@ -305,6 +384,13 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
> >
<Eye size={14} /> {t('resources.viewCalendar')} <Eye size={14} /> {t('resources.viewCalendar')}
</button> </button>
<button
onClick={() => openEditModal(resource)}
className="p-1.5 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
title={t('common.edit', 'Edit')}
>
<Pencil size={16} />
</button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -324,7 +410,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
{isModalOpen && ( {isModalOpen && (
<Portal> <Portal>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md overflow-hidden"> <div key={editingResource?.id || 'new'} className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center"> <div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"> <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{editingResource ? t('resources.editResource') : t('resources.addNewResource')} {editingResource ? t('resources.editResource') : t('resources.addNewResource')}
@@ -338,7 +424,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
{/* Resource Type */} {/* Resource Type */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('resources.resourceType')} {t('resources.resourceType')} <span className="text-red-500">*</span>
</label> </label>
<select <select
value={formType} value={formType}
@@ -346,9 +432,12 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
setFormType(e.target.value as ResourceType); setFormType(e.target.value as ResourceType);
setSelectedStaffId(null); // Clear staff selection if type changes setSelectedStaffId(null); // Clear staff selection if type changes
setStaffSearchQuery(''); setStaffSearchQuery('');
setDebouncedSearchQuery('');
setShowStaffDropdown(false);
setHighlightedIndex(-1);
}} }}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 focus:bg-white dark:focus:bg-gray-600" className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
disabled={!!editingResource} required
> >
<option value="STAFF">{t('resources.staffMember')}</option> <option value="STAFF">{t('resources.staffMember')}</option>
<option value="ROOM">{t('resources.room')}</option> <option value="ROOM">{t('resources.room')}</option>
@@ -370,47 +459,80 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
onChange={(e) => { onChange={(e) => {
setStaffSearchQuery(e.target.value); setStaffSearchQuery(e.target.value);
setShowStaffDropdown(true); setShowStaffDropdown(true);
setHighlightedIndex(-1);
// Clear selection when user types
if (selectedStaffId) {
setSelectedStaffId(null);
}
}}
onKeyDown={handleKeyDown}
onFocus={() => {
setShowStaffDropdown(true);
setHighlightedIndex(-1);
}}
onBlur={() => {
// Delay to allow click on dropdown
setTimeout(() => {
setShowStaffDropdown(false);
setHighlightedIndex(-1);
}, 200);
}} }}
onFocus={() => setShowStaffDropdown(true)}
onBlur={() => setTimeout(() => setShowStaffDropdown(false), 150)} // Delay to allow click on dropdown
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 focus:bg-white dark:focus:bg-gray-600" className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 focus:bg-white dark:focus:bg-gray-600"
placeholder={t('resources.searchStaffPlaceholder')} placeholder={t('resources.searchStaffPlaceholder')}
required={formType === 'STAFF'} required={formType === 'STAFF'}
autoComplete="off"
aria-autocomplete="list" aria-autocomplete="list"
aria-controls="staff-suggestions" aria-controls="staff-suggestions"
aria-expanded={showStaffDropdown}
aria-activedescendant={highlightedIndex >= 0 ? `staff-option-${highlightedIndex}` : undefined}
/> />
{showStaffDropdown && filteredStaff.length > 0 && ( {showStaffDropdown && displayedStaff.length > 0 && (
<div <div
ref={staffDropdownRef} ref={staffDropdownRef}
id="staff-suggestions" id="staff-suggestions"
className="absolute z-10 mt-1 w-full bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600 max-h-60 overflow-auto" className="absolute z-10 mt-1 w-full bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600 max-h-60 overflow-auto"
role="listbox" role="listbox"
> >
{filteredStaff.map((staff) => ( {displayedStaff.map((staff, index) => (
<div <div
key={staff.id} key={staff.id}
className="p-2 text-sm text-gray-900 dark:text-white hover:bg-brand-50 dark:hover:bg-brand-900/30 cursor-pointer" id={`staff-option-${index}`}
className={`p-2 text-sm text-gray-900 dark:text-white cursor-pointer transition-colors ${
index === highlightedIndex
? 'bg-brand-100 dark:bg-brand-900/50'
: selectedStaffId === staff.id
? 'bg-brand-50 dark:bg-brand-900/30'
: 'hover:bg-gray-50 dark:hover:bg-gray-600'
}`}
onClick={() => { onClick={() => {
setSelectedStaffId(staff.id); setSelectedStaffId(staff.id);
setStaffSearchQuery(staff.name); setStaffSearchQuery(staff.name);
setShowStaffDropdown(false); setShowStaffDropdown(false);
setHighlightedIndex(-1);
}}
onMouseEnter={() => setHighlightedIndex(index)}
onMouseDown={(e) => {
// Prevent input blur on mousedown
e.preventDefault();
}} }}
role="option" role="option"
aria-selected={selectedStaffId === staff.id} aria-selected={selectedStaffId === staff.id}
> >
{staff.name} ({staff.email}) <div className="font-medium">{staff.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{staff.email}</div>
</div> </div>
))} ))}
</div> </div>
)} )}
{formType === 'STAFF' && !selectedStaffId && staffSearchQuery && filteredStaff.length === 0 && ( {formType === 'STAFF' && !selectedStaffId && staffSearchQuery.trim() !== '' && filteredStaff.length === 0 && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400"> <p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
{t('resources.noMatchingStaff')} {t('resources.noMatchingStaff')}
</p> </p>
)} )}
{formType === 'STAFF' && !selectedStaffId && !staffSearchQuery && ( {selectedStaffId && selectedStaff && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400"> <p className="mt-1 text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
{t('resources.staffRequired')} <span className="inline-block w-2 h-2 bg-green-500 rounded-full"></span>
Selected: {selectedStaff.name}
</p> </p>
)} )}
</div> </div>

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react'; import React, { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Plus, Pencil, Trash2, Clock, DollarSign, X, Loader2 } from 'lucide-react'; import { Plus, Pencil, Trash2, Clock, DollarSign, X, Loader2, GripVertical, Eye, ChevronRight, Upload, ImagePlus, Image } from 'lucide-react';
import { useServices, useCreateService, useUpdateService, useDeleteService } from '../hooks/useServices'; import { useServices, useCreateService, useUpdateService, useDeleteService, useReorderServices } from '../hooks/useServices';
import { Service } from '../types'; import { Service } from '../types';
interface ServiceFormData { interface ServiceFormData {
@@ -9,6 +9,7 @@ interface ServiceFormData {
durationMinutes: number; durationMinutes: number;
price: number; price: number;
description: string; description: string;
photos: string[];
} }
const Services: React.FC = () => { const Services: React.FC = () => {
@@ -17,6 +18,7 @@ const Services: React.FC = () => {
const createService = useCreateService(); const createService = useCreateService();
const updateService = useUpdateService(); const updateService = useUpdateService();
const deleteService = useDeleteService(); const deleteService = useDeleteService();
const reorderServices = useReorderServices();
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [editingService, setEditingService] = useState<Service | null>(null); const [editingService, setEditingService] = useState<Service | null>(null);
@@ -25,8 +27,165 @@ const Services: React.FC = () => {
durationMinutes: 60, durationMinutes: 60,
price: 0, price: 0,
description: '', description: '',
photos: [],
}); });
// Photo gallery state
const [isDraggingPhoto, setIsDraggingPhoto] = useState(false);
const [draggedPhotoIndex, setDraggedPhotoIndex] = useState<number | null>(null);
const [dragOverPhotoIndex, setDragOverPhotoIndex] = useState<number | null>(null);
// Drag and drop state
const [draggedId, setDraggedId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const [localServices, setLocalServices] = useState<Service[] | null>(null);
const dragNodeRef = useRef<HTMLDivElement | null>(null);
// Use local state during drag, otherwise use fetched data
const displayServices = localServices ?? services;
// Drag handlers
const handleDragStart = (e: React.DragEvent<HTMLDivElement>, serviceId: string) => {
setDraggedId(serviceId);
dragNodeRef.current = e.currentTarget;
e.dataTransfer.effectAllowed = 'move';
// Add a slight delay to allow the drag image to be set
setTimeout(() => {
if (dragNodeRef.current) {
dragNodeRef.current.style.opacity = '0.5';
}
}, 0);
};
const handleDragEnd = () => {
if (dragNodeRef.current) {
dragNodeRef.current.style.opacity = '1';
}
setDraggedId(null);
setDragOverId(null);
dragNodeRef.current = null;
// If we have local changes, save them
if (localServices) {
const orderedIds = localServices.map(s => s.id);
reorderServices.mutate(orderedIds, {
onSettled: () => {
setLocalServices(null);
}
});
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>, serviceId: string) => {
e.preventDefault();
if (draggedId === serviceId) return;
setDragOverId(serviceId);
// Reorder locally for visual feedback
const currentServices = localServices ?? services ?? [];
const draggedIndex = currentServices.findIndex(s => s.id === draggedId);
const targetIndex = currentServices.findIndex(s => s.id === serviceId);
if (draggedIndex === -1 || targetIndex === -1 || draggedIndex === targetIndex) return;
const newServices = [...currentServices];
const [removed] = newServices.splice(draggedIndex, 1);
newServices.splice(targetIndex, 0, removed);
setLocalServices(newServices);
};
const handleDragLeave = () => {
setDragOverId(null);
};
// Photo upload handlers
const handlePhotoDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingPhoto(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
Array.from(files).forEach((file) => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onloadend = () => {
setFormData((prev) => ({
...prev,
photos: [...prev.photos, reader.result as string],
}));
};
reader.readAsDataURL(file);
}
});
}
};
const handlePhotoDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingPhoto(true);
};
const handlePhotoDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingPhoto(false);
};
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
Array.from(files).forEach((file) => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onloadend = () => {
setFormData((prev) => ({
...prev,
photos: [...prev.photos, reader.result as string],
}));
};
reader.readAsDataURL(file);
}
});
}
// Reset input
e.target.value = '';
};
const removePhoto = (index: number) => {
setFormData((prev) => ({
...prev,
photos: prev.photos.filter((_, i) => i !== index),
}));
};
// Photo reorder drag handlers
const handlePhotoReorderStart = (e: React.DragEvent<HTMLDivElement>, index: number) => {
setDraggedPhotoIndex(index);
e.dataTransfer.effectAllowed = 'move';
};
const handlePhotoReorderOver = (e: React.DragEvent<HTMLDivElement>, index: number) => {
e.preventDefault();
if (draggedPhotoIndex === null || draggedPhotoIndex === index) return;
setDragOverPhotoIndex(index);
// Reorder photos
const newPhotos = [...formData.photos];
const [removed] = newPhotos.splice(draggedPhotoIndex, 1);
newPhotos.splice(index, 0, removed);
setFormData((prev) => ({ ...prev, photos: newPhotos }));
setDraggedPhotoIndex(index);
};
const handlePhotoReorderEnd = () => {
setDraggedPhotoIndex(null);
setDragOverPhotoIndex(null);
};
const openCreateModal = () => { const openCreateModal = () => {
setEditingService(null); setEditingService(null);
setFormData({ setFormData({
@@ -34,6 +193,7 @@ const Services: React.FC = () => {
durationMinutes: 60, durationMinutes: 60,
price: 0, price: 0,
description: '', description: '',
photos: [],
}); });
setIsModalOpen(true); setIsModalOpen(true);
}; };
@@ -45,6 +205,7 @@ const Services: React.FC = () => {
durationMinutes: service.durationMinutes, durationMinutes: service.durationMinutes,
price: service.price, price: service.price,
description: service.description || '', description: service.description || '',
photos: service.photos || [],
}); });
setIsModalOpen(true); setIsModalOpen(true);
}; };
@@ -122,7 +283,7 @@ const Services: React.FC = () => {
</button> </button>
</div> </div>
{services && services.length === 0 ? ( {displayServices && displayServices.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-100 dark:border-gray-700"> <div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-100 dark:border-gray-700">
<div className="text-gray-500 dark:text-gray-400 mb-4"> <div className="text-gray-500 dark:text-gray-400 mb-4">
{t('services.noServices', 'No services yet. Add your first service to get started.')} {t('services.noServices', 'No services yet. Add your first service to get started.')}
@@ -136,60 +297,149 @@ const Services: React.FC = () => {
</button> </button>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{services?.map((service) => ( {/* Left Column - Editable Services List */}
<div <div>
key={service.id} <p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
className="p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm" {t('services.dragToReorder', 'Drag services to reorder how they appear in menus')}
> </p>
<div className="flex items-start justify-between mb-4"> <div className="space-y-3">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"> {displayServices?.map((service) => (
{service.name} <div
</h3> key={service.id}
<div className="flex items-center gap-2"> draggable
<button onDragStart={(e) => handleDragStart(e, service.id)}
onClick={() => openEditModal(service)} onDragEnd={handleDragEnd}
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors" onDragOver={(e) => handleDragOver(e, service.id)}
title={t('common.edit', 'Edit')} onDragLeave={handleDragLeave}
> className={`p-4 bg-white dark:bg-gray-800 border rounded-xl shadow-sm cursor-move transition-all ${
<Pencil className="h-4 w-4" /> draggedId === service.id
</button> ? 'opacity-50 border-brand-500'
<button : dragOverId === service.id
onClick={() => handleDelete(service.id)} ? 'border-brand-500 ring-2 ring-brand-500/50'
className="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors" : 'border-gray-100 dark:border-gray-700'
title={t('common.delete', 'Delete')} }`}
> >
<Trash2 className="h-4 w-4" /> <div className="flex items-center gap-3">
</button> <GripVertical className="h-5 w-5 text-gray-400 cursor-grab active:cursor-grabbing shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
{service.name}
</h3>
<div className="flex items-center gap-1 shrink-0 ml-2">
<button
onClick={() => openEditModal(service)}
className="p-1.5 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
title={t('common.edit', 'Edit')}
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(service.id)}
className="p-1.5 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title={t('common.delete', 'Delete')}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
{service.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 line-clamp-1">
{service.description}
</p>
)}
<div className="flex items-center gap-4 mt-2 text-sm">
<span className="text-gray-600 dark:text-gray-300 flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{service.durationMinutes} {t('common.minutes', 'min')}
</span>
<span className="text-gray-600 dark:text-gray-300 flex items-center gap-1">
<DollarSign className="h-3.5 w-3.5" />
${service.price.toFixed(2)}
</span>
{service.photos && service.photos.length > 0 && (
<span className="text-gray-500 dark:text-gray-400 flex items-center gap-1">
<Image className="h-3.5 w-3.5" />
{service.photos.length}
</span>
)}
</div>
</div>
</div>
</div> </div>
</div> ))}
</div>
</div>
{service.description && ( {/* Right Column - Customer Preview Mockup */}
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4"> <div>
{service.description} <div className="flex items-center gap-2 mb-4">
</p> <Eye className="h-5 w-5 text-gray-500 dark:text-gray-400" />
)} <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('services.customerPreview', 'Customer Preview')}
</h3>
</div>
<div className="flex items-center gap-4 text-sm"> {/* Mockup Container - styled like a booking widget */}
<div className="flex items-center gap-1 text-gray-600 dark:text-gray-300"> <div className="sticky top-8">
<Clock className="h-4 w-4" /> <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<span>{service.durationMinutes} {t('common.minutes', 'min')}</span> {/* Mockup Header */}
<div className="bg-brand-600 px-6 py-4">
<h4 className="text-white font-semibold text-lg">{t('services.selectService', 'Select a Service')}</h4>
<p className="text-white/70 text-sm">{t('services.chooseFromMenu', 'Choose from our available services')}</p>
</div> </div>
<div className="flex items-center gap-1 text-gray-600 dark:text-gray-300">
<DollarSign className="h-4 w-4" /> {/* Services List */}
<span>${service.price.toFixed(2)}</span> <div className="divide-y divide-gray-100 dark:divide-gray-700 max-h-[500px] overflow-y-auto">
{displayServices?.map((service) => (
<div
key={`preview-${service.id}`}
className="px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors cursor-pointer group"
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<h5 className="font-medium text-gray-900 dark:text-white truncate">
{service.name}
</h5>
{service.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-1">
{service.description}
</p>
)}
<div className="flex items-center gap-3 mt-2 text-sm">
<span className="text-gray-600 dark:text-gray-300 flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{service.durationMinutes} min
</span>
<span className="font-semibold text-brand-600 dark:text-brand-400">
${service.price.toFixed(2)}
</span>
</div>
</div>
<ChevronRight className="h-5 w-5 text-gray-400 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors shrink-0 ml-4" />
</div>
</div>
))}
</div>
{/* Mockup Footer */}
<div className="bg-gray-50 dark:bg-gray-900/50 px-6 py-3 text-center border-t border-gray-100 dark:border-gray-700">
<p className="text-xs text-gray-400 dark:text-gray-500">
{t('services.mockupNote', 'Preview only - not clickable')}
</p>
</div> </div>
</div> </div>
</div> </div>
))} </div>
</div> </div>
)} )}
{/* Modal */} {/* Modal */}
{isModalOpen && ( {isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full mx-4"> <div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full mx-4 max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700"> <div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700 shrink-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"> <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{editingService {editingService
? t('services.editService', 'Edit Service') ? t('services.editService', 'Edit Service')
@@ -203,66 +453,157 @@ const Services: React.FC = () => {
</button> </button>
</div> </div>
<form onSubmit={handleSubmit} className="p-6 space-y-4"> <form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
<div> <div className="p-6 space-y-4 overflow-y-auto flex-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.name', 'Name')} *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => 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')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.duration', 'Duration (min)')} * {t('services.name', 'Name')} *
</label> </label>
<input <input
type="number" type="text"
value={formData.durationMinutes} value={formData.name}
onChange={(e) => setFormData({ ...formData, durationMinutes: parseInt(e.target.value) || 0 })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required 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" 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')}
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.duration', 'Duration (min)')} *
</label>
<input
type="number"
value={formData.durationMinutes}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.price', 'Price ($)')} *
</label>
<input
type="number"
value={formData.price}
onChange={(e) => 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"
/>
</div>
</div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.price', 'Price ($)')} * {t('services.descriptionLabel', 'Description')}
</label> </label>
<input <textarea
type="number" value={formData.description}
value={formData.price} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })} rows={3}
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 resize-none"
min={0} placeholder={t('services.descriptionPlaceholder', 'Optional description of the service...')}
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"
/> />
</div> </div>
{/* Photo Gallery */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('services.photos', 'Photos')}
</label>
{/* Photo Grid */}
{formData.photos.length > 0 && (
<div className="grid grid-cols-4 gap-3 mb-3">
{formData.photos.map((photo, index) => (
<div
key={index}
draggable
onDragStart={(e) => handlePhotoReorderStart(e, index)}
onDragOver={(e) => handlePhotoReorderOver(e, index)}
onDragEnd={handlePhotoReorderEnd}
className={`relative group aspect-square rounded-lg overflow-hidden border-2 cursor-move transition-all ${
draggedPhotoIndex === index
? 'opacity-50 border-brand-500'
: dragOverPhotoIndex === index
? 'border-brand-500 ring-2 ring-brand-500/50'
: 'border-gray-200 dark:border-gray-600'
}`}
>
<img
src={photo}
alt={`Photo ${index + 1}`}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<div className="absolute top-1 left-1 text-white/70">
<GripVertical className="h-4 w-4" />
</div>
<button
type="button"
onClick={() => removePhoto(index)}
className="p-1.5 bg-red-500 hover:bg-red-600 text-white rounded-full transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<div className="absolute bottom-1 right-1 bg-black/60 text-white text-[10px] px-1.5 py-0.5 rounded">
{index + 1}
</div>
</div>
))}
</div>
)}
{/* Drop Zone */}
<div
onDrop={handlePhotoDrop}
onDragOver={handlePhotoDragOver}
onDragLeave={handlePhotoDragLeave}
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
isDraggingPhoto
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
}`}
>
<ImagePlus className={`mx-auto mb-2 h-8 w-8 ${isDraggingPhoto ? 'text-brand-500' : 'text-gray-400'}`} />
<p className={`text-sm ${isDraggingPhoto ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'}`}>
{isDraggingPhoto ? t('services.dropImagesHere', 'Drop images here') : t('services.dragAndDropImages', 'Drag and drop images here, or')}
</p>
{!isDraggingPhoto && (
<>
<input
type="file"
id="service-photo-upload"
className="hidden"
accept="image/*"
multiple
onChange={handlePhotoUpload}
/>
<label
htmlFor="service-photo-upload"
className="inline-flex items-center gap-1 mt-2 px-3 py-1.5 text-sm font-medium text-brand-600 dark:text-brand-400 hover:text-brand-700 dark:hover:text-brand-300 cursor-pointer"
>
<Upload className="h-3.5 w-3.5" />
{t('services.browseFiles', 'browse files')}
</label>
</>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
{t('services.photosHint', 'Drag photos to reorder. First photo is the primary image.')}
</p>
</div>
</div> </div>
<div> <div className="flex justify-end gap-3 p-6 border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 shrink-0">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.descriptionLabel', 'Description')}
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
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 resize-none"
placeholder={t('services.descriptionPlaceholder', 'Optional description of the service...')}
/>
</div>
<div className="flex justify-end gap-3 pt-4">
<button <button
type="button" type="button"
onClick={closeModal} onClick={closeModal}

View File

@@ -2,11 +2,12 @@ import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom'; import { useOutletContext } from 'react-router-dom';
import { Business, User, CustomDomain } from '../types'; import { Business, User, CustomDomain } from '../types';
import { Save, Globe, Palette, BookKey, Check, Sparkles, CheckCircle, Link2, AlertCircle, ExternalLink, Copy, Crown, ShieldCheck, Trash2, RefreshCw, Star, Eye, EyeOff, Key, ShoppingCart, Building2, Users, Lock, Wallet } from 'lucide-react'; import { Save, Globe, Palette, BookKey, Check, Sparkles, CheckCircle, Link2, AlertCircle, ExternalLink, Copy, Crown, ShieldCheck, Trash2, RefreshCw, Star, Eye, EyeOff, Key, ShoppingCart, Building2, Users, Lock, Wallet, X, Plus, Layers, Pencil } from 'lucide-react';
import DomainPurchase from '../components/DomainPurchase'; import DomainPurchase from '../components/DomainPurchase';
import { useBusinessOAuthSettings, useUpdateBusinessOAuthSettings } from '../hooks/useBusinessOAuth'; import { useBusinessOAuthSettings, useUpdateBusinessOAuthSettings } from '../hooks/useBusinessOAuth';
import { useCustomDomains, useAddCustomDomain, useDeleteCustomDomain, useVerifyCustomDomain, useSetPrimaryDomain } from '../hooks/useCustomDomains'; import { useCustomDomains, useAddCustomDomain, useDeleteCustomDomain, useVerifyCustomDomain, useSetPrimaryDomain } from '../hooks/useCustomDomains';
import { useBusinessOAuthCredentials, useUpdateBusinessOAuthCredentials } from '../hooks/useBusinessOAuthCredentials'; import { useBusinessOAuthCredentials, useUpdateBusinessOAuthCredentials } from '../hooks/useBusinessOAuthCredentials';
import { useResourceTypes, useCreateResourceType, useUpdateResourceType, useDeleteResourceType } from '../hooks/useResourceTypes';
import OnboardingWizard from '../components/OnboardingWizard'; import OnboardingWizard from '../components/OnboardingWizard';
// Curated color palettes with complementary primary and secondary colors // Curated color palettes with complementary primary and secondary colors
@@ -18,6 +19,55 @@ const colorPalettes = [
secondary: '#0ea5e9', secondary: '#0ea5e9',
preview: 'bg-gradient-to-br from-blue-600 to-sky-500', preview: 'bg-gradient-to-br from-blue-600 to-sky-500',
}, },
{
name: 'Sky Blue',
description: 'Light & airy',
primary: '#0ea5e9',
secondary: '#38bdf8',
preview: 'bg-gradient-to-br from-sky-500 to-sky-400',
},
{
name: 'Cyan Splash',
description: 'Modern & vibrant',
primary: '#06b6d4',
secondary: '#22d3ee',
preview: 'bg-gradient-to-br from-cyan-500 to-cyan-400',
},
{
name: 'Aqua Fresh',
description: 'Clean & refreshing',
primary: '#14b8a6',
secondary: '#2dd4bf',
preview: 'bg-gradient-to-br from-teal-500 to-teal-400',
},
{
name: 'Mint Green',
description: 'Soft & welcoming',
primary: '#10b981',
secondary: '#34d399',
preview: 'bg-gradient-to-br from-emerald-500 to-emerald-400',
},
{
name: 'Coral Reef',
description: 'Warm & inviting',
primary: '#f97316',
secondary: '#fb923c',
preview: 'bg-gradient-to-br from-orange-500 to-orange-400',
},
{
name: 'Lavender Dream',
description: 'Gentle & elegant',
primary: '#a78bfa',
secondary: '#c4b5fd',
preview: 'bg-gradient-to-br from-violet-400 to-violet-300',
},
{
name: 'Rose Pink',
description: 'Friendly & modern',
primary: '#ec4899',
secondary: '#f472b6',
preview: 'bg-gradient-to-br from-pink-500 to-pink-400',
},
{ {
name: 'Forest Green', name: 'Forest Green',
description: 'Natural & calming', description: 'Natural & calming',
@@ -32,20 +82,6 @@ const colorPalettes = [
secondary: '#a78bfa', secondary: '#a78bfa',
preview: 'bg-gradient-to-br from-violet-600 to-purple-400', preview: 'bg-gradient-to-br from-violet-600 to-purple-400',
}, },
{
name: 'Sunset Orange',
description: 'Energetic & warm',
primary: '#ea580c',
secondary: '#f97316',
preview: 'bg-gradient-to-br from-orange-600 to-amber-500',
},
{
name: 'Rose Pink',
description: 'Friendly & modern',
primary: '#db2777',
secondary: '#f472b6',
preview: 'bg-gradient-to-br from-pink-600 to-pink-400',
},
{ {
name: 'Slate Gray', name: 'Slate Gray',
description: 'Minimal & sophisticated', description: 'Minimal & sophisticated',
@@ -53,13 +89,6 @@ const colorPalettes = [
secondary: '#64748b', secondary: '#64748b',
preview: 'bg-gradient-to-br from-slate-600 to-slate-400', preview: 'bg-gradient-to-br from-slate-600 to-slate-400',
}, },
{
name: 'Teal Wave',
description: 'Fresh & balanced',
primary: '#0d9488',
secondary: '#14b8a6',
preview: 'bg-gradient-to-br from-teal-600 to-teal-400',
},
{ {
name: 'Crimson Red', name: 'Crimson Red',
description: 'Bold & dynamic', description: 'Bold & dynamic',
@@ -69,7 +98,256 @@ const colorPalettes = [
}, },
]; ];
type SettingsTab = 'general' | 'domains' | 'authentication'; type SettingsTab = 'general' | 'domains' | 'authentication' | 'resources';
// Resource Types Management Section Component
const ResourceTypesSection: React.FC = () => {
const { t } = useTranslation();
const { data: resourceTypes = [], isLoading } = useResourceTypes();
const createResourceType = useCreateResourceType();
const updateResourceType = useUpdateResourceType();
const deleteResourceType = useDeleteResourceType();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingType, setEditingType] = useState<any>(null);
const [formData, setFormData] = useState({
name: '',
description: '',
category: 'OTHER' as 'STAFF' | 'OTHER',
iconName: '',
});
const openCreateModal = () => {
setEditingType(null);
setFormData({ name: '', description: '', category: 'OTHER', iconName: '' });
setIsModalOpen(true);
};
const openEditModal = (type: any) => {
setEditingType(type);
setFormData({
name: type.name,
description: type.description || '',
category: type.category,
iconName: type.icon_name || type.iconName || '',
});
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
setEditingType(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingType) {
await updateResourceType.mutateAsync({
id: editingType.id,
updates: formData,
});
} else {
await createResourceType.mutateAsync(formData);
}
closeModal();
} catch (error) {
console.error('Failed to save resource type:', error);
}
};
const handleDelete = async (id: string, name: string) => {
if (window.confirm(`Are you sure you want to delete the "${name}" resource type?`)) {
try {
await deleteResourceType.mutateAsync(id);
} catch (error: any) {
alert(error.response?.data?.error || 'Failed to delete resource type');
}
}
};
return (
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Layers size={20} className="text-indigo-500" />
{t('settings.resourceTypes', 'Resource Types')}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{t('settings.resourceTypesDescription', 'Define custom types for your resources (e.g., Stylist, Treatment Room, Equipment)')}
</p>
</div>
<button
onClick={openCreateModal}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium text-sm"
>
<Plus size={16} />
{t('settings.addResourceType', 'Add Type')}
</button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
</div>
) : resourceTypes.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<Layers size={40} className="mx-auto mb-2 opacity-30" />
<p>{t('settings.noResourceTypes', 'No custom resource types yet.')}</p>
<p className="text-sm mt-1">{t('settings.addFirstResourceType', 'Add your first resource type to categorize your resources.')}</p>
</div>
) : (
<div className="space-y-3">
{resourceTypes.map((type: any) => {
const isDefault = type.is_default || type.isDefault;
return (
<div
key={type.id}
className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${
type.category === 'STAFF' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}>
{type.category === 'STAFF' ? <Users size={20} /> : <Layers size={20} />}
</div>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-900 dark:text-white flex items-center gap-2">
{type.name}
{isDefault && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
Default
</span>
)}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
{type.category === 'STAFF' ? 'Requires staff assignment' : 'General resource'}
</p>
{type.description && (
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1 line-clamp-2">
{type.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-2">
<button
onClick={() => openEditModal(type)}
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
title={t('common.edit', 'Edit')}
>
<Pencil size={16} />
</button>
{!isDefault && (
<button
onClick={() => handleDelete(type.id, type.name)}
disabled={deleteResourceType.isPending}
className="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50"
title={t('common.delete', 'Delete')}
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{/* Modal for Create/Edit */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700 shrink-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{editingType
? t('settings.editResourceType', 'Edit Resource Type')
: t('settings.addResourceType', 'Add Resource Type')}
</h3>
<button
onClick={closeModal}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.resourceTypeName', 'Name')} *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => 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('settings.resourceTypeNamePlaceholder', 'e.g., Stylist, Treatment Room, Camera')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.resourceTypeDescription', 'Description')}
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
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 resize-none"
placeholder={t('settings.resourceTypeDescriptionPlaceholder', 'Describe this type of resource...')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('settings.resourceTypeCategory', 'Category')} *
</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value as 'STAFF' | 'OTHER' })}
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"
>
<option value="STAFF">{t('settings.categoryStaff', 'Staff (requires staff assignment)')}</option>
<option value="OTHER">{t('settings.categoryOther', 'Other (general resource)')}</option>
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{formData.category === 'STAFF'
? t('settings.staffCategoryHint', 'Staff resources must be assigned to a team member')
: t('settings.otherCategoryHint', 'General resources like rooms, equipment, or vehicles')}
</p>
</div>
</div>
<div className="flex justify-end gap-3 p-6 border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 shrink-0">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
{t('common.cancel', 'Cancel')}
</button>
<button
type="submit"
disabled={createResourceType.isPending || updateResourceType.isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{editingType ? t('common.save', 'Save') : t('common.create', 'Create')}
</button>
</div>
</form>
</div>
</div>
)}
</section>
);
};
const SettingsPage: React.FC = () => { const SettingsPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -115,6 +393,16 @@ const SettingsPage: React.FC = () => {
const [showSecrets, setShowSecrets] = useState<{ [key: string]: boolean }>({}); const [showSecrets, setShowSecrets] = useState<{ [key: string]: boolean }>({});
const [showOnboarding, setShowOnboarding] = useState(false); const [showOnboarding, setShowOnboarding] = useState(false);
// Drag and drop state for logo uploads
const [isDraggingLogo, setIsDraggingLogo] = useState(false);
const [isDraggingEmailLogo, setIsDraggingEmailLogo] = useState(false);
// Lightbox state for viewing logos
const [lightboxImage, setLightboxImage] = useState<{ url: string; title: string } | null>(null);
// Email preview modal state
const [showEmailPreview, setShowEmailPreview] = useState(false);
// Update OAuth settings when data loads // Update OAuth settings when data loads
useEffect(() => { useEffect(() => {
if (oauthData?.businessSettings) { if (oauthData?.businessSettings) {
@@ -159,11 +447,77 @@ const SettingsPage: React.FC = () => {
} }
}; };
// Drag and drop handlers for logo upload
const handleLogoDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingLogo(true);
};
const handleLogoDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingLogo(false);
};
const handleLogoDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingLogo(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
const file = files[0];
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onloadend = () => {
setFormState(prev => ({ ...prev, logoUrl: reader.result as string }));
};
reader.readAsDataURL(file);
}
}
};
// Drag and drop handlers for email logo upload
const handleEmailLogoDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingEmailLogo(true);
};
const handleEmailLogoDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingEmailLogo(false);
};
const handleEmailLogoDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingEmailLogo(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
const file = files[0];
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onloadend = () => {
setFormState(prev => ({ ...prev, emailLogoUrl: reader.result as string }));
};
reader.readAsDataURL(file);
}
}
};
const handleSave = () => { const handleSave = () => {
updateBusiness(formState); updateBusiness(formState);
setShowToast(true); setShowToast(true);
}; };
const handleCancel = () => {
setFormState(business);
};
const handleOAuthSave = () => { const handleOAuthSave = () => {
updateOAuthMutation.mutate(oauthSettings, { updateOAuthMutation.mutate(oauthSettings, {
onSuccess: () => { onSuccess: () => {
@@ -289,25 +643,17 @@ const SettingsPage: React.FC = () => {
// Tab configuration // Tab configuration
const tabs = [ const tabs = [
{ id: 'general' as const, label: 'General', icon: Building2 }, { id: 'general' as const, label: 'General', icon: Building2 },
{ id: 'resources' as const, label: 'Resource Types', icon: Layers },
{ id: 'domains' as const, label: 'Domains', icon: Globe }, { id: 'domains' as const, label: 'Domains', icon: Globe },
{ id: 'authentication' as const, label: 'Authentication', icon: Lock }, { id: 'authentication' as const, label: 'Authentication', icon: Lock },
]; ];
return ( return (
<div className="p-8 max-w-4xl mx-auto"> <div className="p-8 max-w-4xl mx-auto pb-24">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-6"> <div className="mb-6">
<div> <h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('settings.businessSettings')}</h2>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('settings.businessSettings')}</h2> <p className="text-gray-500 dark:text-gray-400">{t('settings.businessSettingsDescription')}</p>
<p className="text-gray-500 dark:text-gray-400">{t('settings.businessSettingsDescription')}</p>
</div>
<button
onClick={handleSave}
className="flex items-center gap-2 px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium"
>
<Save size={18} />
{t('common.saveChanges')}
</button>
</div> </div>
{/* Tab Navigation */} {/* Tab Navigation */}
@@ -367,6 +713,270 @@ const SettingsPage: React.FC = () => {
<Palette size={20} className="text-purple-500"/> {t('settings.branding')} <Palette size={20} className="text-purple-500"/> {t('settings.branding')}
</h3> </h3>
{/* Logo Upload */}
<div className="mb-6 pb-6 border-b border-gray-200 dark:border-gray-700">
<h4 className="font-medium text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Image size={16} className="text-blue-500" />
Brand Logos
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
Upload your logos for different purposes. PNG with transparent background recommended.
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Website Logo Upload/Display */}
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h5 className="font-medium text-gray-900 dark:text-white mb-3">Website Logo</h5>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Used in sidebar and customer-facing pages
</p>
<div
onDragOver={handleLogoDragOver}
onDragLeave={handleLogoDragLeave}
onDrop={handleLogoDrop}
className={`transition-all ${isDraggingLogo ? 'scale-105' : ''}`}
>
{formState.logoUrl ? (
<div className="space-y-3">
<div className="relative inline-block group">
<img
src={formState.logoUrl}
alt="Business logo"
onClick={() => setLightboxImage({ url: formState.logoUrl!, title: 'Website Logo' })}
className="w-32 h-32 object-contain border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 p-2 cursor-pointer hover:border-blue-400 transition-colors"
/>
<button
type="button"
onClick={() => {
setFormState(prev => ({
...prev,
logoUrl: undefined,
logoDisplayMode: 'logo-and-text' // Reset to show icon with text
}));
}}
className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full p-1.5 shadow-lg transition-colors z-10"
title="Remove logo"
>
<X size={14} />
</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Click to view full size Click × to remove Drag and drop to replace
</p>
</div>
) : (
<div className={`w-32 h-32 border-2 border-dashed rounded-lg flex items-center justify-center transition-colors ${
isDraggingLogo
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-500'
: 'border-gray-300 dark:border-gray-600 text-gray-400'
}`}>
<div className="text-center">
<Image size={32} className="mx-auto mb-2" />
<p className="text-xs">Drop image here</p>
</div>
</div>
)}
</div>
<div className="mt-3">
<input
type="file"
id="logo-upload"
className="hidden"
accept="image/png,image/jpeg,image/svg+xml"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
// TODO: Upload to backend
const reader = new FileReader();
reader.onloadend = () => {
setFormState(prev => ({ ...prev, logoUrl: reader.result as string }));
};
reader.readAsDataURL(file);
}
}}
/>
<label
htmlFor="logo-upload"
className="inline-flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer transition-colors text-sm font-medium"
>
<Upload size={16} />
{formState.logoUrl ? 'Change Logo' : 'Upload Logo'}
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
PNG, JPG, or SVG. Recommended: 500x500px
</p>
</div>
{/* Logo Display Mode */}
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Display Mode
</label>
<select
name="logoDisplayMode"
value={formState.logoDisplayMode || 'text-only'}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg text-sm"
>
<option value="text-only">Text Only</option>
<option value="logo-only">Logo Only</option>
<option value="logo-and-text">Logo and Text</option>
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
How your branding appears in the sidebar
</p>
</div>
</div>
{/* Email Logo Upload/Display */}
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h5 className="font-medium text-gray-900 dark:text-white mb-3">Email Logo</h5>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
Used in email notifications and receipts
</p>
<div
onDragOver={handleEmailLogoDragOver}
onDragLeave={handleEmailLogoDragLeave}
onDrop={handleEmailLogoDrop}
className={`transition-all ${isDraggingEmailLogo ? 'scale-105' : ''}`}
>
{formState.emailLogoUrl ? (
<div className="space-y-3">
<div className="relative inline-block group">
<img
src={formState.emailLogoUrl}
alt="Email logo"
onClick={() => setLightboxImage({ url: formState.emailLogoUrl!, title: 'Email Logo' })}
className="w-48 h-16 object-contain border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 p-2 cursor-pointer hover:border-blue-400 transition-colors"
/>
<button
type="button"
onClick={() => {
setFormState(prev => ({ ...prev, emailLogoUrl: undefined }));
}}
className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full p-1.5 shadow-lg transition-colors z-10"
title="Remove email logo"
>
<X size={14} />
</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Click to view full size Click × to remove Drag and drop to replace
</p>
</div>
) : (
<div className={`w-48 h-16 border-2 border-dashed rounded-lg flex items-center justify-center transition-colors ${
isDraggingEmailLogo
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-500'
: 'border-gray-300 dark:border-gray-600 text-gray-400'
}`}>
<div className="text-center">
<Image size={24} className="mx-auto mb-1" />
<p className="text-xs">Drop image here</p>
</div>
</div>
)}
</div>
<div className="mt-3">
<input
type="file"
id="email-logo-upload"
className="hidden"
accept="image/png,image/jpeg,image/svg+xml"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
// TODO: Upload to backend
const reader = new FileReader();
reader.onloadend = () => {
setFormState(prev => ({ ...prev, emailLogoUrl: reader.result as string }));
};
reader.readAsDataURL(file);
}
}}
/>
<label
htmlFor="email-logo-upload"
className="inline-flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer transition-colors text-sm font-medium"
>
<Upload size={16} />
{formState.emailLogoUrl ? 'Change Email Logo' : 'Upload Email Logo'}
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
PNG with transparent background. Recommended: 600x200px
</p>
<button
type="button"
onClick={() => setShowEmailPreview(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium mt-3"
>
<Eye size={16} />
Preview Email
</button>
</div>
</div>
</div>
{/* Sidebar Preview */}
<div className="mt-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Sidebar Preview
</label>
<div
className="w-full max-w-xs p-6 rounded-xl"
style={{ backgroundColor: formState.primaryColor }}
>
<div className="flex items-center gap-3">
{/* Logo-only mode: full width */}
{formState.logoDisplayMode === 'logo-only' && formState.logoUrl ? (
<div className="flex items-center justify-center w-full">
<img
src={formState.logoUrl}
alt={formState.name}
className="max-w-full max-h-16 object-contain"
/>
</div>
) : (
<>
{/* Logo/Icon display - only show if NOT text-only mode */}
{formState.logoDisplayMode !== 'text-only' && (
formState.logoUrl ? (
<div className="flex items-center justify-center w-10 h-10 shrink-0">
<img
src={formState.logoUrl}
alt={formState.name}
className="w-full h-full object-contain"
/>
</div>
) : (
<div
className="flex items-center justify-center w-10 h-10 bg-white rounded-lg text-brand-600 font-bold text-xl shrink-0"
style={{ color: formState.primaryColor }}
>
{formState.name.substring(0, 2).toUpperCase()}
</div>
)
)}
{/* Text display - only show if NOT logo-only mode */}
{formState.logoDisplayMode !== 'logo-only' && (
<div className="overflow-hidden">
<h1 className="font-bold leading-tight truncate text-white">{formState.name}</h1>
<p className="text-xs text-white/60 truncate">{business.subdomain}.smoothschedule.com</p>
</div>
)}
</>
)}
</div>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
This is how your branding will appear in the navigation sidebar
</p>
</div>
</div>
{/* Color Palette Selection */} {/* Color Palette Selection */}
<div className="mb-6"> <div className="mb-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@@ -593,6 +1203,11 @@ const SettingsPage: React.FC = () => {
</> </>
)} )}
{/* RESOURCES TAB */}
{activeTab === 'resources' && isOwner && (
<ResourceTypesSection />
)}
{/* DOMAINS TAB */} {/* DOMAINS TAB */}
{activeTab === 'domains' && ( {activeTab === 'domains' && (
<> <>
@@ -1103,6 +1718,185 @@ const SettingsPage: React.FC = () => {
}} }}
/> />
)} )}
{/* Lightbox Modal for Logo Preview */}
{lightboxImage && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
onClick={() => setLightboxImage(null)}
>
<div className="relative max-w-4xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between mb-4 text-white">
<h3 className="text-lg font-semibold">{lightboxImage.title}</h3>
<button
onClick={() => setLightboxImage(null)}
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
title="Close"
>
<X size={24} />
</button>
</div>
<div
className="bg-white dark:bg-gray-800 rounded-lg p-8 overflow-auto"
onClick={(e) => e.stopPropagation()}
>
<img
src={lightboxImage.url}
alt={lightboxImage.title}
className="max-w-full max-h-[70vh] object-contain mx-auto"
/>
</div>
<p className="text-white text-sm mt-4 text-center">
Click anywhere outside to close
</p>
</div>
</div>
)}
{/* Email Preview Modal */}
{showEmailPreview && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
onClick={() => setShowEmailPreview(false)}
>
<div
className="relative max-w-2xl w-full bg-white dark:bg-gray-800 rounded-lg shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">Email Preview</h3>
<button
onClick={() => setShowEmailPreview(false)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Close"
>
<X size={24} />
</button>
</div>
<div className="p-6 overflow-auto max-h-[70vh]">
{/* Email Template Preview */}
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-8" style={{ fontFamily: 'Arial, sans-serif' }}>
{/* Email Header with Logo */}
<div className="bg-white dark:bg-gray-800 rounded-t-lg p-6 text-center border-b-4" style={{ borderBottomColor: formState.primaryColor }}>
{formState.emailLogoUrl ? (
<img
src={formState.emailLogoUrl}
alt={formState.name}
className="mx-auto max-h-20 object-contain"
/>
) : (
<div className="flex items-center justify-center">
<div
className="inline-flex items-center justify-center w-16 h-16 rounded-full text-white font-bold text-2xl"
style={{ backgroundColor: formState.primaryColor }}
>
{formState.name.substring(0, 2).toUpperCase()}
</div>
</div>
)}
</div>
{/* Email Body */}
<div className="bg-white dark:bg-gray-800 p-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Appointment Confirmation
</h2>
<p className="text-gray-700 dark:text-gray-300 mb-4">
Hi John Doe,
</p>
<p className="text-gray-700 dark:text-gray-300 mb-6">
Your appointment has been confirmed. Here are the details:
</p>
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-6 mb-6">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-500 dark:text-gray-400 font-medium">Service</p>
<p className="text-gray-900 dark:text-white">Haircut & Style</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400 font-medium">Date & Time</p>
<p className="text-gray-900 dark:text-white">Dec 15, 2025 at 2:00 PM</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400 font-medium">Duration</p>
<p className="text-gray-900 dark:text-white">60 minutes</p>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400 font-medium">Price</p>
<p className="text-gray-900 dark:text-white">$45.00</p>
</div>
</div>
</div>
<button
className="w-full text-white font-semibold py-3 px-6 rounded-lg transition-colors"
style={{ backgroundColor: formState.primaryColor }}
>
View Appointment Details
</button>
<p className="text-gray-600 dark:text-gray-400 text-sm mt-6">
Need to make changes? You can reschedule or cancel up to 24 hours before your appointment.
</p>
</div>
{/* Email Footer */}
<div className="bg-gray-100 dark:bg-gray-900 rounded-b-lg p-6 text-center">
<p className="text-gray-600 dark:text-gray-400 text-sm mb-2">
{formState.name}
</p>
<p className="text-gray-500 dark:text-gray-500 text-xs">
{business.subdomain}.smoothschedule.com
</p>
<p className="text-gray-400 dark:text-gray-600 text-xs mt-4">
© 2025 {formState.name}. All rights reserved.
</p>
</div>
</div>
</div>
<div className="p-6 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 rounded-b-lg">
<p className="text-sm text-gray-600 dark:text-gray-400 text-center">
This is a preview of how your appointment confirmation emails will appear to customers.
</p>
</div>
</div>
</div>
)}
{/* Floating Action Buttons */}
<div className="fixed bottom-0 left-64 right-0 p-4 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 shadow-lg z-40 md:left-64">
<div className="max-w-4xl mx-auto flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
{showToast && (
<span className="flex items-center gap-2 text-green-600 dark:text-green-400">
<CheckCircle size={16} />
Changes saved successfully
</span>
)}
</div>
<div className="flex items-center gap-3">
<button
onClick={handleCancel}
className="flex items-center gap-2 px-6 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors font-medium"
>
<X size={18} />
Cancel Changes
</button>
<button
onClick={handleSave}
className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-md font-medium"
>
<Save size={18} />
Save Changes
</button>
</div>
</div>
</div>
</div> </div>
); );
}; };

View File

@@ -97,8 +97,8 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
{staffUsers.map((user: any) => { {staffUsers.map((user: any) => {
const linkedResource = getLinkedResource(user.id); const linkedResource = getLinkedResource(user.id);
// Owners/Managers can log in as anyone. // Only owners can masquerade as staff (per backend permissions)
const canMasquerade = ['owner', 'manager'].includes(effectiveUser.role) && user.id !== effectiveUser.id; const canMasquerade = effectiveUser.role === 'owner' && user.id !== effectiveUser.id;
return ( return (
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group"> <tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group">

View File

@@ -2,11 +2,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Search, Filter, MoreHorizontal, Eye, ShieldCheck, Ban } from 'lucide-react'; import { Search, Filter, MoreHorizontal, Eye, ShieldCheck, Ban } from 'lucide-react';
import { User } from '../../types';
import { useBusinesses } from '../../hooks/usePlatform'; import { useBusinesses } from '../../hooks/usePlatform';
interface PlatformBusinessesProps { interface PlatformBusinessesProps {
onMasquerade: (targetUser: User) => void; onMasquerade: (targetUser: { id: number; username?: string; name?: string; email?: string; role?: string }) => void;
} }
const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade }) => { const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade }) => {
@@ -22,19 +21,14 @@ const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade })
const handleLoginAs = (business: any) => { const handleLoginAs = (business: any) => {
// Use the owner data from the API response // Use the owner data from the API response
if (business.owner) { if (business.owner) {
const targetOwner: User = { // Pass owner info to masquerade - we only need the id
id: business.owner.id.toString(), onMasquerade({
id: business.owner.id,
username: business.owner.username, username: business.owner.username,
name: business.owner.name, name: business.owner.full_name,
email: business.owner.email, email: business.owner.email,
role: business.owner.role, role: business.owner.role,
business_id: business.id.toString(), });
business_subdomain: business.subdomain,
is_active: true,
is_staff: false,
is_superuser: false,
};
onMasquerade(targetOwner);
} }
}; };
@@ -130,14 +124,14 @@ const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade })
</div> </div>
</td> </td>
<td className="px-6 py-4 text-gray-500 dark:text-gray-400"> <td className="px-6 py-4 text-gray-500 dark:text-gray-400">
{new Date(biz.created_at).toLocaleDateString()} {new Date(biz.created_on).toLocaleDateString()}
</td> </td>
<td className="px-6 py-4 text-right"> <td className="px-6 py-4 text-right">
<button <button
onClick={() => handleLoginAs(biz)} onClick={() => handleLoginAs(biz)}
className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors mr-2" className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors mr-2"
disabled={!biz.owner} disabled={!biz.owner}
title={!biz.owner ? 'No owner assigned' : `Masquerade as ${biz.owner.name}`} title={!biz.owner ? 'No owner assigned' : `Masquerade as ${biz.owner.full_name}`}
> >
<Eye size={14} /> {t('platform.masquerade')} <Eye size={14} /> {t('platform.masquerade')}
</button> </button>

View File

@@ -2,11 +2,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Search, Filter, Eye, Shield, User as UserIcon } from 'lucide-react'; import { Search, Filter, Eye, Shield, User as UserIcon } from 'lucide-react';
import { User } from '../../types';
import { usePlatformUsers } from '../../hooks/usePlatform'; import { usePlatformUsers } from '../../hooks/usePlatform';
interface PlatformUsersProps { interface PlatformUsersProps {
onMasquerade: (targetUser: User) => void; onMasquerade: (targetUser: { id: number; username?: string; name?: string; email?: string; role?: string }) => void;
} }
const PlatformUsers: React.FC<PlatformUsersProps> = ({ onMasquerade }) => { const PlatformUsers: React.FC<PlatformUsersProps> = ({ onMasquerade }) => {
@@ -36,20 +35,14 @@ const PlatformUsers: React.FC<PlatformUsersProps> = ({ onMasquerade }) => {
}; };
const handleMasquerade = (platformUser: any) => { const handleMasquerade = (platformUser: any) => {
// Convert platform user to User type for masquerade // Pass user info to masquerade - we only need the id
const targetUser: User = { onMasquerade({
id: platformUser.id.toString(), id: platformUser.id,
username: platformUser.username, username: platformUser.username,
name: platformUser.name || platformUser.username, name: platformUser.full_name || platformUser.username,
email: platformUser.email, email: platformUser.email,
role: platformUser.role || 'customer', role: platformUser.role || 'customer',
business_id: platformUser.business?.toString() || null, });
business_subdomain: platformUser.business_subdomain || null,
is_active: platformUser.is_active,
is_staff: platformUser.is_staff,
is_superuser: platformUser.is_superuser,
};
onMasquerade(targetUser);
}; };
if (isLoading) { if (isLoading) {

View File

@@ -38,6 +38,8 @@ export interface Business {
primaryColor: string; primaryColor: string;
secondaryColor: string; secondaryColor: string;
logoUrl?: string; logoUrl?: string;
emailLogoUrl?: string;
logoDisplayMode?: 'logo-only' | 'text-only' | 'logo-and-text'; // How to display branding
whitelabelEnabled: boolean; whitelabelEnabled: boolean;
plan?: 'Free' | 'Professional' | 'Business' | 'Enterprise'; plan?: 'Free' | 'Professional' | 'Business' | 'Enterprise';
status?: 'Active' | 'Suspended' | 'Trial'; status?: 'Active' | 'Suspended' | 'Trial';
@@ -58,6 +60,7 @@ export interface Business {
isTrialActive?: boolean; isTrialActive?: boolean;
isTrialExpired?: boolean; isTrialExpired?: boolean;
daysLeftInTrial?: number; daysLeftInTrial?: number;
resourceTypes?: ResourceTypeDefinition[]; // Custom resource types
} }
export type UserRole = 'superuser' | 'platform_manager' | 'platform_support' | 'owner' | 'manager' | 'staff' | 'resource' | 'customer'; export type UserRole = 'superuser' | 'platform_manager' | 'platform_support' | 'owner' | 'manager' | 'staff' | 'resource' | 'customer';
@@ -87,11 +90,25 @@ export interface User {
export type ResourceType = 'STAFF' | 'ROOM' | 'EQUIPMENT'; export type ResourceType = 'STAFF' | 'ROOM' | 'EQUIPMENT';
export type ResourceTypeCategory = 'STAFF' | 'OTHER';
export interface ResourceTypeDefinition {
id: string;
name: string; // User-facing name like "Stylist", "Massage Therapist", "Treatment Room"
description?: string; // Description of this resource type
category: ResourceTypeCategory; // STAFF (requires staff assignment) or OTHER
isDefault: boolean; // Cannot be deleted
iconName?: string; // Optional icon identifier
}
export interface Resource { export interface Resource {
id: string; id: string;
name: string; name: string;
type: ResourceType; type: ResourceType; // Legacy field - will be deprecated
typeId?: string; // New field - references ResourceTypeDefinition
userId?: string; userId?: string;
maxConcurrentEvents: number;
savedLaneCount?: number; // Remembered lane count when multilane is disabled
} }
export type AppointmentStatus = 'PENDING' | 'CONFIRMED' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW'; export type AppointmentStatus = 'PENDING' | 'CONFIRMED' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW';
@@ -147,6 +164,8 @@ export interface Service {
durationMinutes: number; durationMinutes: number;
price: number; price: number;
description: string; description: string;
displayOrder: number;
photos?: string[];
} }
export interface Metric { export interface Metric {

View File

@@ -1,6 +1,6 @@
{ {
"status": "failed", "status": "failed",
"failedTests": [ "failedTests": [
"7662eeffef95b745c0c7-05f7d22eaed6ca80a04d" "4ccd3b6df344f024c4e8-470435a1aee1bc432b30"
] ]
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -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"
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

175
smoothschedule/CLAUDE.md Normal file
View File

@@ -0,0 +1,175 @@
# SmoothSchedule Backend Development Guide
## Docker-Based Development
**IMPORTANT:** This project runs in Docker containers. Do NOT try to run Django commands directly on the host machine - they will fail due to missing environment variables and database connection.
### Running Django Commands
Always use Docker Compose to execute commands:
```bash
# Navigate to the smoothschedule directory first
cd /home/poduck/Desktop/smoothschedule2/smoothschedule
# Run migrations
docker compose -f docker-compose.local.yml exec django python manage.py migrate
# Run migrations for a specific app
docker compose -f docker-compose.local.yml exec django python manage.py migrate schedule
# Make migrations
docker compose -f docker-compose.local.yml exec django python manage.py makemigrations
# Create superuser
docker compose -f docker-compose.local.yml exec django python manage.py createsuperuser
# Run management commands
docker compose -f docker-compose.local.yml exec django python manage.py <command>
# Access Django shell
docker compose -f docker-compose.local.yml exec django python manage.py shell
# Run tests
docker compose -f docker-compose.local.yml exec django pytest
```
### Multi-Tenant Migrations
This is a multi-tenant app using django-tenants. To run migrations on a specific tenant schema:
```bash
# Run on a specific tenant (e.g., 'demo')
docker compose -f docker-compose.local.yml exec django python manage.py tenant_command migrate --schema=demo
# Run on public schema
docker compose -f docker-compose.local.yml exec django python manage.py migrate_schemas --shared
```
### Docker Services
```bash
# Start all services
docker compose -f docker-compose.local.yml up -d
# View logs
docker compose -f docker-compose.local.yml logs -f django
# Restart Django after code changes (usually auto-reloads)
docker compose -f docker-compose.local.yml restart django
# Rebuild after dependency changes
docker compose -f docker-compose.local.yml up -d --build
```
## Project Structure
### Critical Configuration Files
```
smoothschedule/
├── docker-compose.local.yml # Local development Docker config
├── docker-compose.production.yml # Production Docker config
├── .envs/
│ ├── .local/
│ │ ├── .django # Django env vars (SECRET_KEY, DEBUG, etc.)
│ │ └── .postgres # Database credentials
│ └── .production/
│ ├── .django
│ └── .postgres
├── config/
│ ├── settings/
│ │ ├── base.py # Base settings (shared)
│ │ ├── local.py # Local dev settings (imports multitenancy.py)
│ │ ├── production.py # Production settings
│ │ ├── multitenancy.py # Multi-tenant configuration
│ │ └── test.py # Test settings
│ └── urls.py # Main URL configuration
├── compose/
│ ├── local/django/
│ │ ├── Dockerfile # Local Django container
│ │ └── start # Startup script
│ └── production/
│ ├── django/
│ ├── postgres/
│ └── traefik/
```
### Django Apps
```
smoothschedule/smoothschedule/
├── users/ # User management, authentication
│ ├── models.py # User model with roles
│ ├── api_views.py # Auth endpoints, user API
│ └── migrations/
├── schedule/ # Core scheduling functionality
│ ├── models.py # Resource, Event, Service, Participant
│ ├── serializers.py # DRF serializers
│ ├── views.py # ViewSets for API
│ ├── services.py # AvailabilityService
│ └── migrations/
├── tenants/ # Multi-tenancy (Business/Tenant models)
│ ├── models.py # Tenant, Domain models
│ └── migrations/
```
### API Endpoints
Base URL: `http://lvh.me:8000/api/`
- `/api/resources/` - Resource CRUD
- `/api/events/` - Event/Appointment CRUD
- `/api/services/` - Service CRUD
- `/api/customers/` - Customer listing
- `/api/auth/login/` - Authentication
- `/api/auth/logout/` - Logout
- `/api/users/me/` - Current user info
- `/api/business/` - Business settings
## Local Development URLs
- **Backend API:** `http://lvh.me:8000`
- **Frontend:** `http://demo.lvh.me:5173` (business subdomain)
- **Platform Frontend:** `http://platform.lvh.me:5173`
Note: `lvh.me` resolves to `127.0.0.1` and allows subdomain-based multi-tenancy with cookies.
## Database
- **Type:** PostgreSQL with django-tenants
- **Public Schema:** Shared tables (tenants, domains, platform users)
- **Tenant Schemas:** Per-business data (resources, events, customers)
## Common Issues
### 500 Error with No CORS Headers
When Django crashes (500 error), CORS headers aren't sent. Check Django logs:
```bash
docker compose -f docker-compose.local.yml logs django --tail=100
```
### Missing Column/Table Errors
Run migrations:
```bash
docker compose -f docker-compose.local.yml exec django python manage.py migrate
```
### ModuleNotFoundError / ImportError
You're trying to run Python directly instead of through Docker. Use `docker compose exec`.
## Key Models
### Resource (schedule/models.py)
- `name`, `type` (STAFF/ROOM/EQUIPMENT)
- `max_concurrent_events` - concurrency limit (1=exclusive, >1=multilane, 0=unlimited)
- `saved_lane_count` - remembers lane count when multilane disabled
- `buffer_duration` - time between events
### Event (schedule/models.py)
- `title`, `start_time`, `end_time`, `status`
- Links to resources/customers via `Participant` model
### User (users/models.py)
- Roles: `superuser`, `platform_manager`, `platform_support`, `owner`, `manager`, `staff`, `resource`, `customer`
- `business_subdomain` - which tenant they belong to

View File

@@ -10,8 +10,11 @@ from drf_spectacular.views import SpectacularAPIView
from drf_spectacular.views import SpectacularSwaggerView from drf_spectacular.views import SpectacularSwaggerView
from rest_framework.authtoken.views import obtain_auth_token from rest_framework.authtoken.views import obtain_auth_token
from smoothschedule.users.api_views import current_user_view, logout_view, send_verification_email, verify_email from smoothschedule.users.api_views import (
from schedule.api_views import current_business_view current_user_view, logout_view, send_verification_email, verify_email,
hijack_acquire_view, hijack_release_view
)
from schedule.api_views import current_business_view, update_business_view
urlpatterns = [ urlpatterns = [
# Django Admin, use {% url 'admin:index' %} # Django Admin, use {% url 'admin:index' %}
@@ -19,6 +22,8 @@ urlpatterns = [
# User management # User management
path("users/", include("smoothschedule.users.urls", namespace="users")), path("users/", include("smoothschedule.users.urls", namespace="users")),
path("accounts/", include("allauth.urls")), path("accounts/", include("allauth.urls")),
# Django Hijack (masquerade) - for admin interface
path("hijack/", include("hijack.urls")),
# Your stuff: custom urls includes go here # Your stuff: custom urls includes go here
# ... # ...
# Media files # Media files
@@ -39,8 +44,12 @@ urlpatterns += [
path("api/auth/logout/", logout_view, name="logout"), path("api/auth/logout/", logout_view, name="logout"),
path("api/auth/email/verify/send/", send_verification_email, name="send_verification_email"), path("api/auth/email/verify/send/", send_verification_email, name="send_verification_email"),
path("api/auth/email/verify/", verify_email, name="verify_email"), path("api/auth/email/verify/", verify_email, name="verify_email"),
# Hijack (masquerade) API
path("api/auth/hijack/acquire/", hijack_acquire_view, name="hijack_acquire"),
path("api/auth/hijack/release/", hijack_release_view, name="hijack_release"),
# Business API # Business API
path("api/business/current/", current_business_view, name="current_business"), path("api/business/current/", current_business_view, name="current_business"),
path("api/business/current/update/", update_business_view, name="update_business"),
# API Docs # API Docs
path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"), path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"),
path( path(

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.8 on 2025-11-28 03:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_tierlimit'),
]
operations = [
migrations.AddField(
model_name='tenant',
name='logo',
field=models.ImageField(blank=True, help_text='Business logo (recommended: 500x500px square or 500x200px wide)', null=True, upload_to='tenant_logos/'),
),
migrations.AddField(
model_name='tenant',
name='logo_display_mode',
field=models.CharField(choices=[('logo-only', 'Logo Only'), ('text-only', 'Text Only'), ('logo-and-text', 'Logo and Text')], default='text-only', help_text='How to display branding in sidebar and headers', max_length=20),
),
migrations.AddField(
model_name='tenant',
name='primary_color',
field=models.CharField(default='#2563eb', help_text='Primary brand color (hex format, e.g., #2563eb)', max_length=7),
),
migrations.AddField(
model_name='tenant',
name='secondary_color',
field=models.CharField(default='#0ea5e9', help_text='Secondary brand color (hex format, e.g., #0ea5e9)', max_length=7),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.8 on 2025-11-28 03:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0003_tenant_logo_tenant_logo_display_mode_and_more'),
]
operations = [
migrations.AddField(
model_name='tenant',
name='email_logo',
field=models.ImageField(blank=True, help_text='Email logo (recommended: 600x200px wide, PNG with transparent background)', null=True, upload_to='tenant_email_logos/'),
),
migrations.AlterField(
model_name='tenant',
name='logo',
field=models.ImageField(blank=True, help_text='Website logo (recommended: 500x500px square or 500x200px wide, PNG with transparent background)', null=True, upload_to='tenant_logos/'),
),
]

View File

@@ -15,7 +15,7 @@ class Tenant(TenantMixin):
""" """
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
created_on = models.DateField(auto_now_add=True) created_on = models.DateField(auto_now_add=True)
# Subscription & billing # Subscription & billing
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
subscription_tier = models.CharField( subscription_tier = models.CharField(
@@ -28,11 +28,45 @@ class Tenant(TenantMixin):
], ],
default='FREE' default='FREE'
) )
# Feature flags # Feature flags
max_users = models.IntegerField(default=5) max_users = models.IntegerField(default=5)
max_resources = models.IntegerField(default=10) max_resources = models.IntegerField(default=10)
# Branding
logo = models.ImageField(
upload_to='tenant_logos/',
null=True,
blank=True,
help_text="Website logo (recommended: 500x500px square or 500x200px wide, PNG with transparent background)"
)
email_logo = models.ImageField(
upload_to='tenant_email_logos/',
null=True,
blank=True,
help_text="Email logo (recommended: 600x200px wide, PNG with transparent background)"
)
logo_display_mode = models.CharField(
max_length=20,
choices=[
('logo-only', 'Logo Only'),
('text-only', 'Text Only'),
('logo-and-text', 'Logo and Text'),
],
default='text-only',
help_text="How to display branding in sidebar and headers"
)
primary_color = models.CharField(
max_length=7,
default='#2563eb',
help_text="Primary brand color (hex format, e.g., #2563eb)"
)
secondary_color = models.CharField(
max_length=7,
default='#0ea5e9',
help_text="Secondary brand color (hex format, e.g., #0ea5e9)"
)
# Metadata # Metadata
contact_email = models.EmailField(blank=True) contact_email = models.EmailField(blank=True)
phone = models.CharField(max_length=20, blank=True) phone = models.CharField(max_length=20, blank=True)

View File

@@ -33,7 +33,7 @@ def can_hijack(hijacker, hijacked):
- Always validate tenant boundaries for tenant-scoped roles - Always validate tenant boundaries for tenant-scoped roles
- Log all hijack attempts (success and failure) for audit - Log all hijack attempts (success and failure) for audit
""" """
from users.models import User from smoothschedule.users.models import User
# Safety check: can't hijack yourself # Safety check: can't hijack yourself
if hijacker.id == hijacked.id: if hijacker.id == hijacked.id:
@@ -60,16 +60,17 @@ def can_hijack(hijacker, hijacked):
if hijacker.role == User.Role.PLATFORM_SALES: if hijacker.role == User.Role.PLATFORM_SALES:
return hijacked.is_temporary return hijacked.is_temporary
# Rule 4: TENANT_OWNER can hijack staff within their own tenant # Rule 4: TENANT_OWNER can hijack managers, staff, and customers within their own tenant
if hijacker.role == User.Role.TENANT_OWNER: if hijacker.role == User.Role.TENANT_OWNER:
# Must be in same tenant # Must be in same tenant
if not hijacker.tenant or not hijacked.tenant: if not hijacker.tenant or not hijacked.tenant:
return False return False
if hijacker.tenant.id != hijacked.tenant.id: if hijacker.tenant.id != hijacked.tenant.id:
return False return False
# Can only hijack staff and customers, not other owners/managers # Can hijack managers, staff, and customers (not other owners)
return hijacked.role in [ return hijacked.role in [
User.Role.TENANT_MANAGER,
User.Role.TENANT_STAFF, User.Role.TENANT_STAFF,
User.Role.CUSTOMER, User.Role.CUSTOMER,
] ]
@@ -112,7 +113,7 @@ def get_hijackable_users(hijacker):
Returns: Returns:
QuerySet: Users that can be hijacked by this user QuerySet: Users that can be hijacked by this user
""" """
from users.models import User from smoothschedule.users.models import User
# Start with all users except self # Start with all users except self
qs = User.objects.exclude(id=hijacker.id) qs = User.objects.exclude(id=hijacker.id)
@@ -136,13 +137,13 @@ def get_hijackable_users(hijacker):
return qs.filter(is_temporary=True) return qs.filter(is_temporary=True)
elif hijacker.role == User.Role.TENANT_OWNER: elif hijacker.role == User.Role.TENANT_OWNER:
# Only staff in same tenant # Managers, staff, and customers in same tenant
if not hijacker.tenant: if not hijacker.tenant:
return qs.none() return qs.none()
return qs.filter( return qs.filter(
tenant=hijacker.tenant, tenant=hijacker.tenant,
role__in=[User.Role.TENANT_STAFF, User.Role.CUSTOMER] role__in=[User.Role.TENANT_MANAGER, User.Role.TENANT_STAFF, User.Role.CUSTOMER]
) )
else: else:

View File

@@ -1,6 +1,9 @@
""" """
API views for business/tenant management API views for business/tenant management
""" """
import base64
import uuid
from django.core.files.base import ContentFile
from rest_framework import status from rest_framework import status
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
@@ -39,10 +42,119 @@ def current_business_view(request):
'tier': tenant.subscription_tier, 'tier': tenant.subscription_tier,
'status': 'active' if tenant.is_active else 'inactive', 'status': 'active' if tenant.is_active else 'inactive',
'created_at': tenant.created_on.isoformat() if tenant.created_on else None, 'created_at': tenant.created_on.isoformat() if tenant.created_on else None,
# Optional fields with defaults # Branding fields from Tenant model
'primary_color': '#3B82F6', # Blue-500 default 'primary_color': tenant.primary_color,
'secondary_color': '#1E40AF', # Blue-800 default 'secondary_color': tenant.secondary_color,
'logo_url': None, 'logo_url': request.build_absolute_uri(tenant.logo.url) if tenant.logo else None,
'email_logo_url': request.build_absolute_uri(tenant.email_logo.url) if tenant.email_logo else None,
'logo_display_mode': tenant.logo_display_mode,
# Other optional fields with defaults
'whitelabel_enabled': False,
'resources_can_reschedule': False,
'require_payment_method_to_book': False,
'cancellation_window_hours': 24,
'late_cancellation_fee_percent': 0,
'initial_setup_complete': False,
'website_pages': {},
'customer_dashboard_content': [],
}
return Response(business_data, status=status.HTTP_200_OK)
@api_view(['PATCH'])
@permission_classes([IsAuthenticated])
def update_business_view(request):
"""
Update business (tenant) settings for the authenticated user
PATCH /api/business/current/update/
Only business owners can update settings
"""
user = request.user
tenant = user.tenant
# Platform users don't have a tenant
if not tenant:
return Response({'error': 'No business found'}, status=status.HTTP_404_NOT_FOUND)
# Only owners can update business settings
if user.role.lower() != 'tenant_owner':
return Response({'error': 'Only business owners can update settings'}, status=status.HTTP_403_FORBIDDEN)
# Update fields if provided in request
if 'name' in request.data:
tenant.name = request.data['name']
if 'primary_color' in request.data:
tenant.primary_color = request.data['primary_color']
if 'secondary_color' in request.data:
tenant.secondary_color = request.data['secondary_color']
if 'logo_display_mode' in request.data:
tenant.logo_display_mode = request.data['logo_display_mode']
# Handle logo uploads (base64 data URLs)
if 'logo_url' in request.data:
logo_data = request.data['logo_url']
if logo_data and logo_data.startswith('data:image'):
# Extract base64 data and file extension
format_str, imgstr = logo_data.split(';base64,')
ext = format_str.split('/')[-1]
# Decode base64 and create Django file
data = ContentFile(base64.b64decode(imgstr), name=f'logo_{uuid.uuid4()}.{ext}')
# Delete old logo if exists
if tenant.logo:
tenant.logo.delete(save=False)
tenant.logo = data
elif logo_data is None or logo_data == '':
# Remove logo if set to None or empty string
if tenant.logo:
tenant.logo.delete(save=False)
tenant.logo = None
if 'email_logo_url' in request.data:
email_logo_data = request.data['email_logo_url']
if email_logo_data and email_logo_data.startswith('data:image'):
# Extract base64 data and file extension
format_str, imgstr = email_logo_data.split(';base64,')
ext = format_str.split('/')[-1]
# Decode base64 and create Django file
data = ContentFile(base64.b64decode(imgstr), name=f'email_logo_{uuid.uuid4()}.{ext}')
# Delete old email logo if exists
if tenant.email_logo:
tenant.email_logo.delete(save=False)
tenant.email_logo = data
elif email_logo_data is None or email_logo_data == '':
# Remove email logo if set to None or empty string
if tenant.email_logo:
tenant.email_logo.delete(save=False)
tenant.email_logo = None
# Save the tenant
tenant.save()
# Return updated business data
subdomain = None
primary_domain = tenant.domains.filter(is_primary=True).first()
if primary_domain:
domain_parts = primary_domain.domain.split('.')
if len(domain_parts) > 0:
subdomain = domain_parts[0]
business_data = {
'id': tenant.id,
'name': tenant.name,
'subdomain': subdomain or tenant.schema_name,
'tier': tenant.subscription_tier,
'status': 'active' if tenant.is_active else 'inactive',
'created_at': tenant.created_on.isoformat() if tenant.created_on else None,
'primary_color': tenant.primary_color,
'secondary_color': tenant.secondary_color,
'logo_url': request.build_absolute_uri(tenant.logo.url) if tenant.logo else None,
'email_logo_url': request.build_absolute_uri(tenant.email_logo.url) if tenant.email_logo else None,
'logo_display_mode': tenant.logo_display_mode,
'whitelabel_enabled': False, 'whitelabel_enabled': False,
'resources_can_reschedule': False, 'resources_can_reschedule': False,
'require_payment_method_to_book': False, 'require_payment_method_to_book': False,

View File

@@ -0,0 +1,22 @@
# Generated manually
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('schedule', '0004_rename_schedule_se_is_acti_idx_schedule_se_is_acti_8c055e_idx'),
]
operations = [
migrations.AddField(
model_name='resource',
name='saved_lane_count',
field=models.PositiveIntegerField(
blank=True,
help_text='Remembered lane count when multilane is disabled',
null=True,
),
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 5.2.8 on 2025-11-28 02:59
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('schedule', '0005_resource_saved_lane_count'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='resource',
name='user',
field=models.ForeignKey(blank=True, help_text='Link to User account for STAFF type resources', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_resources', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,40 @@
# Generated by Django 5.2.8 on 2025-11-28 03:37
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('schedule', '0006_add_user_to_resource'),
]
operations = [
migrations.AlterField(
model_name='resource',
name='type',
field=models.CharField(choices=[('STAFF', 'Staff Member'), ('ROOM', 'Room'), ('EQUIPMENT', 'Equipment')], default='STAFF', help_text='DEPRECATED: Use resource_type instead', max_length=20),
),
migrations.CreateModel(
name='ResourceType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text="User-facing name like 'Stylist' or 'Treatment Room'", max_length=100)),
('category', models.CharField(choices=[('STAFF', 'Staff'), ('OTHER', 'Other')], default='OTHER', help_text='STAFF types require staff assignment, OTHER types do not', max_length=10)),
('is_default', models.BooleanField(default=False, help_text="Default types cannot be deleted (e.g., 'Staff')")),
('icon_name', models.CharField(blank=True, help_text='Optional icon identifier', max_length=50)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name'],
'indexes': [models.Index(fields=['category', 'name'], name='schedule_re_categor_3040dd_idx')],
},
),
migrations.AddField(
model_name='resource',
name='resource_type',
field=models.ForeignKey(blank=True, help_text='Custom resource type definition', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='resources', to='schedule.resourcetype'),
),
]

View File

@@ -0,0 +1,51 @@
# Generated by Django 5.2.8 on 2025-11-28 03:43
from django.db import migrations
def create_default_resource_types(apps, schema_editor):
"""Create default resource types for all tenants"""
ResourceType = apps.get_model('schedule', 'ResourceType')
# Create default types if they don't exist
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'
}
)
def reverse_default_resource_types(apps, schema_editor):
"""Remove default resource types (for rollback)"""
ResourceType = apps.get_model('schedule', 'ResourceType')
ResourceType.objects.filter(is_default=True).delete()
class Migration(migrations.Migration):
dependencies = [
('schedule', '0007_alter_resource_type_resourcetype_and_more'),
]
operations = [
migrations.RunPython(create_default_resource_types, reverse_default_resource_types),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 5.2.8 on 2025-11-28 05:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('schedule', '0008_create_default_resource_types'),
]
operations = [
migrations.AlterModelOptions(
name='service',
options={'ordering': ['display_order', 'name']},
),
migrations.RemoveIndex(
model_name='service',
name='schedule_se_is_acti_8c055e_idx',
),
migrations.AddField(
model_name='service',
name='display_order',
field=models.PositiveIntegerField(default=0, help_text='Order in which services appear in menus (lower = first)'),
),
migrations.AddIndex(
model_name='service',
index=models.Index(fields=['is_active', 'display_order'], name='schedule_se_is_acti_d33934_idx'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.8 on 2025-11-28 05:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('schedule', '0009_add_service_display_order'),
]
operations = [
migrations.AddField(
model_name='resourcetype',
name='description',
field=models.TextField(blank=True, help_text='Description of this resource type'),
),
migrations.AddField(
model_name='resourcetype',
name='photos',
field=models.JSONField(blank=True, default=list, help_text='List of photo URLs in display order'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2025-11-28 06:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('schedule', '0010_add_resourcetype_description_photos'),
]
operations = [
migrations.AddField(
model_name='service',
name='photos',
field=models.JSONField(blank=True, default=list, help_text='List of photo URLs in display order'),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.2.8 on 2025-11-28 06:09
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('schedule', '0011_add_photos_to_service'),
]
operations = [
migrations.RemoveField(
model_name='resourcetype',
name='photos',
),
]

View File

@@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.utils import timezone from django.utils import timezone
from decimal import Decimal from decimal import Decimal
from django.core.exceptions import ValidationError
class Service(models.Model): class Service(models.Model):
@@ -21,18 +22,75 @@ class Service(models.Model):
decimal_places=2, decimal_places=2,
default=Decimal('0.00') default=Decimal('0.00')
) )
display_order = models.PositiveIntegerField(
default=0,
help_text="Order in which services appear in menus (lower = first)"
)
photos = models.JSONField(
default=list,
blank=True,
help_text="List of photo URLs in display order"
)
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
ordering = ['name'] ordering = ['display_order', 'name']
indexes = [models.Index(fields=['is_active', 'name'])] indexes = [models.Index(fields=['is_active', 'display_order'])]
def __str__(self): def __str__(self):
return f"{self.name} ({self.duration} min - ${self.price})" return f"{self.name} ({self.duration} min - ${self.price})"
class ResourceType(models.Model):
"""
Custom resource type definitions (e.g., "Hair Stylist", "Massage Room").
Businesses can create their own types instead of using hardcoded STAFF/ROOM/EQUIPMENT.
"""
class Category(models.TextChoices):
STAFF = 'STAFF', 'Staff' # Requires staff member assignment
OTHER = 'OTHER', 'Other' # No staff assignment needed
name = models.CharField(max_length=100, help_text="User-facing name like 'Stylist' or 'Treatment Room'")
description = models.TextField(blank=True, help_text="Description of this resource type")
category = models.CharField(
max_length=10,
choices=Category.choices,
default=Category.OTHER,
help_text="STAFF types require staff assignment, OTHER types do not"
)
is_default = models.BooleanField(
default=False,
help_text="Default types cannot be deleted (e.g., 'Staff')"
)
icon_name = models.CharField(max_length=50, blank=True, help_text="Optional icon identifier")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['name']
indexes = [models.Index(fields=['category', 'name'])]
def __str__(self):
return f"{self.name} ({self.get_category_display()})"
def clean(self):
"""Prevent deletion of default types"""
if self.is_default and self.pk:
# Check if being deleted
if not ResourceType.objects.filter(pk=self.pk).exists():
raise ValidationError("Cannot delete default resource types.")
def delete(self, *args, **kwargs):
"""Prevent deletion of default types and types in use"""
if self.is_default:
raise ValidationError("Cannot delete default resource types.")
if self.resources.exists():
raise ValidationError(f"Cannot delete resource type '{self.name}' because it is in use by {self.resources.count()} resource(s).")
super().delete(*args, **kwargs)
class Resource(models.Model): class Resource(models.Model):
""" """
A bookable resource with configurable concurrency. A bookable resource with configurable concurrency.
@@ -48,10 +106,32 @@ class Resource(models.Model):
EQUIPMENT = 'EQUIPMENT', 'Equipment' EQUIPMENT = 'EQUIPMENT', 'Equipment'
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
# NEW: Custom resource type (preferred)
resource_type = models.ForeignKey(
ResourceType,
on_delete=models.PROTECT, # Cannot delete type if resources use it
related_name='resources',
null=True, # For migration compatibility
blank=True,
help_text="Custom resource type definition"
)
# LEGACY: Hardcoded type (deprecated, kept for backwards compatibility)
type = models.CharField( type = models.CharField(
max_length=20, max_length=20,
choices=Type.choices, choices=Type.choices,
default=Type.STAFF default=Type.STAFF,
help_text="DEPRECATED: Use resource_type instead"
)
user = models.ForeignKey(
'users.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='staff_resources',
help_text="Link to User account for STAFF type resources"
) )
description = models.TextField(blank=True) description = models.TextField(blank=True)
max_concurrent_events = models.PositiveIntegerField( max_concurrent_events = models.PositiveIntegerField(
@@ -63,6 +143,11 @@ class Resource(models.Model):
default=timezone.timedelta(0), default=timezone.timedelta(0),
help_text="Time buffer before/after events. Buffers consume capacity." help_text="Time buffer before/after events. Buffers consume capacity."
) )
saved_lane_count = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Remembered lane count when multilane is disabled"
)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)

View File

@@ -3,11 +3,38 @@ DRF Serializers for Schedule App with Availability Validation
""" """
from rest_framework import serializers from rest_framework import serializers
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from .models import Resource, Event, Participant, Service from django.core.exceptions import ValidationError as DjangoValidationError
from .models import Resource, Event, Participant, Service, ResourceType
from .services import AvailabilityService from .services import AvailabilityService
from smoothschedule.users.models import User from smoothschedule.users.models import User
class ResourceTypeSerializer(serializers.ModelSerializer):
"""Serializer for custom resource types"""
class Meta:
model = ResourceType
fields = ['id', 'name', 'description', 'category', 'is_default', 'icon_name', 'created_at', 'updated_at']
read_only_fields = ['id', 'created_at', 'updated_at', 'is_default']
def validate(self, attrs):
# If updating, check if trying to modify is_default
if self.instance and self.instance.is_default:
if 'name' in attrs and attrs['name'] != self.instance.name:
# Allow renaming default types
pass
return attrs
def delete(self, instance):
"""Validate before deletion"""
if instance.is_default:
raise serializers.ValidationError("Cannot delete default resource types.")
if instance.resources.exists():
raise serializers.ValidationError(
f"Cannot delete resource type '{instance.name}' because it is in use by {instance.resources.count()} resource(s)."
)
class CustomerSerializer(serializers.ModelSerializer): class CustomerSerializer(serializers.ModelSerializer):
"""Serializer for Customer (User with role=CUSTOMER)""" """Serializer for Customer (User with role=CUSTOMER)"""
name = serializers.SerializerMethodField() name = serializers.SerializerMethodField()
@@ -20,13 +47,14 @@ class CustomerSerializer(serializers.ModelSerializer):
city = serializers.SerializerMethodField() city = serializers.SerializerMethodField()
state = serializers.SerializerMethodField() state = serializers.SerializerMethodField()
zip = serializers.SerializerMethodField() zip = serializers.SerializerMethodField()
user_data = serializers.SerializerMethodField()
class Meta: class Meta:
model = User model = User
fields = [ fields = [
'id', 'name', 'email', 'phone', 'city', 'state', 'zip', 'id', 'name', 'email', 'phone', 'city', 'state', 'zip',
'total_spend', 'last_visit', 'status', 'avatar_url', 'tags', 'total_spend', 'last_visit', 'status', 'avatar_url', 'tags',
'user_id', 'user_id', 'user_data',
] ]
read_only_fields = ['id', 'email'] read_only_fields = ['id', 'email']
@@ -59,6 +87,41 @@ class CustomerSerializer(serializers.ModelSerializer):
def get_zip(self, obj): def get_zip(self, obj):
return '' return ''
def get_user_data(self, obj):
"""Return user data needed for masquerading"""
return {
'id': obj.id,
'username': obj.username,
'name': obj.full_name,
'email': obj.email,
'role': 'customer',
}
class StaffSerializer(serializers.ModelSerializer):
"""Serializer for Staff members (Users with staff roles)"""
name = serializers.SerializerMethodField()
role = serializers.SerializerMethodField()
class Meta:
model = User
fields = [
'id', 'username', 'name', 'email', 'phone', 'role',
]
read_only_fields = fields
def get_name(self, obj):
return obj.full_name
def get_role(self, obj):
# Map database roles to frontend roles
role_mapping = {
'TENANT_OWNER': 'owner',
'TENANT_MANAGER': 'manager',
'TENANT_STAFF': 'staff',
}
return role_mapping.get(obj.role, obj.role.lower())
class ServiceSerializer(serializers.ModelSerializer): class ServiceSerializer(serializers.ModelSerializer):
"""Serializer for Service model""" """Serializer for Service model"""
@@ -68,7 +131,7 @@ class ServiceSerializer(serializers.ModelSerializer):
model = Service model = Service
fields = [ fields = [
'id', 'name', 'description', 'duration', 'duration_minutes', 'id', 'name', 'description', 'duration', 'duration_minutes',
'price', 'is_active', 'created_at', 'updated_at', 'price', 'display_order', 'photos', 'is_active', 'created_at', 'updated_at',
] ]
read_only_fields = ['created_at', 'updated_at'] read_only_fields = ['created_at', 'updated_at']
@@ -76,16 +139,25 @@ class ServiceSerializer(serializers.ModelSerializer):
class ResourceSerializer(serializers.ModelSerializer): class ResourceSerializer(serializers.ModelSerializer):
"""Serializer for Resource model""" """Serializer for Resource model"""
capacity_description = serializers.SerializerMethodField() capacity_description = serializers.SerializerMethodField()
user_id = serializers.IntegerField(source='user.id', read_only=True, allow_null=True)
user_name = serializers.CharField(source='user.full_name', read_only=True, allow_null=True)
user = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(),
required=False,
allow_null=True,
write_only=True
)
class Meta: class Meta:
model = Resource model = Resource
fields = [ fields = [
'id', 'name', 'type', 'description', 'max_concurrent_events', 'id', 'name', 'type', 'user', 'user_id', 'user_name',
'description', 'max_concurrent_events',
'buffer_duration', 'is_active', 'capacity_description', 'buffer_duration', 'is_active', 'capacity_description',
'saved_lane_count', 'created_at', 'updated_at', 'saved_lane_count', 'created_at', 'updated_at',
] ]
read_only_fields = ['created_at', 'updated_at'] read_only_fields = ['created_at', 'updated_at']
def get_capacity_description(self, obj): def get_capacity_description(self, obj):
if obj.max_concurrent_events == 0: if obj.max_concurrent_events == 0:
return "Unlimited capacity" return "Unlimited capacity"

View File

@@ -3,16 +3,21 @@ Schedule App URLs
""" """
from django.urls import path, include from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .views import ResourceViewSet, EventViewSet, ParticipantViewSet, CustomerViewSet, ServiceViewSet from .views import (
ResourceViewSet, EventViewSet, ParticipantViewSet,
CustomerViewSet, ServiceViewSet, StaffViewSet, ResourceTypeViewSet
)
# Create router and register viewsets # Create router and register viewsets
router = DefaultRouter() router = DefaultRouter()
router.register(r'resource-types', ResourceTypeViewSet, basename='resourcetype')
router.register(r'resources', ResourceViewSet, basename='resource') router.register(r'resources', ResourceViewSet, basename='resource')
router.register(r'appointments', EventViewSet, basename='appointment') # Alias for frontend router.register(r'appointments', EventViewSet, basename='appointment') # Alias for frontend
router.register(r'events', EventViewSet, basename='event') router.register(r'events', EventViewSet, basename='event')
router.register(r'participants', ParticipantViewSet, basename='participant') router.register(r'participants', ParticipantViewSet, basename='participant')
router.register(r'customers', CustomerViewSet, basename='customer') router.register(r'customers', CustomerViewSet, basename='customer')
router.register(r'services', ServiceViewSet, basename='service') router.register(r'services', ServiceViewSet, basename='service')
router.register(r'staff', StaffViewSet, basename='staff')
# URL patterns # URL patterns
urlpatterns = [ urlpatterns = [

View File

@@ -6,13 +6,60 @@ API endpoints for Resources and Events with quota enforcement.
from rest_framework import viewsets, status from rest_framework import viewsets, status
from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from .models import Resource, Event, Participant from rest_framework.decorators import action
from .serializers import ResourceSerializer, EventSerializer, ParticipantSerializer, CustomerSerializer, ServiceSerializer from django.core.exceptions import ValidationError as DjangoValidationError
from .models import Resource, Event, Participant, ResourceType
from .serializers import (
ResourceSerializer, EventSerializer, ParticipantSerializer,
CustomerSerializer, ServiceSerializer, ResourceTypeSerializer, StaffSerializer
)
from .models import Service from .models import Service
from core.permissions import HasQuota from core.permissions import HasQuota
from smoothschedule.users.models import User from smoothschedule.users.models import User
class ResourceTypeViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing custom Resource Types.
Permissions:
- Must be authenticated
- Only owners/managers can create/update/delete
Functionality:
- List all resource types
- Create new custom types
- Update existing types (except is_default flag)
- Delete types (only if not default and not in use)
"""
queryset = ResourceType.objects.all()
serializer_class = ResourceTypeSerializer
permission_classes = [AllowAny] # TODO: Change to IsAuthenticated for production
ordering = ['name']
def destroy(self, request, *args, **kwargs):
"""Override destroy to add validation"""
instance = self.get_object()
# Check if default
if instance.is_default:
return Response(
{'error': 'Cannot delete default resource types.'},
status=status.HTTP_400_BAD_REQUEST
)
# Check if in use
if instance.resources.exists():
return Response(
{
'error': f"Cannot delete resource type '{instance.name}' because it is in use by {instance.resources.count()} resource(s)."
},
status=status.HTTP_400_BAD_REQUEST
)
return super().destroy(request, *args, **kwargs)
class ResourceViewSet(viewsets.ModelViewSet): class ResourceViewSet(viewsets.ModelViewSet):
""" """
API endpoint for managing Resources. API endpoint for managing Resources.
@@ -198,8 +245,8 @@ class ServiceViewSet(viewsets.ModelViewSet):
filterset_fields = ['is_active'] filterset_fields = ['is_active']
search_fields = ['name', 'description'] search_fields = ['name', 'description']
ordering_fields = ['name', 'price', 'duration', 'created_at'] ordering_fields = ['name', 'price', 'duration', 'display_order', 'created_at']
ordering = ['name'] ordering = ['display_order', 'name']
def get_queryset(self): def get_queryset(self):
"""Return services, optionally including inactive ones.""" """Return services, optionally including inactive ones."""
@@ -211,3 +258,71 @@ class ServiceViewSet(viewsets.ModelViewSet):
queryset = queryset.filter(is_active=True) queryset = queryset.filter(is_active=True)
return queryset return queryset
@action(detail=False, methods=['post'])
def reorder(self, request):
"""
Bulk update service display order.
Expects: { "order": [1, 3, 2, 5, 4] }
Where the list contains service IDs in the desired display order.
"""
order = request.data.get('order', [])
if not isinstance(order, list):
return Response(
{'error': 'order must be a list of service IDs'},
status=status.HTTP_400_BAD_REQUEST
)
# Update display_order for each service
for index, service_id in enumerate(order):
Service.objects.filter(id=service_id).update(display_order=index)
return Response({'status': 'ok', 'updated': len(order)})
class StaffViewSet(viewsets.ReadOnlyModelViewSet):
"""
API endpoint for listing staff members (Users who can be assigned to resources).
Staff members are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF.
This endpoint is read-only for assigning staff to resources.
"""
serializer_class = StaffSerializer
# TODO: Re-enable authentication for production
permission_classes = [AllowAny]
search_fields = ['email', 'first_name', 'last_name']
ordering_fields = ['email', 'first_name', 'last_name']
ordering = ['first_name', 'last_name']
def get_queryset(self):
"""
Return staff members for the current tenant.
Staff are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF.
"""
from django.db.models import Q
queryset = User.objects.filter(
Q(role=User.Role.TENANT_OWNER) |
Q(role=User.Role.TENANT_MANAGER) |
Q(role=User.Role.TENANT_STAFF)
).filter(is_active=True)
# Filter by tenant if user is authenticated and has a tenant
# TODO: Re-enable this when authentication is enabled
# if self.request.user.is_authenticated and self.request.user.tenant:
# queryset = queryset.filter(tenant=self.request.user.tenant)
# Apply search filter if provided
search = self.request.query_params.get('search')
if search:
queryset = queryset.filter(
Q(email__icontains=search) |
Q(first_name__icontains=search) |
Q(last_name__icontains=search)
)
return queryset

View File

@@ -5,12 +5,15 @@ import secrets
from django.core.mail import send_mail from django.core.mail import send_mail
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django.shortcuts import get_object_or_404
from rest_framework import status from rest_framework import status
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.authtoken.models import Token
from .models import User, EmailVerificationToken from .models import User, EmailVerificationToken
from core.permissions import can_hijack
@api_view(['GET']) @api_view(['GET'])
@@ -160,3 +163,169 @@ def verify_email(request):
token.user.save(update_fields=['email_verified']) token.user.save(update_fields=['email_verified'])
return Response({"detail": "Email verified successfully."}, status=status.HTTP_200_OK) return Response({"detail": "Email verified successfully."}, status=status.HTTP_200_OK)
def _get_user_data(user):
"""Helper to get user data for API responses."""
# Get business info if user has a tenant
business_name = None
business_subdomain = None
if user.tenant:
business_name = user.tenant.name
primary_domain = user.tenant.domains.filter(is_primary=True).first()
if primary_domain:
business_subdomain = primary_domain.domain.split('.')[0]
else:
business_subdomain = user.tenant.schema_name
# Map database roles to frontend roles
role_mapping = {
'superuser': 'superuser',
'platform_manager': 'platform_manager',
'platform_sales': 'platform_sales',
'platform_support': 'platform_support',
'tenant_owner': 'owner',
'tenant_manager': 'manager',
'tenant_staff': 'staff',
'customer': 'customer',
}
frontend_role = role_mapping.get(user.role.lower(), user.role.lower())
return {
'id': user.id,
'username': user.username,
'email': user.email,
'name': user.full_name,
'role': frontend_role,
'avatar_url': None,
'email_verified': user.email_verified,
'is_staff': user.is_staff,
'is_superuser': user.is_superuser,
'business': user.tenant_id,
'business_name': business_name,
'business_subdomain': business_subdomain,
}
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def hijack_acquire_view(request):
"""
Masquerade as another user (hijack).
POST /api/auth/hijack/acquire/
Body: { "user_pk": <user_id> }
Returns new auth token for the hijacked user along with the hijack history.
"""
# Debug logging
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Hijack API called. User authenticated: {request.user.is_authenticated}, User: {request.user}")
user_pk = request.data.get('user_pk')
if not user_pk:
return Response({"error": "user_pk is required"}, status=status.HTTP_400_BAD_REQUEST)
hijacker = request.user
hijacked = get_object_or_404(User, pk=user_pk)
logger.warning(f"Hijack attempt: hijacker={hijacker.email} (role={hijacker.role}), hijacked={hijacked.email} (role={hijacked.role})")
# Check permission
can_hijack_result = can_hijack(hijacker, hijacked)
logger.warning(f"can_hijack result: {can_hijack_result}")
if not can_hijack_result:
logger.warning(f"Hijack DENIED: {hijacker.email} -> {hijacked.email}")
return Response(
{"error": f"You do not have permission to masquerade as this user."},
status=status.HTTP_403_FORBIDDEN
)
# Get or build hijack history from request
hijack_history = request.data.get('hijack_history', [])
logger.warning(f"hijack_history length: {len(hijack_history)}")
# Don't allow hijacking while already hijacked (max depth 1)
if len(hijack_history) > 0:
logger.warning("Hijack denied - already masquerading")
return Response(
{"error": "Cannot start a new masquerade session while already masquerading. Please exit your current session first."},
status=status.HTTP_403_FORBIDDEN
)
logger.warning("Passed all checks, proceeding with hijack...")
# Add current user (hijacker) to the history stack
hijacker_business_subdomain = None
if hijacker.tenant:
primary_domain = hijacker.tenant.domains.filter(is_primary=True).first()
if primary_domain:
hijacker_business_subdomain = primary_domain.domain.split('.')[0]
else:
hijacker_business_subdomain = hijacker.tenant.schema_name
role_mapping = {
'superuser': 'superuser',
'platform_manager': 'platform_manager',
'platform_sales': 'platform_sales',
'platform_support': 'platform_support',
'tenant_owner': 'owner',
'tenant_manager': 'manager',
'tenant_staff': 'staff',
'customer': 'customer',
}
new_history = [{
'user_id': hijacker.id,
'username': hijacker.username,
'role': role_mapping.get(hijacker.role.lower(), hijacker.role.lower()),
'business_id': hijacker.tenant_id,
'business_subdomain': hijacker_business_subdomain,
}]
# Create or get token for hijacked user
Token.objects.filter(user=hijacked).delete() # Delete old token
token = Token.objects.create(user=hijacked)
return Response({
'access': token.key,
'refresh': token.key, # For API compatibility (we don't use refresh tokens with DRF Token auth)
'user': _get_user_data(hijacked),
'masquerade_stack': new_history,
}, status=status.HTTP_200_OK)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def hijack_release_view(request):
"""
Stop masquerading and return to previous user.
POST /api/auth/hijack/release/
Body: { "masquerade_stack": [...] }
Returns auth token for the original user.
"""
masquerade_stack = request.data.get('masquerade_stack', [])
if not masquerade_stack:
return Response(
{"error": "No masquerade session to stop. masquerade_stack is empty."},
status=status.HTTP_400_BAD_REQUEST
)
# Get the original user from the stack
original_user_entry = masquerade_stack.pop()
original_user = get_object_or_404(User, pk=original_user_entry['user_id'])
# Create or get token for original user
Token.objects.filter(user=original_user).delete() # Delete old token
token = Token.objects.create(user=original_user)
return Response({
'access': token.key,
'refresh': token.key,
'user': _get_user_data(original_user),
'masquerade_stack': masquerade_stack, # Return remaining stack (should be empty now)
}, status=status.HTTP_200_OK)