Add landing page, redesign emails, and return updated task on completion

- Integrate landing page into Go app (served at root /)
- Add STATIC_DIR config for static file serving
- Redesign all email templates with modern dark theme styling
- Add app icon to email headers
- Return updated task with kanban_column in completion response
- Update task DTO to include kanban column for client-side state updates

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-02 21:33:17 -06:00
parent 76579e8bf8
commit 3419b66097
15 changed files with 2689 additions and 283 deletions

View File

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

View File

@@ -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)

View File

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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)

View File

@@ -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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>%s</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
</head>
<body style="margin: 0; padding: 0; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(180deg, #0F172A 0%%, #1E293B 100%%); -webkit-font-smoothing: antialiased;">
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="background: linear-gradient(180deg, #0F172A 0%%, #1E293B 100%%);">
<tr>
<td style="padding: 40px 20px;">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width: 600px; margin: 0 auto; background: #1E293B; border-radius: 16px; overflow: hidden; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3);">
%s
</table>
</td>
</tr>
</table>
</body>
</html>`
}
// 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(`
<!-- Header -->
<tr>
<td style="background: linear-gradient(135deg, #0079FF 0%%, #5AC7F9 50%%, #14B8A6 100%%); padding: 40px 30px; text-align: center;">
<!-- Logo Icon -->
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 0 auto 16px auto;">
<tr>
<td style="background: rgba(255, 255, 255, 0.15); border-radius: 20px; padding: 4px;">
<img src="%s" alt="Casera" width="64" height="64" style="display: block; border-radius: 16px; border: 0;" />
</td>
</tr>
</table>
<h1 style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 28px; font-weight: 800; color: #FFFFFF; margin: 0; letter-spacing: -0.5px;">Casera</h1>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 20px; font-weight: 600; color: rgba(255, 255, 255, 0.95); margin: 12px 0 0 0;">%s</p>
</td>
</tr>`, emailIconURL, title)
}
// emailFooter returns the footer section
func emailFooter(year int) string {
return fmt.Sprintf(`
<!-- Footer -->
<tr>
<td style="background: #0F172A; padding: 30px; text-align: center;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; color: rgba(255, 255, 255, 0.5); margin: 0;">&copy; %d Casera. All rights reserved.</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; color: rgba(255, 255, 255, 0.4); margin: 12px 0 0 0;">Never miss home maintenance again.</p>
</td>
</tr>`, 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(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { text-align: center; padding: 20px 0; }
.code { background: #f4f4f4; padding: 20px; text-align: center; font-size: 32px; font-weight: bold; letter-spacing: 8px; border-radius: 8px; margin: 20px 0; }
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 40px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Welcome to Casera!</h1>
</div>
<p>Hi %s,</p>
<p>Thank you for creating a Casera account. To complete your registration, please verify your email address by entering the following code:</p>
<div class="code">%s</div>
<p>This code will expire in 24 hours.</p>
<p>If you didn't create a Casera account, you can safely ignore this email.</p>
<p>Best regards,<br>The Casera Team</p>
<div class="footer">
<p>&copy; %d Casera. All rights reserved.</p>
</div>
</div>
</body>
</html>
`, name, code, time.Now().Year())
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<tr>
<td style="background: #FFFFFF; padding: 40px 30px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 18px; font-weight: 600; color: #1a1a1a; margin: 0 0 20px 0;">Hi %s,</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; line-height: 1.6; color: #4B5563; margin: 0 0 20px 0;">Thank you for creating a Casera account! To complete your registration, please verify your email address by entering the code below:</p>
<!-- Code Box -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0">
<tr>
<td style="background: linear-gradient(135deg, rgba(0, 121, 255, 0.1) 0%%, rgba(20, 184, 166, 0.1) 100%%); border: 2px solid rgba(0, 121, 255, 0.2); border-radius: 12px; padding: 24px; text-align: center;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 36px; font-weight: 800; letter-spacing: 8px; color: #0079FF; margin: 0;">%s</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 13px; color: #9CA3AF; margin: 12px 0 0 0;">This code will expire in 24 hours</p>
</td>
</tr>
</table>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #6B7280; margin: 24px 0 0 0;">If you didn't create a Casera account, you can safely ignore this email.</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #6B7280; margin: 30px 0 0 0;">Best regards,<br><strong style="color: #1a1a1a;">The Casera Team</strong></p>
</td>
</tr>
%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(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { text-align: center; padding: 20px 0; }
.cta { background: #07A0C3; color: white; padding: 15px 30px; text-decoration: none; border-radius: 8px; display: inline-block; margin: 20px 0; }
.features { background: #f8f9fa; padding: 20px; border-radius: 8px; margin: 20px 0; }
.feature { margin: 10px 0; }
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 40px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Welcome to Casera!</h1>
</div>
<p>Hi %s,</p>
<p>Thank you for joining Casera! Your account has been created and you're ready to start managing your properties.</p>
<div class="features">
<h3>Here's what you can do with Casera:</h3>
<div class="feature">🏠 <strong>Manage Properties</strong> - Track all your homes and rentals in one place</div>
<div class="feature">✅ <strong>Task Management</strong> - Never miss maintenance with smart scheduling</div>
<div class="feature">👷 <strong>Contractor Directory</strong> - Keep your trusted pros organized</div>
<div class="feature">📄 <strong>Document Storage</strong> - Store warranties, manuals, and important records</div>
</div>
<p>If you have any questions, feel free to reach out to us at support@casera.app.</p>
<p>Best regards,<br>The Casera Team</p>
<div class="footer">
<p>&copy; %d Casera. All rights reserved.</p>
</div>
</div>
</body>
</html>
`, name, time.Now().Year())
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<tr>
<td style="background: #FFFFFF; padding: 40px 30px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 18px; font-weight: 600; color: #1a1a1a; margin: 0 0 20px 0;">Hi %s,</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; line-height: 1.6; color: #4B5563; margin: 0 0 24px 0;">Thank you for joining Casera! Your account has been created and you're ready to start managing your properties.</p>
<!-- Features Box -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0">
<tr>
<td style="background: #F8FAFC; border-radius: 12px; padding: 24px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; font-weight: 700; color: #1a1a1a; margin: 0 0 16px 0;">Here's what you can do with Casera:</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#127968; <strong>Manage Properties</strong> - Track all your homes and rentals in one place</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#9989; <strong>Task Management</strong> - Never miss maintenance with smart scheduling</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#128119; <strong>Contractor Directory</strong> - Keep your trusted pros organized</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#128196; <strong>Document Storage</strong> - Store warranties, manuals, and important records</p>
</td>
</tr>
</table>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #6B7280; margin: 24px 0 0 0;">If you have any questions, feel free to reach out to us at support@casera.app.</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #6B7280; margin: 30px 0 0 0;">Best regards,<br><strong style="color: #1a1a1a;">The Casera Team</strong></p>
</td>
</tr>
%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(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.code { background: #f4f4f4; padding: 20px; text-align: center; font-size: 32px; font-weight: bold; letter-spacing: 8px; border-radius: 8px; margin: 20px 0; }
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 40px; }
</style>
</head>
<body>
<div class="container">
<h1>Verify Your Email</h1>
<p>Hi %s,</p>
<p>Please use the following code to verify your email address:</p>
<div class="code">%s</div>
<p>This code will expire in 24 hours.</p>
<p>If you didn't request this, you can safely ignore this email.</p>
<p>Best regards,<br>The Casera Team</p>
<div class="footer">
<p>&copy; %d Casera. All rights reserved.</p>
</div>
</div>
</body>
</html>
`, name, code, time.Now().Year())
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<tr>
<td style="background: #FFFFFF; padding: 40px 30px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 18px; font-weight: 600; color: #1a1a1a; margin: 0 0 20px 0;">Hi %s,</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; line-height: 1.6; color: #4B5563; margin: 0 0 20px 0;">Please use the following code to verify your email address:</p>
<!-- Code Box -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0">
<tr>
<td style="background: linear-gradient(135deg, rgba(0, 121, 255, 0.1) 0%%, rgba(20, 184, 166, 0.1) 100%%); border: 2px solid rgba(0, 121, 255, 0.2); border-radius: 12px; padding: 24px; text-align: center;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 36px; font-weight: 800; letter-spacing: 8px; color: #0079FF; margin: 0;">%s</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 13px; color: #9CA3AF; margin: 12px 0 0 0;">This code will expire in 24 hours</p>
</td>
</tr>
</table>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #6B7280; margin: 24px 0 0 0;">If you didn't request this, you can safely ignore this email.</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #6B7280; margin: 30px 0 0 0;">Best regards,<br><strong style="color: #1a1a1a;">The Casera Team</strong></p>
</td>
</tr>
%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(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.code { background: #f4f4f4; padding: 20px; text-align: center; font-size: 32px; font-weight: bold; letter-spacing: 8px; border-radius: 8px; margin: 20px 0; }
.warning { background: #fff3cd; border: 1px solid #ffc107; padding: 15px; border-radius: 8px; margin: 20px 0; }
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 40px; }
</style>
</head>
<body>
<div class="container">
<h1>Password Reset Request</h1>
<p>Hi %s,</p>
<p>We received a request to reset your password. Use the following code to complete the reset:</p>
<div class="code">%s</div>
<p>This code will expire in 15 minutes.</p>
<div class="warning">
<strong>Security Notice:</strong> If you didn't request a password reset, please ignore this email. Your password will remain unchanged.
</div>
<p>Best regards,<br>The Casera Team</p>
<div class="footer">
<p>&copy; %d Casera. All rights reserved.</p>
</div>
</div>
</body>
</html>
`, name, code, time.Now().Year())
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<tr>
<td style="background: #FFFFFF; padding: 40px 30px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 18px; font-weight: 600; color: #1a1a1a; margin: 0 0 20px 0;">Hi %s,</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; line-height: 1.6; color: #4B5563; margin: 0 0 20px 0;">We received a request to reset your password. Use the following code to complete the reset:</p>
<!-- Code Box -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0">
<tr>
<td style="background: linear-gradient(135deg, rgba(0, 121, 255, 0.1) 0%%, rgba(20, 184, 166, 0.1) 100%%); border: 2px solid rgba(0, 121, 255, 0.2); border-radius: 12px; padding: 24px; text-align: center;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 36px; font-weight: 800; letter-spacing: 8px; color: #0079FF; margin: 0;">%s</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 13px; color: #9CA3AF; margin: 12px 0 0 0;">This code will expire in 15 minutes</p>
</td>
</tr>
</table>
<!-- Warning Box -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="margin: 24px 0;">
<tr>
<td style="background: linear-gradient(135deg, rgba(255, 148, 0, 0.1) 0%%, rgba(236, 72, 153, 0.05) 100%%); border-left: 4px solid #FF9400; border-radius: 8px; padding: 16px 20px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; font-weight: 700; color: #E68600; margin: 0 0 8px 0;">Security Notice</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 0;">If you didn't request a password reset, please ignore this email. Your password will remain unchanged.</p>
</td>
</tr>
</table>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #6B7280; margin: 30px 0 0 0;">Best regards,<br><strong style="color: #1a1a1a;">The Casera Team</strong></p>
</td>
</tr>
%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(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.warning { background: #fff3cd; border: 1px solid #ffc107; padding: 15px; border-radius: 8px; margin: 20px 0; }
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 40px; }
</style>
</head>
<body>
<div class="container">
<h1>Password Changed</h1>
<p>Hi %s,</p>
<p>Your Casera password was successfully changed on %s.</p>
<div class="warning">
<strong>Didn't make this change?</strong> If you didn't change your password, please contact us immediately at support@casera.app or reset your password.
</div>
<p>Best regards,<br>The Casera Team</p>
<div class="footer">
<p>&copy; %d Casera. All rights reserved.</p>
</div>
</div>
</body>
</html>
`, 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
<!-- Body -->
<tr>
<td style="background: #FFFFFF; padding: 40px 30px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 18px; font-weight: 600; color: #1a1a1a; margin: 0 0 20px 0;">Hi %s,</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; line-height: 1.6; color: #4B5563; margin: 0 0 24px 0;">Your Casera password was successfully changed on <strong>%s</strong>.</p>
<!-- Warning Box -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="margin: 24px 0;">
<tr>
<td style="background: linear-gradient(135deg, rgba(255, 148, 0, 0.1) 0%%, rgba(236, 72, 153, 0.05) 100%%); border-left: 4px solid #FF9400; border-radius: 8px; padding: 16px 20px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; font-weight: 700; color: #E68600; margin: 0 0 8px 0;">Didn't make this change?</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 0;">If you didn't change your password, please contact us immediately at support@casera.app or reset your password.</p>
</td>
</tr>
</table>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #6B7280; margin: 30px 0 0 0;">Best regards,<br><strong style="color: #1a1a1a;">The Casera Team</strong></p>
</td>
</tr>
%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(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { text-align: center; padding: 20px 0; }
.task-box { background: #d4edda; border: 1px solid #c3e6cb; padding: 20px; border-radius: 8px; margin: 20px 0; }
.task-title { font-size: 18px; font-weight: bold; color: #155724; margin: 0; }
.task-meta { color: #666; font-size: 14px; margin-top: 10px; }
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 40px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Task Completed!</h1>
</div>
<p>Hi %s,</p>
<p>A task has been completed at <strong>%s</strong>:</p>
<div class="task-box">
<p class="task-title">%s</p>
<p class="task-meta">Completed by: %s<br>Completed on: %s</p>
</div>
<p>Best regards,<br>The Casera Team</p>
<div class="footer">
<p>&copy; %d Casera. All rights reserved.</p>
</div>
</div>
</body>
</html>
`, 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
<!-- Body -->
<tr>
<td style="background: #FFFFFF; padding: 40px 30px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 18px; font-weight: 600; color: #1a1a1a; margin: 0 0 20px 0;">Hi %s,</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; line-height: 1.6; color: #4B5563; margin: 0 0 24px 0;">A task has been completed at <strong>%s</strong>:</p>
<!-- Success Box -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0">
<tr>
<td style="background: linear-gradient(135deg, rgba(34, 197, 94, 0.1) 0%%, rgba(20, 184, 166, 0.1) 100%%); border-left: 4px solid #22C55E; border-radius: 8px; padding: 20px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 18px; font-weight: 700; color: #15803d; margin: 0 0 8px 0;">%s</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 13px; color: #6B7280; margin: 8px 0 0 0;">Completed by: %s<br>Completed on: %s</p>
</td>
</tr>
</table>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #6B7280; margin: 30px 0 0 0;">Best regards,<br><strong style="color: #1a1a1a;">The Casera Team</strong></p>
</td>
</tr>
%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(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { text-align: center; padding: 20px 0; }
.summary-box { background: #f8f9fa; border: 1px solid #e9ecef; padding: 20px; border-radius: 8px; margin: 20px 0; }
.summary-grid { display: flex; flex-wrap: wrap; gap: 20px; }
.summary-item { flex: 1; min-width: 80px; text-align: center; }
.summary-number { font-size: 28px; font-weight: bold; color: #333; }
.summary-label { font-size: 12px; color: #666; text-transform: uppercase; }
.completed { color: #28a745; }
.pending { color: #ffc107; }
.overdue { color: #dc3545; }
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 40px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Tasks Report</h1>
<p style="color: #666; margin: 0;">%s</p>
</div>
<p>Hi %s,</p>
<p>Here's your tasks report for <strong>%s</strong>. The full report is attached as a PDF.</p>
<div class="summary-box">
<h3 style="margin-top: 0;">Summary</h3>
<table width="100%%" cellpadding="10" cellspacing="0">
<tr>
<td align="center" style="border-right: 1px solid #e9ecef;">
<div class="summary-number">%d</div>
<div class="summary-label">Total Tasks</div>
</td>
<td align="center" style="border-right: 1px solid #e9ecef;">
<div class="summary-number completed">%d</div>
<div class="summary-label">Completed</div>
</td>
<td align="center" style="border-right: 1px solid #e9ecef;">
<div class="summary-number pending">%d</div>
<div class="summary-label">Pending</div>
</td>
<td align="center">
<div class="summary-number overdue">%d</div>
<div class="summary-label">Overdue</div>
</td>
</tr>
</table>
</div>
<p>Open the attached PDF for the complete list of tasks with details.</p>
<p>Best regards,<br>The Casera Team</p>
<div class="footer">
<p>&copy; %d Casera. All rights reserved.</p>
</div>
</div>
</body>
</html>
`, residenceName, name, residenceName, totalTasks, completed, pending, overdue, time.Now().Year())
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<tr>
<td style="background: #FFFFFF; padding: 40px 30px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 18px; font-weight: 600; color: #1a1a1a; margin: 0 0 20px 0;">Hi %s,</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; line-height: 1.6; color: #4B5563; margin: 0 0 24px 0;">Here's your tasks report for <strong>%s</strong>. The full report is attached as a PDF.</p>
<!-- Summary Box -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="margin: 24px 0;">
<tr>
<td style="background: #F8FAFC; border-radius: 12px; padding: 24px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; font-weight: 700; color: #1a1a1a; margin: 0 0 16px 0;">Summary</p>
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0">
<tr>
<td width="25%%" style="text-align: center; padding: 12px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 28px; font-weight: 800; color: #1a1a1a; margin: 0;">%d</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 11px; font-weight: 600; color: #9CA3AF; text-transform: uppercase; letter-spacing: 0.5px; margin: 4px 0 0 0;">Total</p>
</td>
<td width="25%%" style="text-align: center; padding: 12px; border-left: 1px solid #E5E7EB;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 28px; font-weight: 800; color: #22C55E; margin: 0;">%d</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 11px; font-weight: 600; color: #9CA3AF; text-transform: uppercase; letter-spacing: 0.5px; margin: 4px 0 0 0;">Completed</p>
</td>
<td width="25%%" style="text-align: center; padding: 12px; border-left: 1px solid #E5E7EB;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 28px; font-weight: 800; color: #FF9400; margin: 0;">%d</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 11px; font-weight: 600; color: #9CA3AF; text-transform: uppercase; letter-spacing: 0.5px; margin: 4px 0 0 0;">Pending</p>
</td>
<td width="25%%" style="text-align: center; padding: 12px; border-left: 1px solid #E5E7EB;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 28px; font-weight: 800; color: #EF4444; margin: 0;">%d</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 11px; font-weight: 600; color: #9CA3AF; text-transform: uppercase; letter-spacing: 0.5px; margin: 4px 0 0 0;">Overdue</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #6B7280; margin: 0 0 24px 0;">Open the attached PDF for the complete list of tasks with details.</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #6B7280; margin: 30px 0 0 0;">Best regards,<br><strong style="color: #1a1a1a;">The Casera Team</strong></p>
</td>
</tr>
%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

View File

@@ -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
}

1082
static/css/style.css Normal file

File diff suppressed because it is too large Load Diff

26
static/images/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
static/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

366
static/index.html Normal file
View File

@@ -0,0 +1,366 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Casera - Home Maintenance & Property Management App</title>
<meta name="description" content="Never miss home maintenance again. Manage properties, tasks, contractors, and costs all in one place. Free for iOS and Android.">
<!-- Open Graph -->
<meta property="og:title" content="Casera - Home Maintenance Made Simple">
<meta property="og:description" content="Manage properties, tasks, and costs all in one place.">
<meta property="og:image" content="images/og-image.png">
<meta property="og:type" content="website">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Casera - Home Maintenance Made Simple">
<meta name="twitter:description" content="Manage properties, tasks, and costs all in one place.">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Styles -->
<link rel="stylesheet" href="css/style.css">
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="images/favicon.svg">
</head>
<body>
<!-- Navigation -->
<nav class="nav" id="nav">
<div class="nav-container">
<a href="#" class="nav-logo">
<span class="logo-text">Casera</span>
</a>
<div class="nav-links" id="nav-links">
<a href="#features" class="nav-link">Features</a>
<a href="#platforms" class="nav-link">Platforms</a>
<a href="#" class="nav-link">Support</a>
</div>
<div class="nav-cta">
<a href="#download" class="btn btn-primary btn-sm">Download</a>
</div>
<button class="nav-toggle" id="nav-toggle" aria-label="Toggle menu">
<span></span>
<span></span>
<span></span>
</button>
</div>
</nav>
<!-- Hero Section -->
<section class="hero">
<div class="container hero-container">
<div class="hero-content">
<span class="hero-badge">Property Management Made Simple</span>
<h1 class="hero-title">Never miss home maintenance again</h1>
<p class="hero-subtitle">Manage properties, tasks, and costs all in one place. Track maintenance, organize contractors, and keep your home running smoothly.</p>
<div class="hero-cta">
<a href="#" class="btn btn-primary btn-lg">
<svg class="btn-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
</svg>
Download for iOS
</a>
<a href="#" class="btn btn-secondary btn-lg">
<svg class="btn-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M3,20.5V3.5C3,2.91 3.34,2.39 3.84,2.15L13.69,12L3.84,21.85C3.34,21.6 3,21.09 3,20.5M16.81,15.12L6.05,21.34L14.54,12.85L16.81,15.12M20.16,10.81C20.5,11.08 20.75,11.5 20.75,12C20.75,12.5 20.53,12.9 20.18,13.18L17.89,14.5L15.39,12L17.89,9.5L20.16,10.81M6.05,2.66L16.81,8.88L14.54,11.15L6.05,2.66Z"/>
</svg>
Download for Android
</a>
</div>
<p class="hero-trust">Free to download &bull; No credit card required</p>
</div>
<div class="hero-visual">
<div class="phone-mockup">
<div class="phone-frame">
<div class="phone-screen">
<div class="placeholder-content">
<span>App Screenshot</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Feature Highlights -->
<section class="highlights">
<div class="container">
<div class="highlights-grid">
<div class="highlight-card">
<div class="highlight-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
</div>
<h3 class="highlight-title">Unlimited Properties</h3>
<p class="highlight-desc">Manage homes, rentals, and investments</p>
</div>
<div class="highlight-card">
<div class="highlight-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
</div>
<h3 class="highlight-title">Smart Scheduling</h3>
<p class="highlight-desc">Recurring tasks that never slip</p>
</div>
<div class="highlight-card">
<div class="highlight-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="1" x2="12" y2="23"></line>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
</svg>
</div>
<h3 class="highlight-title">Cost Tracking</h3>
<p class="highlight-desc">Know exactly what you spend</p>
</div>
</div>
</div>
</section>
<!-- Features Section -->
<section class="features" id="features">
<div class="container">
<!-- Feature 1 -->
<div class="feature feature-left">
<div class="feature-content">
<h2 class="feature-title">All your properties in one place</h2>
<p class="feature-desc">Manage unlimited residential and commercial properties. Track details like bedrooms, bathrooms, square footage, and more. Perfect for homeowners, landlords, and property managers.</p>
</div>
<div class="feature-visual">
<div class="phone-mockup">
<div class="phone-frame">
<div class="phone-screen">
<div class="placeholder-content">
<span>Properties Screen</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Feature 2 -->
<div class="feature feature-right">
<div class="feature-content">
<h2 class="feature-title">Never forget maintenance again</h2>
<p class="feature-desc">Create one-time or recurring tasks. Organize by category &mdash; plumbing, electrical, HVAC, landscaping. Get reminders before things break. View everything in a visual kanban board.</p>
</div>
<div class="feature-visual">
<div class="phone-mockup">
<div class="phone-frame">
<div class="phone-screen">
<div class="placeholder-content">
<span>Tasks Screen</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Feature 3 -->
<div class="feature feature-left">
<div class="feature-content">
<h2 class="feature-title">Your trusted pros, organized</h2>
<p class="feature-desc">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.</p>
</div>
<div class="feature-visual">
<div class="phone-mockup">
<div class="phone-frame">
<div class="phone-screen">
<div class="placeholder-content">
<span>Contractors Screen</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Feature 4 -->
<div class="feature feature-right">
<div class="feature-content">
<h2 class="feature-title">Important records, always accessible</h2>
<p class="feature-desc">Store warranties, manuals, and maintenance records. Track expiration dates and get alerts before warranties expire. Generate PDF reports for insurance claims or property sales.</p>
</div>
<div class="feature-visual">
<div class="phone-mockup">
<div class="phone-frame">
<div class="phone-screen">
<div class="placeholder-content">
<span>Documents Screen</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Platform Section -->
<section class="platforms" id="platforms">
<div class="container">
<div class="platforms-content">
<h2 class="section-title">Beautiful on every device</h2>
<p class="section-subtitle">Available for iPhone, iPad, and Android. Your data syncs seamlessly across all your devices.</p>
</div>
<div class="platforms-visual">
<div class="device-showcase">
<div class="phone-mockup phone-ios">
<div class="phone-frame">
<div class="phone-screen">
<div class="placeholder-content">
<span>iOS</span>
</div>
</div>
</div>
<span class="device-label">iPhone</span>
</div>
<div class="phone-mockup phone-android">
<div class="phone-frame">
<div class="phone-screen">
<div class="placeholder-content">
<span>Android</span>
</div>
</div>
</div>
<span class="device-label">Android</span>
</div>
</div>
</div>
</div>
</section>
<!-- Testimonials Section (Hidden until real testimonials) -->
<!--
<section class="testimonials">
<div class="container">
<h2 class="section-title">What people are saying</h2>
<div class="testimonials-grid">
<div class="testimonial-card">
<p class="testimonial-quote">"Finally, an app that makes home maintenance simple. I've tried dozens of apps, but Casera is the only one that stuck."</p>
<div class="testimonial-author">
<div class="author-avatar"></div>
<div class="author-info">
<span class="author-name">Placeholder Name</span>
<span class="author-title">Homeowner</span>
</div>
</div>
</div>
<div class="testimonial-card">
<p class="testimonial-quote">"As a landlord with multiple properties, Casera has been a game changer. Everything I need is in one place."</p>
<div class="testimonial-author">
<div class="author-avatar"></div>
<div class="author-info">
<span class="author-name">Placeholder Name</span>
<span class="author-title">Property Manager</span>
</div>
</div>
</div>
<div class="testimonial-card">
<p class="testimonial-quote">"The recurring task feature alone is worth it. I never forget to change HVAC filters or schedule seasonal maintenance anymore."</p>
<div class="testimonial-author">
<div class="author-avatar"></div>
<div class="author-info">
<span class="author-name">Placeholder Name</span>
<span class="author-title">First-time Homeowner</span>
</div>
</div>
</div>
</div>
</div>
</section>
-->
<!-- Final CTA Section -->
<section class="cta" id="download">
<div class="container">
<div class="cta-content">
<h2 class="cta-title">Take control of your home</h2>
<p class="cta-subtitle">Join homeowners who never miss maintenance again.</p>
<div class="cta-buttons">
<a href="#" class="btn btn-white btn-lg">
<svg class="btn-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
</svg>
Download for iOS
</a>
<a href="#" class="btn btn-outline-white btn-lg">
<svg class="btn-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M3,20.5V3.5C3,2.91 3.34,2.39 3.84,2.15L13.69,12L3.84,21.85C3.34,21.6 3,21.09 3,20.5M16.81,15.12L6.05,21.34L14.54,12.85L16.81,15.12M20.16,10.81C20.5,11.08 20.75,11.5 20.75,12C20.75,12.5 20.53,12.9 20.18,13.18L17.89,14.5L15.39,12L17.89,9.5L20.16,10.81M6.05,2.66L16.81,8.88L14.54,11.15L6.05,2.66Z"/>
</svg>
Download for Android
</a>
</div>
<p class="cta-trust">Free download &bull; Works offline &bull; Your data stays private</p>
</div>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="container">
<div class="footer-content">
<div class="footer-brand">
<span class="logo-text">Casera</span>
<p class="footer-tagline">Never miss home maintenance again.</p>
</div>
<div class="footer-links">
<div class="footer-column">
<h4>Product</h4>
<a href="#features">Features</a>
<a href="#platforms">Platforms</a>
<a href="#download">Download</a>
</div>
<div class="footer-column">
<h4>Support</h4>
<a href="#">Help Center</a>
<a href="#">Contact</a>
<a href="#">FAQ</a>
</div>
<div class="footer-column">
<h4>Legal</h4>
<a href="#">Privacy Policy</a>
<a href="#">Terms of Service</a>
</div>
</div>
</div>
<div class="footer-bottom">
<p class="copyright">&copy; 2024 Casera. All rights reserved.</p>
<p class="made-by">Made with care</p>
</div>
</div>
</footer>
<script src="js/main.js"></script>
</body>
</html>

82
static/js/main.js Normal file
View File

@@ -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;
});
});

329
templates/emails/base.html Normal file
View File

@@ -0,0 +1,329 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{.Subject}}</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<style>
/* Reset */
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
}
body {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
}
/* Dark theme base */
.email-wrapper {
background: linear-gradient(180deg, #0F172A 0%, #1E293B 100%);
padding: 40px 20px;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background: #1E293B;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3);
}
/* Header with gradient */
.email-header {
background: linear-gradient(135deg, #0079FF 0%, #5AC7F9 50%, #14B8A6 100%);
padding: 40px 30px;
text-align: center;
}
.logo-text {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 28px;
font-weight: 800;
color: #FFFFFF;
margin: 0;
letter-spacing: -0.5px;
}
.header-title {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 24px;
font-weight: 700;
color: #FFFFFF;
margin: 16px 0 0 0;
}
/* Body content */
.email-body {
padding: 40px 30px;
background: #FFFFFF;
}
.greeting {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 18px;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 20px 0;
}
.body-text {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 16px;
line-height: 1.6;
color: #4B5563;
margin: 0 0 20px 0;
}
/* Code box for verification codes */
.code-box {
background: linear-gradient(135deg, rgba(0, 121, 255, 0.1) 0%, rgba(20, 184, 166, 0.1) 100%);
border: 2px solid rgba(0, 121, 255, 0.2);
border-radius: 12px;
padding: 24px;
text-align: center;
margin: 30px 0;
}
.verification-code {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 36px;
font-weight: 800;
letter-spacing: 8px;
color: #0079FF;
margin: 0;
}
.code-expiry {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
color: #9CA3AF;
margin: 12px 0 0 0;
}
/* Feature box */
.feature-box {
background: #F8FAFC;
border-radius: 12px;
padding: 24px;
margin: 24px 0;
}
.feature-title {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 16px;
font-weight: 700;
color: #1a1a1a;
margin: 0 0 16px 0;
}
.feature-item {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
color: #4B5563;
margin: 12px 0;
padding-left: 8px;
}
.feature-icon {
margin-right: 8px;
}
/* Warning box */
.warning-box {
background: linear-gradient(135deg, rgba(255, 148, 0, 0.1) 0%, rgba(236, 72, 153, 0.05) 100%);
border-left: 4px solid #FF9400;
border-radius: 8px;
padding: 16px 20px;
margin: 24px 0;
}
.warning-title {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
font-weight: 700;
color: #E68600;
margin: 0 0 8px 0;
}
.warning-text {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
color: #4B5563;
margin: 0;
}
/* Success box */
.success-box {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.1) 0%, rgba(20, 184, 166, 0.1) 100%);
border-left: 4px solid #22C55E;
border-radius: 8px;
padding: 20px;
margin: 24px 0;
}
.success-title {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 16px;
font-weight: 700;
color: #15803d;
margin: 0 0 8px 0;
}
.success-meta {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
color: #6B7280;
margin: 8px 0 0 0;
}
/* Stats grid */
.stats-grid {
margin: 24px 0;
}
.stat-item {
display: inline-block;
text-align: center;
padding: 16px 24px;
background: #F8FAFC;
border-radius: 8px;
margin: 4px;
}
.stat-number {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 28px;
font-weight: 800;
color: #1a1a1a;
display: block;
}
.stat-number.completed { color: #22C55E; }
.stat-number.pending { color: #FF9400; }
.stat-number.overdue { color: #EF4444; }
.stat-label {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 11px;
font-weight: 600;
color: #9CA3AF;
text-transform: uppercase;
letter-spacing: 0.5px;
display: block;
margin-top: 4px;
}
/* Button */
.button-container {
text-align: center;
margin: 30px 0;
}
.btn-primary {
display: inline-block;
background: linear-gradient(135deg, #0079FF 0%, #5AC7F9 50%, #14B8A6 100%);
color: #FFFFFF !important;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 16px;
font-weight: 600;
text-decoration: none;
padding: 14px 32px;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0, 121, 255, 0.4);
}
/* Signature */
.signature {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
color: #6B7280;
margin: 30px 0 0 0;
}
.signature strong {
color: #1a1a1a;
}
/* Footer */
.email-footer {
background: #0F172A;
padding: 30px;
text-align: center;
}
.footer-text {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
margin: 0;
}
.footer-links {
margin: 16px 0 0 0;
}
.footer-link {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
text-decoration: none;
margin: 0 12px;
}
.footer-link:hover {
color: #5AC7F9;
}
/* Mobile responsiveness */
@media only screen and (max-width: 600px) {
.email-wrapper {
padding: 20px 10px;
}
.email-header {
padding: 30px 20px;
}
.email-body {
padding: 30px 20px;
}
.verification-code {
font-size: 28px;
letter-spacing: 6px;
}
.stat-item {
padding: 12px 16px;
}
.stat-number {
font-size: 24px;
}
}
</style>
</head>
<body style="margin: 0; padding: 0;">
{{.Content}}
</body>
</html>