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:
@@ -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
|
||||
|
||||
281
docs/Secure Media Access Control.md
Normal file
281
docs/Secure Media Access Control.md
Normal 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)
|
||||
@@ -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`
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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;">© %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>© %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>© %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;">🏠 <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;">✅ <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;">👷 <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;">📄 <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>© %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>© %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>© %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>© %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>© %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
|
||||
|
||||
@@ -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
1082
static/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
26
static/images/favicon.svg
Normal file
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
BIN
static/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 464 KiB |
366
static/index.html
Normal file
366
static/index.html
Normal 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 • 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 — 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 • Works offline • 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">© 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
82
static/js/main.js
Normal 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
329
templates/emails/base.html
Normal 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>
|
||||
Reference in New Issue
Block a user