diff --git a/Dockerfile b/Dockerfile index 97cd5d3..956ad07 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,6 +58,9 @@ COPY --from=builder /app/worker /app/worker # Copy templates directory COPY --from=builder /app/templates /app/templates +# Copy static landing page files +COPY --from=builder /app/static /app/static + # Copy migrations and seeds for production use COPY --from=builder /app/migrations /app/migrations COPY --from=builder /app/seeds /app/seeds @@ -116,6 +119,9 @@ COPY --from=builder /app/worker /app/worker # Copy templates directory COPY --from=builder /app/templates /app/templates +# Copy static landing page files +COPY --from=builder /app/static /app/static + # Copy migrations and seeds COPY --from=builder /app/migrations /app/migrations COPY --from=builder /app/seeds /app/seeds diff --git a/docs/Secure Media Access Control.md b/docs/Secure Media Access Control.md new file mode 100644 index 0000000..3ab24ab --- /dev/null +++ b/docs/Secure Media Access Control.md @@ -0,0 +1,281 @@ + # Secure Media Access Control Plan + +## Problem Statement + +Users upload private images and documents (task completion photos, warranties, documents) that may contain sensitive information. Currently, these files are **publicly accessible** via predictable URLs (`/uploads/{category}/{timestamp}_{uuid}.{ext}`). Anyone who knows or guesses a URL can access the file without authentication. + +**Goal**: Ensure only users with access to a residence can view media files associated with that residence. + +## User Decisions + +| Decision | Choice | +|----------|--------| +| **Security Method** | Token-based proxy endpoint | +| **Protected Media** | All uploaded media (completions, documents, warranties) | + +--- + +## Current Architecture (Problem) + +``` +Mobile App Go API Disk + │ │ │ + ├─→ POST /api/documents/ ─────────→│ Save to /uploads/docs/ │ + │ (authenticated) │────────────────────────────→│ + │ │ │ + │←─ { file_url: "/uploads/..." } ←─│ │ + │ │ │ + ├─→ GET /uploads/docs/file.jpg ───→│ Static middleware │ + │ (NO AUTH CHECK!) │←───────────────────────────→│ + │←─ file bytes ←──────────────────←│ │ +``` + +**Issues**: +1. `r.Static("/uploads", cfg.Storage.UploadDir)` serves files without authentication +2. File URLs are predictable (timestamp + short UUID) +3. URLs can be shared/leaked, granting permanent access +4. No audit trail for file access + +--- + +## Recommended Solution: Authenticated Media Proxy + +### Architecture + +``` +Mobile App Go API Disk + │ │ │ + ├─→ POST /api/documents/ ─────────→│ Save to /uploads/docs/ │ + │ (authenticated) │────────────────────────────→│ + │ │ │ + │←─ { file_url: "/api/media/..." }←│ Return proxy URL │ + │ │ │ + ├─→ GET /api/media/{type}/{id}────→│ Media Handler: │ + │ Authorization: Token xxx │ 1. Verify token │ + │ │ 2. Get file record │ + │ │ 3. Check residence access │ + │ │ 4. Stream file │ + │←─ file bytes ←──────────────────←│←───────────────────────────→│ +``` + +### Key Changes + +1. **Remove public static file serving** - No more `r.Static("/uploads", ...)` +2. **Create authenticated media endpoint** - `GET /api/media/{type}/{id}` +3. **Update stored URLs** - Store internal paths, return proxy URLs in API responses +4. **Add access control** - Verify user has residence access before serving + +--- + +## Implementation Plan + +### Phase 1: Create Media Handler + +**New File: `/internal/handlers/media_handler.go`** + +```go +type MediaHandler struct { + documentRepo repositories.DocumentRepository + taskRepo repositories.TaskRepository + residenceRepo repositories.ResidenceRepository + storageSvc *services.StorageService +} + +// GET /api/media/document/{id} +// GET /api/media/document-image/{id} +// GET /api/media/completion-image/{id} +func (h *MediaHandler) ServeMedia(c *gin.Context) { + // 1. Extract user from auth context + user := c.MustGet(middleware.AuthUserKey).(*models.User) + + // 2. Get media type and ID from path + mediaType := c.Param("type") // "document", "document-image", "completion-image" + mediaID := c.Param("id") + + // 3. Look up the file record and get residence ID + residenceID, filePath, err := h.getFileInfo(mediaType, mediaID) + + // 4. Check residence access + hasAccess, _ := h.residenceRepo.HasAccess(residenceID, user.ID) + if !hasAccess { + c.JSON(403, gin.H{"error": "Access denied"}) + return + } + + // 5. Stream the file + c.File(filePath) +} +``` + +### Phase 2: Update Models & Responses + +**Change how URLs are stored and returned:** + +| Model | Current Storage | New Storage | API Response | +|-------|-----------------|-------------|--------------| +| `Document.FileURL` | `/uploads/docs/file.pdf` | `docs/file.pdf` (relative) | `/api/media/document/{id}` | +| `DocumentImage.ImageURL` | `/uploads/images/img.jpg` | `images/img.jpg` (relative) | `/api/media/document-image/{id}` | +| `TaskCompletionImage.ImageURL` | `/uploads/completions/img.jpg` | `completions/img.jpg` (relative) | `/api/media/completion-image/{id}` | + +**Update Response DTOs:** + +```go +// /internal/dto/responses/document_response.go +type DocumentResponse struct { + ID uint `json:"id"` + // Don't expose FileURL directly + // FileURL string `json:"file_url"` // REMOVE + MediaURL string `json:"media_url"` // NEW: "/api/media/document/123" + // ... +} + +type DocumentImageResponse struct { + ID uint `json:"id"` + MediaURL string `json:"media_url"` // "/api/media/document-image/456" + Caption string `json:"caption"` +} +``` + +### Phase 3: Update Router + +**File: `/internal/router/router.go`** + +```go +// REMOVE this line: +// r.Static("/uploads", cfg.Storage.UploadDir) + +// ADD authenticated media routes: +media := api.Group("/media") +media.Use(middleware.AuthRequired(cfg, userService)) +{ + media.GET("/document/:id", mediaHandler.ServeDocument) + media.GET("/document-image/:id", mediaHandler.ServeDocumentImage) + media.GET("/completion-image/:id", mediaHandler.ServeCompletionImage) +} +``` + +### Phase 4: Update Mobile Clients + +**iOS - Update image loading to include auth headers:** + +```swift +// Before: Direct URL access +AsyncImage(url: URL(string: document.fileUrl)) + +// After: Use authenticated request +AuthenticatedImage(url: document.mediaUrl) + +// New component that adds auth header +struct AuthenticatedImage: View { + let url: String + + var body: some View { + // Use URLSession with auth header, or + // use a library like Kingfisher with request modifier + } +} +``` + +**KMM - Update Ktor client for image requests:** + +```kotlin +// Add auth header to image requests +suspend fun fetchMedia(mediaUrl: String): ByteArray { + val token = TokenStorage.getToken() + return httpClient.get(mediaUrl) { + header("Authorization", "Token $token") + }.body() +} +``` + +--- + +## Files to Modify + +### Go API (myCribAPI-go) + +| File | Change | +|------|--------| +| `/internal/handlers/media_handler.go` | **NEW** - Authenticated media serving | +| `/internal/router/router.go` | Remove static serving, add media routes | +| `/internal/dto/responses/document_response.go` | Add `MediaURL` field, remove direct file URL | +| `/internal/dto/responses/task_response.go` | Add `MediaURL` to completion images | +| `/internal/services/document_service.go` | Update to generate proxy URLs | +| `/internal/services/task_service.go` | Update to generate proxy URLs for completions | + +### iOS (MyCribKMM/iosApp) + +| File | Change | +|------|--------| +| `iosApp/Components/AuthenticatedImage.swift` | **NEW** - Image view with auth headers | +| `iosApp/Task/TaskCompletionDetailView.swift` | Use AuthenticatedImage | +| `iosApp/Documents/DocumentDetailView.swift` | Use AuthenticatedImage | + +### KMM/Android + +| File | Change | +|------|--------| +| `network/MediaApi.kt` | **NEW** - Authenticated media fetching | +| `ui/components/AuthenticatedImage.kt` | **NEW** - Compose image with auth | + +--- + +## Migration Strategy + +### For Existing Data + +Option A: **Update URLs in database** (Recommended) +```sql +-- Strip /uploads prefix from existing URLs +UPDATE documents SET file_url = REPLACE(file_url, '/uploads/', '') WHERE file_url LIKE '/uploads/%'; +UPDATE document_images SET image_url = REPLACE(image_url, '/uploads/', '') WHERE image_url LIKE '/uploads/%'; +UPDATE task_completion_images SET image_url = REPLACE(image_url, '/uploads/', '') WHERE image_url LIKE '/uploads/%'; +``` + +Option B: **Handle both formats in code** (Temporary) +```go +func (h *MediaHandler) getFilePath(storedURL string) string { + // Handle legacy /uploads/... URLs + if strings.HasPrefix(storedURL, "/uploads/") { + return filepath.Join(h.uploadDir, strings.TrimPrefix(storedURL, "/uploads/")) + } + // Handle new relative paths + return filepath.Join(h.uploadDir, storedURL) +} +``` + +--- + +## Security Considerations + +### Pros of This Approach +- ✓ All media access requires valid auth token +- ✓ Access control tied to residence membership +- ✓ No URL guessing possible (URLs are opaque IDs) +- ✓ Can add audit logging easily +- ✓ Can add rate limiting per user + +### Cons / Trade-offs +- ✗ Every image request hits the API (increased server load) +- ✗ Can't use CDN caching for images +- ✗ Mobile apps need to handle auth for image loading + +### Mitigations +1. **Add caching headers** - `Cache-Control: private, max-age=3600` +2. **Consider short-lived signed URLs** later if performance becomes an issue +3. **Add response compression** for images if not already enabled + +--- + +## Implementation Order + +1. **Create MediaHandler** with access control logic +2. **Update router** - add media routes, keep static for now +3. **Update response DTOs** - add MediaURL fields +4. **Update services** - generate proxy URLs +5. **Test with Postman** - verify access control works +6. **Update iOS** - AuthenticatedImage component +7. **Update Android/KMM** - AuthenticatedImage component +8. **Run migration** - update existing URLs in database +9. **Remove static serving** - final step after clients updated +10. **Add audit logging** (optional) diff --git a/docs/TASK_KANBAN_LOGIC.md b/docs/TASK_KANBAN_LOGIC.md index 5097c4b..1a7921e 100644 --- a/docs/TASK_KANBAN_LOGIC.md +++ b/docs/TASK_KANBAN_LOGIC.md @@ -6,16 +6,16 @@ This document describes how tasks are categorized into kanban columns for displa Tasks are organized into 6 kanban columns based on their state and due date. The categorization logic is implemented in `internal/repositories/task_repo.go` in the `GetKanbanData` and `GetKanbanDataForMultipleResidences` functions. -## Columns +## Columns Summary -| Column | Name | Color | Description | -|--------|------|-------|-------------| -| 1 | **Overdue** | `#FF3B30` (Red) | Tasks past their due date | -| 2 | **Due Soon** | `#FF9500` (Orange) | Tasks due within the threshold (default 30 days) | -| 3 | **Upcoming** | `#007AFF` (Blue) | Tasks due beyond the threshold or with no due date | -| 4 | **In Progress** | `#5856D6` (Purple) | Tasks with status "In Progress" | -| 5 | **Completed** | `#34C759` (Green) | Tasks with at least one completion record | -| 6 | **Cancelled** | `#8E8E93` (Gray) | Tasks marked as cancelled | +| Column | Name | Color | Button Types | Description | +|--------|------|-------|--------------|-------------| +| 1 | **Overdue** | `#FF3B30` (Red) | edit, complete, cancel, mark_in_progress | Tasks past their due date | +| 2 | **Due Soon** | `#FF9500` (Orange) | edit, complete, cancel, mark_in_progress | Tasks due within the threshold (default 30 days) | +| 3 | **Upcoming** | `#007AFF` (Blue) | edit, complete, cancel, mark_in_progress | Tasks due beyond the threshold or with no due date | +| 4 | **In Progress** | `#5856D6` (Purple) | edit, complete, cancel | Tasks with status "In Progress" | +| 5 | **Completed** | `#34C759` (Green) | view | Tasks with at least one completion record | +| 6 | **Cancelled** | `#8E8E93` (Gray) | uncancel, delete | Tasks marked as cancelled | ## Categorization Flow @@ -86,7 +86,8 @@ if task.IsCancelled { } ``` - **Condition**: `is_cancelled = true` -- **Actions Available**: `uncancel`, `delete` +- **Button Types**: `uncancel`, `delete` +- **Rationale**: Cancelled tasks can be restored (uncancel) or permanently removed (delete) ### 2. Completed ```go @@ -97,7 +98,8 @@ if len(task.Completions) > 0 { ``` - **Condition**: Task has at least one `TaskCompletion` record - **Note**: A task is considered completed based on having completion records, NOT based on status -- **Actions Available**: `view` +- **Button Types**: `view` +- **Rationale**: Completed tasks are read-only for historical purposes; users can view completion details and photos ### 3. In Progress ```go @@ -107,7 +109,8 @@ if task.Status != nil && task.Status.Name == "In Progress" { } ``` - **Condition**: Task's status name is exactly `"In Progress"` -- **Actions Available**: `edit`, `complete` +- **Button Types**: `edit`, `complete`, `cancel` +- **Rationale**: In-progress tasks can be edited, marked complete, or cancelled if work is abandoned ### 4. Due Date Based Categories (only for tasks not cancelled, completed, or in progress) @@ -118,7 +121,8 @@ if task.DueDate.Before(now) { } ``` - **Condition**: `due_date < current_time` -- **Actions Available**: `edit`, `cancel`, `mark_in_progress` +- **Button Types**: `edit`, `complete`, `cancel`, `mark_in_progress` +- **Rationale**: Overdue tasks need urgent attention - can complete immediately, start working on them, edit the due date, or cancel if no longer needed #### Due Soon ```go @@ -128,14 +132,28 @@ if task.DueDate.Before(threshold) { ``` - **Condition**: `current_time <= due_date < (current_time + days_threshold)` - **Default threshold**: 30 days -- **Actions Available**: `edit`, `complete`, `mark_in_progress` +- **Button Types**: `edit`, `complete`, `cancel`, `mark_in_progress` +- **Rationale**: Due soon tasks are approaching their deadline - users can complete early, start working, reschedule, or cancel #### Upcoming ```go upcoming = append(upcoming, task) ``` - **Condition**: `due_date >= (current_time + days_threshold)` OR `due_date IS NULL` -- **Actions Available**: `edit`, `cancel` +- **Button Types**: `edit`, `complete`, `cancel`, `mark_in_progress` +- **Rationale**: Future tasks may need to be completed early (ahead of schedule), started, rescheduled, or cancelled + +## Button Types Reference + +| Button Type | Action | Description | +|-------------|--------|-------------| +| `edit` | Update task | Modify task details (title, description, due date, category, etc.) | +| `complete` | Create completion | Mark task as done with optional notes and photos | +| `cancel` | Cancel task | Mark task as cancelled (soft delete, can be uncancelled) | +| `mark_in_progress` | Start work | Change status to "In Progress" to indicate work has begun | +| `view` | View details | View task and completion details (read-only) | +| `uncancel` | Restore task | Restore a cancelled task to its previous state | +| `delete` | Hard delete | Permanently remove task (only for cancelled tasks) | ## Column Metadata @@ -145,7 +163,7 @@ Each column includes metadata for the mobile clients: { Name: "overdue_tasks", // Internal identifier DisplayName: "Overdue", // User-facing label - ButtonTypes: []string{"edit", "cancel", "mark_in_progress"}, // Available actions + ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{ // Platform-specific icons "ios": "exclamationmark.triangle", "android": "Warning" @@ -156,6 +174,17 @@ Each column includes metadata for the mobile clients: } ``` +### Icons by Column + +| Column | iOS Icon | Android Icon | +|--------|----------|--------------| +| Overdue | `exclamationmark.triangle` | `Warning` | +| Due Soon | `clock` | `Schedule` | +| Upcoming | `calendar` | `Event` | +| In Progress | `hammer` | `Build` | +| Completed | `checkmark.circle` | `CheckCircle` | +| Cancelled | `xmark.circle` | `Cancel` | + ## Days Threshold Parameter The `daysThreshold` parameter (default: 30) determines the boundary between "Due Soon" and "Upcoming": @@ -185,7 +214,7 @@ The following tasks are excluded from the kanban board entirely: { "name": "overdue_tasks", "display_name": "Overdue", - "button_types": ["edit", "cancel", "mark_in_progress"], + "button_types": ["edit", "complete", "cancel", "mark_in_progress"], "icons": { "ios": "exclamationmark.triangle", "android": "Warning" @@ -194,13 +223,86 @@ The following tasks are excluded from the kanban board entirely: "tasks": [...], "count": 2 }, - // ... other columns + { + "name": "due_soon_tasks", + "display_name": "Due Soon", + "button_types": ["edit", "complete", "cancel", "mark_in_progress"], + "icons": { + "ios": "clock", + "android": "Schedule" + }, + "color": "#FF9500", + "tasks": [...], + "count": 5 + }, + { + "name": "upcoming_tasks", + "display_name": "Upcoming", + "button_types": ["edit", "complete", "cancel", "mark_in_progress"], + "icons": { + "ios": "calendar", + "android": "Event" + }, + "color": "#007AFF", + "tasks": [...], + "count": 10 + }, + { + "name": "in_progress_tasks", + "display_name": "In Progress", + "button_types": ["edit", "complete", "cancel"], + "icons": { + "ios": "hammer", + "android": "Build" + }, + "color": "#5856D6", + "tasks": [...], + "count": 3 + }, + { + "name": "completed_tasks", + "display_name": "Completed", + "button_types": ["view"], + "icons": { + "ios": "checkmark.circle", + "android": "CheckCircle" + }, + "color": "#34C759", + "tasks": [...], + "count": 15 + }, + { + "name": "cancelled_tasks", + "display_name": "Cancelled", + "button_types": ["uncancel", "delete"], + "icons": { + "ios": "xmark.circle", + "android": "Cancel" + }, + "color": "#8E8E93", + "tasks": [...], + "count": 1 + } ], "days_threshold": 30, "residence_id": "123" } ``` +## Design Decisions + +### Why "In Progress" doesn't have "mark_in_progress" +Tasks already in the "In Progress" column are already marked as in progress, so this action doesn't make sense. + +### Why "Completed" only has "view" +Completed tasks represent historical records. Users can view them but shouldn't be able to uncomplete or modify them. If a task was completed incorrectly, the user should delete the completion record through the completion detail view. + +### Why "Cancelled" has "delete" but others don't +Hard deletion is only available for cancelled tasks as a final cleanup action. For active tasks, users should cancel first (soft delete), then delete if truly needed. This prevents accidental data loss. + +### Why all active columns have "cancel" +Users should always be able to abandon a task they no longer need, regardless of its due date or progress state. + ## Code Location - **Repository**: `internal/repositories/task_repo.go` diff --git a/internal/config/config.go b/internal/config/config.go index 7347cfb..cfb862d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,6 +29,7 @@ type ServerConfig struct { Debug bool AllowedHosts []string Timezone string + StaticDir string // Directory for static landing page files } type DatabaseConfig struct { @@ -148,6 +149,7 @@ func Load() (*Config, error) { Debug: viper.GetBool("DEBUG"), AllowedHosts: strings.Split(viper.GetString("ALLOWED_HOSTS"), ","), Timezone: viper.GetString("TIMEZONE"), + StaticDir: viper.GetString("STATIC_DIR"), }, Database: dbConfig, Redis: RedisConfig{ @@ -217,6 +219,7 @@ func setDefaults() { viper.SetDefault("DEBUG", false) viper.SetDefault("ALLOWED_HOSTS", "localhost,127.0.0.1") viper.SetDefault("TIMEZONE", "UTC") + viper.SetDefault("STATIC_DIR", "/app/static") // Database defaults viper.SetDefault("DB_HOST", "localhost") diff --git a/internal/dto/responses/task.go b/internal/dto/responses/task.go index e6383c0..17996d7 100644 --- a/internal/dto/responses/task.go +++ b/internal/dto/responses/task.go @@ -73,6 +73,7 @@ type TaskCompletionResponse struct { Rating *int `json:"rating"` Images []TaskCompletionImageResponse `json:"images"` CreatedAt time.Time `json:"created_at"` + Task *TaskResponse `json:"task,omitempty"` // Updated task after completion } // TaskResponse represents a task in the API response @@ -99,10 +100,11 @@ type TaskResponse struct { ContractorID *uint `json:"contractor_id"` IsCancelled bool `json:"is_cancelled"` IsArchived bool `json:"is_archived"` - ParentTaskID *uint `json:"parent_task_id"` - CompletionCount int `json:"completion_count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ParentTaskID *uint `json:"parent_task_id"` + CompletionCount int `json:"completion_count"` + KanbanColumn string `json:"kanban_column,omitempty"` // Which kanban column this task belongs to + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // Note: Pagination removed - list endpoints now return arrays directly @@ -340,3 +342,55 @@ func NewTaskCompletionListResponse(completions []models.TaskCompletion) []TaskCo } return results } + +// NewTaskCompletionWithTaskResponse creates a TaskCompletionResponse with the updated task included +func NewTaskCompletionWithTaskResponse(c *models.TaskCompletion, task *models.Task, daysThreshold int) TaskCompletionResponse { + resp := NewTaskCompletionResponse(c) + + if task != nil { + taskResp := NewTaskResponse(task) + taskResp.KanbanColumn = DetermineKanbanColumn(task, daysThreshold) + resp.Task = &taskResp + } + + return resp +} + +// DetermineKanbanColumn determines which kanban column a task belongs to +// Uses the same logic as task_repo.go GetKanbanData +func DetermineKanbanColumn(task *models.Task, daysThreshold int) string { + if daysThreshold <= 0 { + daysThreshold = 30 // Default + } + + now := time.Now().UTC() + threshold := now.AddDate(0, 0, daysThreshold) + + // Priority order (same as GetKanbanData): + // 1. Cancelled + if task.IsCancelled { + return "cancelled_tasks" + } + + // 2. Completed (has completions) + if len(task.Completions) > 0 { + return "completed_tasks" + } + + // 3. In Progress + if task.Status != nil && task.Status.Name == "In Progress" { + return "in_progress_tasks" + } + + // 4. Due date based + if task.DueDate != nil { + if task.DueDate.Before(now) { + return "overdue_tasks" + } else if task.DueDate.Before(threshold) { + return "due_soon_tasks" + } + } + + // Default: upcoming + return "upcoming_tasks" +} diff --git a/internal/repositories/task_repo.go b/internal/repositories/task_repo.go index cd2b50c..6ff1952 100644 --- a/internal/repositories/task_repo.go +++ b/internal/repositories/task_repo.go @@ -197,7 +197,7 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo { Name: "overdue_tasks", DisplayName: "Overdue", - ButtonTypes: []string{"edit", "cancel", "mark_in_progress"}, + ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"}, Color: "#FF3B30", Tasks: overdue, @@ -206,7 +206,7 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo { Name: "due_soon_tasks", DisplayName: "Due Soon", - ButtonTypes: []string{"edit", "complete", "mark_in_progress"}, + ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "clock", "android": "Schedule"}, Color: "#FF9500", Tasks: dueSoon, @@ -215,7 +215,7 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo { Name: "upcoming_tasks", DisplayName: "Upcoming", - ButtonTypes: []string{"edit", "cancel"}, + ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "calendar", "android": "Event"}, Color: "#007AFF", Tasks: upcoming, @@ -224,7 +224,7 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo { Name: "in_progress_tasks", DisplayName: "In Progress", - ButtonTypes: []string{"edit", "complete"}, + ButtonTypes: []string{"edit", "complete", "cancel"}, Icons: map[string]string{"ios": "hammer", "android": "Build"}, Color: "#5856D6", Tasks: inProgress, @@ -324,7 +324,7 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, { Name: "overdue_tasks", DisplayName: "Overdue", - ButtonTypes: []string{"edit", "cancel", "mark_in_progress"}, + ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"}, Color: "#FF3B30", Tasks: overdue, @@ -333,7 +333,7 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, { Name: "due_soon_tasks", DisplayName: "Due Soon", - ButtonTypes: []string{"edit", "complete", "mark_in_progress"}, + ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "clock", "android": "Schedule"}, Color: "#FF9500", Tasks: dueSoon, @@ -342,7 +342,7 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, { Name: "upcoming_tasks", DisplayName: "Upcoming", - ButtonTypes: []string{"edit", "cancel"}, + ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "calendar", "android": "Event"}, Color: "#007AFF", Tasks: upcoming, @@ -351,7 +351,7 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, { Name: "in_progress_tasks", DisplayName: "In Progress", - ButtonTypes: []string{"edit", "complete"}, + ButtonTypes: []string{"edit", "complete", "cancel"}, Icons: map[string]string{"ios": "hammer", "android": "Build"}, Color: "#5856D6", Tasks: inProgress, diff --git a/internal/router/router.go b/internal/router/router.go index 34b66a0..cdfe974 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -51,6 +51,20 @@ func SetupRouter(deps *Dependencies) *gin.Engine { r.Use(corsMiddleware(cfg)) r.Use(i18n.Middleware()) + // Serve landing page static files (if static directory is configured) + staticDir := cfg.Server.StaticDir + if staticDir != "" { + r.Static("/css", staticDir+"/css") + r.Static("/js", staticDir+"/js") + r.Static("/images", staticDir+"/images") + r.StaticFile("/favicon.ico", staticDir+"/images/favicon.svg") + + // Serve index.html at root + r.GET("/", func(c *gin.Context) { + c.File(staticDir + "/index.html") + }) + } + // Health check endpoint (no auth required) r.GET("/api/health/", healthCheck) diff --git a/internal/services/email_service.go b/internal/services/email_service.go index feb70c1..d0adca4 100644 --- a/internal/services/email_service.go +++ b/internal/services/email_service.go @@ -84,6 +84,74 @@ func (s *EmailService) SendEmailWithAttachment(to, subject, htmlBody, textBody s return nil } +// baseEmailTemplate returns the styled email wrapper +func baseEmailTemplate() string { + return ` + + + + + + %s + + + + + + + +
+ + %s +
+
+ +` +} + +// emailIconURL is the URL for the email icon +const emailIconURL = "https://casera.app/images/icon.png" + +// emailHeader returns the gradient header section with logo +func emailHeader(title string) string { + return fmt.Sprintf(` + + + + + + + + +
+ Casera +
+

Casera

+

%s

+ + `, emailIconURL, title) +} + +// emailFooter returns the footer section +func emailFooter(year int) string { + return fmt.Sprintf(` + + + +

© %d Casera. All rights reserved.

+

Never miss home maintenance again.

+ + `, year) +} + // SendWelcomeEmail sends a welcome email with verification code func (s *EmailService) SendWelcomeEmail(to, firstName, code string) error { subject := "Welcome to Casera - Verify Your Email" @@ -93,37 +161,35 @@ func (s *EmailService) SendWelcomeEmail(to, firstName, code string) error { name = "there" } - htmlBody := fmt.Sprintf(` - - - - - - - -
-
-

Welcome to Casera!

-
-

Hi %s,

-

Thank you for creating a Casera account. To complete your registration, please verify your email address by entering the following code:

-
%s
-

This code will expire in 24 hours.

-

If you didn't create a Casera account, you can safely ignore this email.

-

Best regards,
The Casera Team

- -
- - -`, name, code, time.Now().Year()) + bodyContent := fmt.Sprintf(` + %s + + + +

Hi %s,

+

Thank you for creating a Casera account! To complete your registration, please verify your email address by entering the code below:

+ + + + + + +
+

%s

+

This code will expire in 24 hours

+
+ +

If you didn't create a Casera account, you can safely ignore this email.

+ +

Best regards,
The Casera Team

+ + + %s`, + emailHeader("Welcome!"), + name, code, + emailFooter(time.Now().Year())) + + htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent) textBody := fmt.Sprintf(` Welcome to Casera! @@ -154,44 +220,38 @@ func (s *EmailService) SendAppleWelcomeEmail(to, firstName string) error { name = "there" } - htmlBody := fmt.Sprintf(` - - - - - - - -
-
-

Welcome to Casera!

-
-

Hi %s,

-

Thank you for joining Casera! Your account has been created and you're ready to start managing your properties.

-
-

Here's what you can do with Casera:

-
🏠 Manage Properties - Track all your homes and rentals in one place
-
Task Management - Never miss maintenance with smart scheduling
-
👷 Contractor Directory - Keep your trusted pros organized
-
📄 Document Storage - Store warranties, manuals, and important records
-
-

If you have any questions, feel free to reach out to us at support@casera.app.

-

Best regards,
The Casera Team

- -
- - -`, name, time.Now().Year()) + bodyContent := fmt.Sprintf(` + %s + + + +

Hi %s,

+

Thank you for joining Casera! Your account has been created and you're ready to start managing your properties.

+ + + + + + +
+

Here's what you can do with Casera:

+

🏠 Manage Properties - Track all your homes and rentals in one place

+

Task Management - Never miss maintenance with smart scheduling

+

👷 Contractor Directory - Keep your trusted pros organized

+

📄 Document Storage - Store warranties, manuals, and important records

+
+ +

If you have any questions, feel free to reach out to us at support@casera.app.

+ +

Best regards,
The Casera Team

+ + + %s`, + emailHeader("Welcome!"), + name, + emailFooter(time.Now().Year())) + + htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent) textBody := fmt.Sprintf(` Welcome to Casera! @@ -224,34 +284,35 @@ func (s *EmailService) SendVerificationEmail(to, firstName, code string) error { name = "there" } - htmlBody := fmt.Sprintf(` - - - - - - - -
-

Verify Your Email

-

Hi %s,

-

Please use the following code to verify your email address:

-
%s
-

This code will expire in 24 hours.

-

If you didn't request this, you can safely ignore this email.

-

Best regards,
The Casera Team

- -
- - -`, name, code, time.Now().Year()) + bodyContent := fmt.Sprintf(` + %s + + + +

Hi %s,

+

Please use the following code to verify your email address:

+ + + + + + +
+

%s

+

This code will expire in 24 hours

+
+ +

If you didn't request this, you can safely ignore this email.

+ +

Best regards,
The Casera Team

+ + + %s`, + emailHeader("Verify Your Email"), + name, code, + emailFooter(time.Now().Year())) + + htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent) textBody := fmt.Sprintf(` Verify Your Email @@ -282,37 +343,43 @@ func (s *EmailService) SendPasswordResetEmail(to, firstName, code string) error name = "there" } - htmlBody := fmt.Sprintf(` - - - - - - - -
-

Password Reset Request

-

Hi %s,

-

We received a request to reset your password. Use the following code to complete the reset:

-
%s
-

This code will expire in 15 minutes.

-
- Security Notice: If you didn't request a password reset, please ignore this email. Your password will remain unchanged. -
-

Best regards,
The Casera Team

- -
- - -`, name, code, time.Now().Year()) + bodyContent := fmt.Sprintf(` + %s + + + +

Hi %s,

+

We received a request to reset your password. Use the following code to complete the reset:

+ + + + + + +
+

%s

+

This code will expire in 15 minutes

+
+ + + + + + +
+

Security Notice

+

If you didn't request a password reset, please ignore this email. Your password will remain unchanged.

+
+ +

Best regards,
The Casera Team

+ + + %s`, + emailHeader("Password Reset"), + name, code, + emailFooter(time.Now().Year())) + + htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent) textBody := fmt.Sprintf(` Password Reset Request @@ -343,34 +410,35 @@ func (s *EmailService) SendPasswordChangedEmail(to, firstName string) error { name = "there" } - htmlBody := fmt.Sprintf(` - - - - - - - -
-

Password Changed

-

Hi %s,

-

Your Casera password was successfully changed on %s.

-
- Didn't make this change? If you didn't change your password, please contact us immediately at support@casera.app or reset your password. -
-

Best regards,
The Casera Team

- -
- - -`, name, time.Now().UTC().Format("January 2, 2006 at 3:04 PM UTC"), time.Now().Year()) + changeTime := time.Now().UTC().Format("January 2, 2006 at 3:04 PM UTC") + + bodyContent := fmt.Sprintf(` + %s + + + +

Hi %s,

+

Your Casera password was successfully changed on %s.

+ + + + + + +
+

Didn't make this change?

+

If you didn't change your password, please contact us immediately at support@casera.app or reset your password.

+
+ +

Best regards,
The Casera Team

+ + + %s`, + emailHeader("Password Changed"), + name, changeTime, + emailFooter(time.Now().Year())) + + htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent) textBody := fmt.Sprintf(` Password Changed @@ -383,7 +451,7 @@ DIDN'T MAKE THIS CHANGE? If you didn't change your password, please contact us i Best regards, The Casera Team -`, name, time.Now().UTC().Format("January 2, 2006 at 3:04 PM UTC")) +`, name, changeTime) return s.SendEmail(to, subject, htmlBody, textBody) } @@ -397,40 +465,35 @@ func (s *EmailService) SendTaskCompletedEmail(to, recipientName, taskTitle, comp name = "there" } - htmlBody := fmt.Sprintf(` - - - - - - - -
-
-

Task Completed!

-
-

Hi %s,

-

A task has been completed at %s:

-
-

%s

-

Completed by: %s
Completed on: %s

-
-

Best regards,
The Casera Team

- -
- - -`, name, residenceName, taskTitle, completedByName, time.Now().UTC().Format("January 2, 2006 at 3:04 PM"), time.Now().Year()) + completedTime := time.Now().UTC().Format("January 2, 2006 at 3:04 PM") + + bodyContent := fmt.Sprintf(` + %s + + + +

Hi %s,

+

A task has been completed at %s:

+ + + + + + +
+

%s

+

Completed by: %s
Completed on: %s

+
+ +

Best regards,
The Casera Team

+ + + %s`, + emailHeader("Task Completed!"), + name, residenceName, taskTitle, completedByName, completedTime, + emailFooter(time.Now().Year())) + + htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent) textBody := fmt.Sprintf(` Task Completed! @@ -445,7 +508,7 @@ Completed on: %s Best regards, The Casera Team -`, name, residenceName, taskTitle, completedByName, time.Now().UTC().Format("January 2, 2006 at 3:04 PM")) +`, name, residenceName, taskTitle, completedByName, completedTime) return s.SendEmail(to, subject, htmlBody, textBody) } @@ -459,66 +522,54 @@ func (s *EmailService) SendTasksReportEmail(to, recipientName, residenceName str name = "there" } - htmlBody := fmt.Sprintf(` - - - - - - - -
-
-

Tasks Report

-

%s

-
-

Hi %s,

-

Here's your tasks report for %s. The full report is attached as a PDF.

-
-

Summary

- - - - - - - -
-
%d
-
Total Tasks
-
-
%d
-
Completed
-
-
%d
-
Pending
-
-
%d
-
Overdue
-
-
-

Open the attached PDF for the complete list of tasks with details.

-

Best regards,
The Casera Team

- -
- - -`, residenceName, name, residenceName, totalTasks, completed, pending, overdue, time.Now().Year()) + bodyContent := fmt.Sprintf(` + %s + + + +

Hi %s,

+

Here's your tasks report for %s. The full report is attached as a PDF.

+ + + + + + +
+

Summary

+ + + + + + + +
+

%d

+

Total

+
+

%d

+

Completed

+
+

%d

+

Pending

+
+

%d

+

Overdue

+
+
+ +

Open the attached PDF for the complete list of tasks with details.

+ +

Best regards,
The Casera Team

+ + + %s`, + emailHeader("Tasks Report"), + name, residenceName, totalTasks, completed, pending, overdue, + emailFooter(time.Now().Year())) + + htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent) textBody := fmt.Sprintf(` Tasks Report for %s diff --git a/internal/services/task_service.go b/internal/services/task_service.go index 5df47ed..ccac8cb 100644 --- a/internal/services/task_service.go +++ b/internal/services/task_service.go @@ -501,10 +501,20 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest return nil, err } + // Reload task with updated completions (so client can update kanban column) + task, err = s.taskRepo.FindByID(req.TaskID) + if err != nil { + // Non-fatal - still return the completion, just without the task + log.Warn().Err(err).Uint("task_id", req.TaskID).Msg("Failed to reload task after completion") + resp := responses.NewTaskCompletionResponse(completion) + return &resp, nil + } + // Send notification to residence owner and other users s.sendTaskCompletedNotification(task, completion) - resp := responses.NewTaskCompletionResponse(completion) + // Return completion with updated task (includes kanban_column for UI update) + resp := responses.NewTaskCompletionWithTaskResponse(completion, task, 30) return &resp, nil } diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..5d64dbe --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,1082 @@ +/* =================================== + Casera Landing Page Styles + VIBRANT EDITION + =================================== */ + +/* CSS Variables */ +:root { + /* Brand Colors */ + --color-primary: #0079FF; + --color-primary-dark: #0055CC; + --color-primary-light: #4DA3FF; + + --color-secondary: #5AC7F9; + --color-secondary-dark: #3AB8F5; + + --color-accent: #FF9400; + --color-accent-light: #FFB347; + --color-accent-dark: #E68600; + + --color-purple: #8B5CF6; + --color-pink: #EC4899; + --color-teal: #14B8A6; + --color-green: #22C55E; + + /* Text */ + --color-text-primary: #1a1a1a; + --color-text-secondary: #4B5563; + --color-text-muted: #9CA3AF; + --color-text-light: rgba(255, 255, 255, 0.9); + + /* Backgrounds */ + --color-bg-primary: #FFFFFF; + --color-bg-secondary: #F8FAFC; + --color-bg-dark: #0F172A; + --color-bg-darker: #020617; + + /* Gradients */ + --gradient-hero: linear-gradient(135deg, #0079FF 0%, #5AC7F9 50%, #14B8A6 100%); + --gradient-cta: linear-gradient(135deg, #0079FF 0%, #8B5CF6 100%); + --gradient-accent: linear-gradient(135deg, #FF9400 0%, #EC4899 100%); + --gradient-dark: linear-gradient(180deg, #0F172A 0%, #1E293B 100%); + --gradient-mesh: radial-gradient(at 40% 20%, rgba(0, 121, 255, 0.3) 0px, transparent 50%), + radial-gradient(at 80% 0%, rgba(90, 199, 249, 0.3) 0px, transparent 50%), + radial-gradient(at 0% 50%, rgba(139, 92, 246, 0.2) 0px, transparent 50%), + radial-gradient(at 80% 50%, rgba(20, 184, 166, 0.2) 0px, transparent 50%), + radial-gradient(at 0% 100%, rgba(255, 148, 0, 0.2) 0px, transparent 50%); + + /* Typography */ + --font-primary: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + + --text-hero: clamp(2.5rem, 5vw, 4rem); + --text-h1: clamp(2rem, 4vw, 3rem); + --text-h2: clamp(1.75rem, 3vw, 2.25rem); + --text-h3: 1.25rem; + --text-body: 1.125rem; + --text-small: 0.875rem; + + /* Spacing */ + --space-xs: 0.5rem; + --space-sm: 1rem; + --space-md: 1.5rem; + --space-lg: 2rem; + --space-xl: 3rem; + --space-2xl: 4rem; + --space-3xl: 6rem; + --space-4xl: 8rem; + + /* Other */ + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 24px; + --radius-2xl: 32px; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + --shadow-glow: 0 0 60px rgba(0, 121, 255, 0.3); + --shadow-glow-accent: 0 0 60px rgba(255, 148, 0, 0.3); + + --transition: 0.3s ease; + --transition-slow: 0.5s ease; +} + +/* Reset */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + scroll-behavior: smooth; +} + +body { + font-family: var(--font-primary); + font-size: var(--text-body); + line-height: 1.6; + color: var(--color-text-primary); + background-color: var(--color-bg-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow-x: hidden; +} + +img { + max-width: 100%; + height: auto; +} + +a { + color: inherit; + text-decoration: none; +} + +/* Container */ +.container { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 0 var(--space-md); +} + +/* =================================== + Navigation + =================================== */ +.nav { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + background: rgba(15, 23, 42, 0.9); + backdrop-filter: blur(20px); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + transition: var(--transition); +} + +.nav-container { + display: flex; + align-items: center; + justify-content: space-between; + max-width: 1200px; + margin: 0 auto; + padding: var(--space-sm) var(--space-md); +} + +.nav-logo { + display: flex; + align-items: center; + gap: var(--space-xs); +} + +.logo-text { + font-size: 1.5rem; + font-weight: 800; + background: linear-gradient(135deg, #fff 0%, var(--color-secondary) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.nav-links { + display: flex; + align-items: center; + gap: var(--space-lg); +} + +.nav-link { + font-size: var(--text-small); + font-weight: 500; + color: rgba(255, 255, 255, 0.7); + transition: var(--transition); +} + +.nav-link:hover { + color: #fff; +} + +.nav-cta { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.nav-toggle { + display: none; + flex-direction: column; + gap: 5px; + background: none; + border: none; + cursor: pointer; + padding: var(--space-xs); +} + +.nav-toggle span { + display: block; + width: 24px; + height: 2px; + background: #fff; + transition: var(--transition); +} + +/* =================================== + Buttons + =================================== */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-xs); + font-family: var(--font-primary); + font-weight: 600; + text-align: center; + border: none; + border-radius: var(--radius-lg); + cursor: pointer; + transition: var(--transition); + position: relative; + overflow: hidden; +} + +.btn::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(255,255,255,0.2) 0%, transparent 50%); + opacity: 0; + transition: var(--transition); +} + +.btn:hover::before { + opacity: 1; +} + +.btn-icon { + width: 20px; + height: 20px; +} + +.btn-sm { + padding: var(--space-xs) var(--space-sm); + font-size: var(--text-small); +} + +.btn-lg { + padding: var(--space-sm) var(--space-lg); + font-size: 1rem; +} + +.btn-primary { + background: var(--gradient-hero); + color: white; + box-shadow: 0 4px 15px rgba(0, 121, 255, 0.4); +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0, 121, 255, 0.5); +} + +.btn-secondary { + background: rgba(255, 255, 255, 0.1); + color: white; + border: 1px solid rgba(255, 255, 255, 0.2); + backdrop-filter: blur(10px); +} + +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.3); +} + +.btn-white { + background: white; + color: var(--color-primary); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); +} + +.btn-white:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15); +} + +.btn-outline-white { + background: transparent; + color: white; + border: 2px solid rgba(255, 255, 255, 0.5); +} + +.btn-outline-white:hover { + background: rgba(255, 255, 255, 0.1); + border-color: white; +} + +/* =================================== + Hero Section + =================================== */ +.hero { + position: relative; + padding: calc(80px + var(--space-4xl)) 0 var(--space-4xl); + background: var(--gradient-dark); + overflow: hidden; +} + +.hero::before { + content: ''; + position: absolute; + inset: 0; + background: var(--gradient-mesh); + opacity: 1; +} + +.hero::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 200px; + background: linear-gradient(to top, var(--color-bg-primary), transparent); +} + +.hero-container { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-2xl); + align-items: center; +} + +.hero-content { + max-width: 560px; +} + +.hero-badge { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); + background: linear-gradient(135deg, rgba(255, 148, 0, 0.2) 0%, rgba(236, 72, 153, 0.2) 100%); + border: 1px solid rgba(255, 148, 0, 0.3); + color: var(--color-accent-light); + font-size: var(--text-small); + font-weight: 600; + border-radius: var(--radius-xl); + margin-bottom: var(--space-md); +} + +.hero-badge::before { + content: ''; + width: 8px; + height: 8px; + background: var(--color-accent); + border-radius: 50%; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(1.2); } +} + +.hero-title { + font-size: var(--text-hero); + font-weight: 800; + line-height: 1.1; + margin-bottom: var(--space-md); + color: #fff; +} + +.hero-title-highlight { + background: var(--gradient-hero); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.hero-subtitle { + font-size: var(--text-body); + color: rgba(255, 255, 255, 0.7); + margin-bottom: var(--space-lg); + line-height: 1.8; +} + +.hero-cta { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); + margin-bottom: var(--space-md); +} + +.hero-trust { + font-size: var(--text-small); + color: rgba(255, 255, 255, 0.5); +} + +.hero-visual { + display: flex; + justify-content: center; + align-items: center; + position: relative; +} + +/* Glowing orbs behind phone */ +.hero-visual::before { + content: ''; + position: absolute; + width: 300px; + height: 300px; + background: var(--color-primary); + border-radius: 50%; + filter: blur(80px); + opacity: 0.4; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.hero-visual::after { + content: ''; + position: absolute; + width: 200px; + height: 200px; + background: var(--color-accent); + border-radius: 50%; + filter: blur(60px); + opacity: 0.3; + top: 30%; + right: 10%; +} + +/* Phone Mockup */ +.phone-mockup { + position: relative; + z-index: 1; +} + +.phone-frame { + position: relative; + width: 280px; + height: 580px; + background: linear-gradient(145deg, #2a2a2a 0%, #1a1a1a 100%); + border-radius: 44px; + padding: 12px; + box-shadow: + 0 50px 100px -20px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(255, 255, 255, 0.1), + inset 0 0 0 1px rgba(255, 255, 255, 0.05); +} + +.phone-frame::before { + content: ''; + position: absolute; + top: 16px; + left: 50%; + transform: translateX(-50%); + width: 100px; + height: 28px; + background: #1a1a1a; + border-radius: 20px; + z-index: 10; +} + +.phone-screen { + width: 100%; + height: 100%; + background: linear-gradient(180deg, #1E293B 0%, #0F172A 100%); + border-radius: 36px; + overflow: hidden; + position: relative; +} + +.placeholder-content { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, + rgba(0, 121, 255, 0.1) 0%, + rgba(139, 92, 246, 0.1) 50%, + rgba(20, 184, 166, 0.1) 100%); + color: rgba(255, 255, 255, 0.4); + font-size: var(--text-small); + font-weight: 500; + gap: var(--space-sm); +} + +.placeholder-content::before { + content: ''; + width: 60px; + height: 60px; + border: 2px dashed rgba(255, 255, 255, 0.2); + border-radius: var(--radius-md); +} + +/* =================================== + Feature Highlights + =================================== */ +.highlights { + padding: var(--space-4xl) 0; + background: var(--color-bg-primary); + position: relative; +} + +.highlights-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-lg); +} + +.highlight-card { + text-align: center; + padding: var(--space-xl); + background: white; + border-radius: var(--radius-xl); + border: 1px solid rgba(0, 0, 0, 0.05); + transition: var(--transition); + position: relative; + overflow: hidden; +} + +.highlight-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: var(--gradient-hero); + transform: scaleX(0); + transition: var(--transition); +} + +.highlight-card:hover { + transform: translateY(-8px); + box-shadow: var(--shadow-xl), var(--shadow-glow); +} + +.highlight-card:hover::before { + transform: scaleX(1); +} + +.highlight-card:nth-child(1) .highlight-icon { + background: linear-gradient(135deg, rgba(0, 121, 255, 0.15) 0%, rgba(90, 199, 249, 0.15) 100%); + color: var(--color-primary); +} + +.highlight-card:nth-child(2) .highlight-icon { + background: linear-gradient(135deg, rgba(139, 92, 246, 0.15) 0%, rgba(236, 72, 153, 0.15) 100%); + color: var(--color-purple); +} + +.highlight-card:nth-child(3) .highlight-icon { + background: linear-gradient(135deg, rgba(255, 148, 0, 0.15) 0%, rgba(34, 197, 94, 0.15) 100%); + color: var(--color-accent); +} + +.highlight-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 72px; + height: 72px; + border-radius: var(--radius-lg); + margin-bottom: var(--space-md); +} + +.highlight-icon svg { + width: 32px; + height: 32px; +} + +.highlight-title { + font-size: var(--text-h3); + font-weight: 700; + margin-bottom: var(--space-xs); + color: var(--color-text-primary); +} + +.highlight-desc { + font-size: var(--text-small); + color: var(--color-text-secondary); +} + +/* =================================== + Features Section + =================================== */ +.features { + padding: var(--space-4xl) 0; + background: var(--color-bg-secondary); + position: relative; +} + +.feature { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-3xl); + align-items: center; + margin-bottom: var(--space-4xl); +} + +.feature:last-child { + margin-bottom: 0; +} + +.feature-right .feature-content { + order: 1; +} + +.feature-right .feature-visual { + order: 0; +} + +.feature-content { + max-width: 480px; +} + +.feature-label { + display: inline-block; + padding: var(--space-xs) var(--space-sm); + background: var(--gradient-hero); + color: white; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + border-radius: var(--radius-sm); + margin-bottom: var(--space-sm); +} + +.feature-title { + font-size: var(--text-h2); + font-weight: 700; + margin-bottom: var(--space-md); + line-height: 1.2; + color: var(--color-text-primary); +} + +.feature-desc { + font-size: var(--text-body); + color: var(--color-text-secondary); + line-height: 1.8; +} + +.feature-visual { + display: flex; + justify-content: center; + position: relative; +} + +/* Colored backgrounds behind feature phones */ +.feature:nth-child(1) .feature-visual::before { + content: ''; + position: absolute; + width: 250px; + height: 250px; + background: var(--color-primary); + border-radius: 50%; + filter: blur(80px); + opacity: 0.2; +} + +.feature:nth-child(2) .feature-visual::before { + content: ''; + position: absolute; + width: 250px; + height: 250px; + background: var(--color-purple); + border-radius: 50%; + filter: blur(80px); + opacity: 0.2; +} + +.feature:nth-child(3) .feature-visual::before { + content: ''; + position: absolute; + width: 250px; + height: 250px; + background: var(--color-teal); + border-radius: 50%; + filter: blur(80px); + opacity: 0.2; +} + +.feature:nth-child(4) .feature-visual::before { + content: ''; + position: absolute; + width: 250px; + height: 250px; + background: var(--color-accent); + border-radius: 50%; + filter: blur(80px); + opacity: 0.2; +} + +.feature-visual .phone-frame { + width: 260px; + height: 540px; + border-radius: 40px; +} + +.feature-visual .phone-frame::before { + width: 90px; + height: 24px; +} + +.feature-visual .phone-screen { + border-radius: 32px; +} + +/* =================================== + Platforms Section + =================================== */ +.platforms { + padding: var(--space-4xl) 0; + background: var(--gradient-dark); + position: relative; + overflow: hidden; +} + +.platforms::before { + content: ''; + position: absolute; + inset: 0; + background: var(--gradient-mesh); + opacity: 0.5; +} + +.platforms-content { + position: relative; + z-index: 1; + text-align: center; + max-width: 600px; + margin: 0 auto var(--space-3xl); +} + +.section-title { + font-size: var(--text-h1); + font-weight: 700; + margin-bottom: var(--space-sm); + color: #fff; +} + +.section-subtitle { + font-size: var(--text-body); + color: rgba(255, 255, 255, 0.7); +} + +.platforms-visual { + position: relative; + z-index: 1; +} + +.device-showcase { + display: flex; + justify-content: center; + align-items: flex-end; + gap: var(--space-3xl); +} + +.device-showcase .phone-mockup { + text-align: center; +} + +.device-showcase .phone-frame { + width: 240px; + height: 500px; + border-radius: 36px; +} + +.device-showcase .phone-frame::before { + width: 80px; + height: 22px; +} + +.device-showcase .phone-screen { + border-radius: 30px; +} + +.device-label { + display: inline-block; + margin-top: var(--space-md); + padding: var(--space-xs) var(--space-sm); + background: rgba(255, 255, 255, 0.1); + border-radius: var(--radius-xl); + font-size: var(--text-small); + font-weight: 600; + color: rgba(255, 255, 255, 0.9); +} + +/* =================================== + CTA Section + =================================== */ +.cta { + padding: var(--space-4xl) 0; + background: var(--gradient-cta); + text-align: center; + position: relative; + overflow: hidden; +} + +.cta::before { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 50%); + animation: rotate 20s linear infinite; +} + +@keyframes rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.cta-content { + position: relative; + z-index: 1; + max-width: 600px; + margin: 0 auto; +} + +.cta-title { + font-size: var(--text-h1); + font-weight: 800; + color: white; + margin-bottom: var(--space-sm); +} + +.cta-subtitle { + font-size: var(--text-body); + color: rgba(255, 255, 255, 0.85); + margin-bottom: var(--space-xl); +} + +.cta-buttons { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--space-sm); + margin-bottom: var(--space-lg); +} + +.cta-trust { + font-size: var(--text-small); + color: rgba(255, 255, 255, 0.6); +} + +/* =================================== + Footer + =================================== */ +.footer { + padding: var(--space-3xl) 0 var(--space-lg); + background: var(--color-bg-darker); + color: rgba(255, 255, 255, 0.7); +} + +.footer-content { + display: grid; + grid-template-columns: 1fr 2fr; + gap: var(--space-3xl); + margin-bottom: var(--space-2xl); +} + +.footer-brand .logo-text { + font-size: 1.5rem; + margin-bottom: var(--space-xs); + display: block; +} + +.footer-tagline { + font-size: var(--text-small); + color: rgba(255, 255, 255, 0.5); +} + +.footer-links { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-lg); +} + +.footer-column h4 { + font-size: var(--text-small); + font-weight: 600; + margin-bottom: var(--space-sm); + color: #fff; +} + +.footer-column a { + display: block; + font-size: var(--text-small); + color: rgba(255, 255, 255, 0.5); + margin-bottom: var(--space-xs); + transition: var(--transition); +} + +.footer-column a:hover { + color: var(--color-primary-light); +} + +.footer-bottom { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: var(--space-lg); + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.copyright, .made-by { + font-size: var(--text-small); + color: rgba(255, 255, 255, 0.4); +} + +/* =================================== + Animations + =================================== */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-in { + animation: fadeInUp 0.8s ease forwards; +} + +/* =================================== + Responsive Styles + =================================== */ +@media (max-width: 1024px) { + .hero-container { + grid-template-columns: 1fr; + text-align: center; + } + + .hero-content { + max-width: 100%; + } + + .hero-cta { + justify-content: center; + } + + .hero-visual { + order: -1; + margin-bottom: var(--space-lg); + } + + .phone-frame { + width: 240px; + height: 500px; + } + + .feature { + grid-template-columns: 1fr; + text-align: center; + gap: var(--space-xl); + } + + .feature-content { + max-width: 100%; + } + + .feature-right .feature-content, + .feature-right .feature-visual { + order: unset; + } + + .highlights-grid { + grid-template-columns: 1fr; + max-width: 400px; + margin: 0 auto; + } + + .footer-content { + grid-template-columns: 1fr; + text-align: center; + } + + .footer-links { + grid-template-columns: repeat(3, 1fr); + text-align: left; + } + + .footer-bottom { + flex-direction: column; + gap: var(--space-xs); + } +} + +@media (max-width: 768px) { + .nav-links { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + flex-direction: column; + background: var(--color-bg-dark); + padding: var(--space-md); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + gap: var(--space-sm); + } + + .nav-links.active { + display: flex; + } + + .nav-cta { + display: none; + } + + .nav-toggle { + display: flex; + } + + .hero { + padding: calc(70px + var(--space-2xl)) 0 var(--space-2xl); + } + + .hero-cta { + flex-direction: column; + } + + .hero-cta .btn { + width: 100%; + } + + .device-showcase { + flex-direction: column; + align-items: center; + gap: var(--space-xl); + } + + .footer-links { + grid-template-columns: 1fr; + text-align: center; + } + + .cta-buttons { + flex-direction: column; + } + + .cta-buttons .btn { + width: 100%; + } +} + +@media (max-width: 480px) { + .phone-frame { + width: 220px; + height: 460px; + border-radius: 36px; + } + + .phone-frame::before { + width: 70px; + height: 20px; + } + + .phone-screen { + border-radius: 28px; + } + + .device-showcase .phone-frame { + width: 200px; + height: 420px; + } +} diff --git a/static/images/favicon.svg b/static/images/favicon.svg new file mode 100644 index 0000000..d0cba5c --- /dev/null +++ b/static/images/favicon.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/images/icon.png b/static/images/icon.png new file mode 100644 index 0000000..a5215d3 Binary files /dev/null and b/static/images/icon.png differ diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..61314d8 --- /dev/null +++ b/static/index.html @@ -0,0 +1,366 @@ + + + + + + Casera - Home Maintenance & Property Management App + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ Property Management Made Simple +

Never miss home maintenance again

+

Manage properties, tasks, and costs all in one place. Track maintenance, organize contractors, and keep your home running smoothly.

+ + + +

Free to download • No credit card required

+
+ +
+
+
+
+
+ App Screenshot +
+
+
+
+
+
+
+ + +
+
+
+
+
+ + + + +
+

Unlimited Properties

+

Manage homes, rentals, and investments

+
+ +
+
+ + + + +
+

Smart Scheduling

+

Recurring tasks that never slip

+
+ +
+
+ + + + +
+

Cost Tracking

+

Know exactly what you spend

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

All your properties in one place

+

Manage unlimited residential and commercial properties. Track details like bedrooms, bathrooms, square footage, and more. Perfect for homeowners, landlords, and property managers.

+
+
+
+
+
+
+ Properties Screen +
+
+
+
+
+
+ + +
+
+

Never forget maintenance again

+

Create one-time or recurring tasks. Organize by category — plumbing, electrical, HVAC, landscaping. Get reminders before things break. View everything in a visual kanban board.

+
+
+
+
+
+
+ Tasks Screen +
+
+
+
+
+
+ + +
+
+

Your trusted pros, organized

+

Keep a directory of service providers with contact info, specialties, ratings, and work history. Quickly call, email, or get directions to any contractor. Never lose a business card again.

+
+
+
+
+
+
+ Contractors Screen +
+
+
+
+
+
+ + +
+
+

Important records, always accessible

+

Store warranties, manuals, and maintenance records. Track expiration dates and get alerts before warranties expire. Generate PDF reports for insurance claims or property sales.

+
+
+
+
+
+
+ Documents Screen +
+
+
+
+
+
+
+
+ + +
+
+
+

Beautiful on every device

+

Available for iPhone, iPad, and Android. Your data syncs seamlessly across all your devices.

+
+ +
+
+
+
+
+
+ iOS +
+
+
+ iPhone +
+ +
+
+
+
+ Android +
+
+
+ Android +
+
+
+
+
+ + + + + +
+
+
+

Take control of your home

+

Join homeowners who never miss maintenance again.

+ + + +

Free download • Works offline • Your data stays private

+
+
+
+ + + + + + + diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..4440d5e --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,82 @@ +/** + * Casera Landing Page JavaScript + */ + +document.addEventListener('DOMContentLoaded', function() { + // Mobile Navigation Toggle + const navToggle = document.getElementById('nav-toggle'); + const navLinks = document.getElementById('nav-links'); + + if (navToggle && navLinks) { + navToggle.addEventListener('click', function() { + navLinks.classList.toggle('active'); + navToggle.classList.toggle('active'); + }); + + // Close menu when clicking a link + navLinks.querySelectorAll('.nav-link').forEach(link => { + link.addEventListener('click', () => { + navLinks.classList.remove('active'); + navToggle.classList.remove('active'); + }); + }); + } + + // Smooth Scroll for Anchor Links + document.querySelectorAll('a[href^="#"]').forEach(anchor => { + anchor.addEventListener('click', function(e) { + const href = this.getAttribute('href'); + if (href === '#') return; + + e.preventDefault(); + const target = document.querySelector(href); + + if (target) { + const navHeight = document.querySelector('.nav').offsetHeight; + const targetPosition = target.getBoundingClientRect().top + window.pageYOffset - navHeight; + + window.scrollTo({ + top: targetPosition, + behavior: 'smooth' + }); + } + }); + }); + + // Scroll Animation Observer + const observerOptions = { + threshold: 0.1, + rootMargin: '0px 0px -50px 0px' + }; + + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + entry.target.classList.add('animate-in'); + observer.unobserve(entry.target); + } + }); + }, observerOptions); + + // Observe elements for animation + document.querySelectorAll('.feature, .highlight-card, .testimonial-card, .platforms-content').forEach(el => { + el.style.opacity = '0'; + observer.observe(el); + }); + + // Nav Background on Scroll + const nav = document.querySelector('.nav'); + let lastScroll = 0; + + window.addEventListener('scroll', () => { + const currentScroll = window.pageYOffset; + + if (currentScroll > 50) { + nav.style.boxShadow = 'var(--shadow-md)'; + } else { + nav.style.boxShadow = 'none'; + } + + lastScroll = currentScroll; + }); +}); diff --git a/templates/emails/base.html b/templates/emails/base.html new file mode 100644 index 0000000..a7e6175 --- /dev/null +++ b/templates/emails/base.html @@ -0,0 +1,329 @@ + + + + + + + {{.Subject}} + + + + + {{.Content}} + +