feat: complete Phase 3 — advanced features for Casera web app

Adds sharing (residence share codes, join, user management, .casera file
export/import), subscription status with feature comparison, notification
preferences with bell icon, profile settings (edit info, change password,
theme picker, delete account), onboarding wizard with create/join paths,
enhanced dashboard with stats cards, Recharts completion chart, recent
activity feed, and task report PDF download.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-03 09:31:29 -06:00
commit 5a50d77515
183 changed files with 34450 additions and 0 deletions
+297
View File
@@ -0,0 +1,297 @@
// ---------------------------------------------------------------------------
// Auth API client (client-side)
// ---------------------------------------------------------------------------
// Login & logout go through dedicated Next.js route handlers that manage the
// httpOnly cookie. All other auth routes use the catch-all proxy.
// ---------------------------------------------------------------------------
import { ApiError } from './client';
// ---------------------------------------------------------------------------
// Request / response shapes (inline; will unify with @/lib/types later)
// TODO: import from @/lib/types once the shared types package is finalised
// ---------------------------------------------------------------------------
export interface LoginRequest {
username?: string;
email?: string;
password: string;
}
/** Login response after the route handler strips the raw token. */
export interface LoginResponse {
user: UserResponse;
}
export interface RegisterRequest {
username: string;
email: string;
password: string;
first_name?: string;
last_name?: string;
}
export interface RegisterResponse {
token: string;
user: UserResponse;
}
export interface UserResponse {
id: number;
username: string;
email: string;
first_name: string;
last_name: string;
is_email_verified: boolean;
profile_image_url?: string;
date_joined: string;
}
export interface UpdateProfileRequest {
email?: string;
first_name?: string;
last_name?: string;
}
export interface ForgotPasswordRequest {
email: string;
}
export interface VerifyResetCodeRequest {
email: string;
code: string;
}
export interface VerifyResetCodeResponse {
message: string;
reset_token: string;
}
export interface ResetPasswordRequest {
reset_token: string;
new_password: string;
}
export interface VerifyEmailRequest {
code: string;
}
export interface VerifyEmailResponse {
message: string;
verified: boolean;
}
export interface MessageResponse {
message: string;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function handleResponse<T>(res: Response): Promise<T> {
const body = await res.json().catch(() => ({ error: 'Unknown error' }));
if (!res.ok) {
throw new ApiError(
res.status,
body.message || body.error || 'Request failed',
body.details,
);
}
return body as T;
}
function timezone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
// ---------------------------------------------------------------------------
// Public API functions
// ---------------------------------------------------------------------------
/**
* Log in with username/email + password.
* Uses the dedicated `/api/auth/login` route handler which sets the httpOnly
* cookie and strips the token from the response.
*/
export async function login(credentials: LoginRequest): Promise<LoginResponse> {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
body: JSON.stringify(credentials),
});
return handleResponse<LoginResponse>(res);
}
/**
* Register a new account.
* Goes through the proxy; the token is returned in the response body
* (caller should follow up with `login` to set the cookie).
*/
export async function register(
data: RegisterRequest,
): Promise<RegisterResponse> {
const res = await fetch('/api/proxy/auth/register/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
body: JSON.stringify(data),
});
return handleResponse<RegisterResponse>(res);
}
/**
* Log out. Clears the httpOnly cookie and invalidates the token server-side.
*/
export async function logout(): Promise<MessageResponse> {
const res = await fetch('/api/auth/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
return handleResponse<MessageResponse>(res);
}
/**
* Get the currently authenticated user.
*/
export async function getCurrentUser(): Promise<UserResponse> {
const res = await fetch('/api/auth/me', {
headers: { 'Content-Type': 'application/json' },
});
return handleResponse<UserResponse>(res);
}
/**
* Update the authenticated user's profile.
*/
export async function updateProfile(
data: UpdateProfileRequest,
): Promise<UserResponse> {
const res = await fetch('/api/proxy/auth/profile/', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
body: JSON.stringify(data),
});
return handleResponse<UserResponse>(res);
}
/**
* Verify the user's email with a 6-digit code.
*/
export async function verifyEmail(
data: VerifyEmailRequest,
): Promise<VerifyEmailResponse> {
const res = await fetch('/api/proxy/auth/verify-email/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
body: JSON.stringify(data),
});
return handleResponse<VerifyEmailResponse>(res);
}
/**
* Resend the email verification code.
*/
export async function resendVerification(): Promise<MessageResponse> {
const res = await fetch('/api/proxy/auth/resend-verification/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
});
return handleResponse<MessageResponse>(res);
}
/**
* Request a password reset email.
*/
export async function forgotPassword(
data: ForgotPasswordRequest,
): Promise<MessageResponse> {
const res = await fetch('/api/proxy/auth/forgot-password/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
body: JSON.stringify(data),
});
return handleResponse<MessageResponse>(res);
}
/**
* Verify the 6-digit reset code; returns a reset token.
*/
export async function verifyResetCode(
data: VerifyResetCodeRequest,
): Promise<VerifyResetCodeResponse> {
const res = await fetch('/api/proxy/auth/verify-reset-code/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
body: JSON.stringify(data),
});
return handleResponse<VerifyResetCodeResponse>(res);
}
/**
* Reset password using the token from `verifyResetCode`.
*/
export async function resetPassword(
data: ResetPasswordRequest,
): Promise<MessageResponse> {
const res = await fetch('/api/proxy/auth/reset-password/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
body: JSON.stringify(data),
});
return handleResponse<MessageResponse>(res);
}
/**
* Change the authenticated user's password.
*/
export async function changePassword(
data: { current_password: string; new_password: string },
): Promise<MessageResponse> {
const res = await fetch('/api/proxy/auth/change-password/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
body: JSON.stringify(data),
});
return handleResponse<MessageResponse>(res);
}
/**
* Delete the authenticated user's account permanently.
*/
export async function deleteAccount(): Promise<MessageResponse> {
const res = await fetch('/api/proxy/auth/delete-account/', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
});
return handleResponse<MessageResponse>(res);
}
+141
View File
@@ -0,0 +1,141 @@
// ---------------------------------------------------------------------------
// Base API client for Casera web app
// ---------------------------------------------------------------------------
// All client-side requests go through Next.js API route handlers (proxy).
// The proxy reads the httpOnly `casera-token` cookie and forwards it to the
// Go API as an Authorization header. This avoids exposing the token to JS.
// ---------------------------------------------------------------------------
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_URL || 'https://mycrib.treytartt.com/api';
/**
* Server-only base URL. Falls back to the public one so that server
* components / route handlers can reach the Go API directly.
*/
const SERVER_API_URL = process.env.API_URL || API_BASE_URL;
// ---------------------------------------------------------------------------
// Error class
// ---------------------------------------------------------------------------
export class ApiError extends Error {
constructor(
public status: number,
message: string,
public details?: Record<string, string[]>,
) {
super(message);
this.name = 'ApiError';
}
}
// ---------------------------------------------------------------------------
// Client-side fetcher (calls Next.js /api/proxy/... routes)
// ---------------------------------------------------------------------------
/**
* Client-side authenticated fetch. Calls the Next.js catch-all proxy which
* attaches the auth token from the httpOnly cookie before forwarding to Go.
*
* @param path API path *without* the `/api` prefix, e.g. `/tasks/`
* @param options Standard RequestInit overrides
*/
export async function apiFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
// Ensure trailing slash (Go API requires it)
const normalized = path.endsWith('/') ? path : `${path}/`;
const url = `/api/proxy${normalized}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Timezone': Intl.DateTimeFormat().resolvedOptions().timeZone,
...(options.headers as Record<string, string>),
};
// If the caller explicitly set Content-Type to something else (e.g. for
// FormData uploads), remove our default so the browser can set the
// multipart boundary automatically.
if (options.body instanceof FormData) {
delete headers['Content-Type'];
}
const res = await fetch(url, { ...options, headers });
if (!res.ok) {
const body = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new ApiError(
res.status,
body.message || body.error || 'Request failed',
body.details,
);
}
// Handle 204 No Content
if (res.status === 204) return undefined as T;
return res.json();
}
// ---------------------------------------------------------------------------
// Server-side fetcher (for server components & route handlers)
// ---------------------------------------------------------------------------
/**
* Server-side fetch that reads the auth token from the `casera-token` cookie
* and calls the Go API directly (no proxy hop).
*
* Only use this inside:
* - `app/api/.../route.ts` handlers
* - Server Components
* - Server Actions
*/
export async function serverFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
// Dynamic import so this module can still be imported from client bundles
// (the function itself should only be *called* on the server).
const { cookies } = await import('next/headers');
const cookieStore = await cookies();
const token = cookieStore.get('casera-token')?.value;
const normalized = path.endsWith('/') ? path : `${path}/`;
const url = `${SERVER_API_URL}${normalized}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Timezone': 'UTC', // Server-side default; client overrides via proxy
...(options.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Token ${token}`;
}
if (options.body instanceof FormData) {
delete headers['Content-Type'];
}
const res = await fetch(url, {
...options,
headers,
// Disable Next.js fetch cache for mutations; callers can override.
cache: options.cache ?? 'no-store',
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new ApiError(
res.status,
body.message || body.error || 'Request failed',
body.details,
);
}
if (res.status === 204) return undefined as T;
return res.json();
}
+158
View File
@@ -0,0 +1,158 @@
// ---------------------------------------------------------------------------
// Contractors API client (client-side)
// ---------------------------------------------------------------------------
import { apiFetch } from './client';
// ---------------------------------------------------------------------------
// Request / response shapes
// TODO: import from @/lib/types once the shared types package is finalised
// ---------------------------------------------------------------------------
export interface CreateContractorRequest {
residence_id?: number;
name: string;
company?: string;
phone?: string;
email?: string;
website?: string;
notes?: string;
street_address?: string;
city?: string;
state_province?: string;
postal_code?: string;
specialty_ids?: number[];
rating?: number;
is_favorite?: boolean;
}
export interface UpdateContractorRequest {
name?: string;
company?: string;
phone?: string;
email?: string;
website?: string;
notes?: string;
street_address?: string;
city?: string;
state_province?: string;
postal_code?: string;
specialty_ids?: number[];
rating?: number;
is_favorite?: boolean;
residence_id?: number;
}
export interface ContractorResponse {
id: number;
residence_id?: number;
residence_name?: string;
name: string;
company: string;
phone: string;
email: string;
website: string;
notes: string;
street_address: string;
city: string;
state_province: string;
postal_code: string;
specialties: SpecialtyResponse[];
rating?: number;
is_favorite: boolean;
task_count: number;
created_by_id: number;
created_at: string;
updated_at: string;
}
export interface SpecialtyResponse {
id: number;
name: string;
icon: string;
}
export interface ContractorTaskResponse {
id: number;
title: string;
residence_name: string;
status: string;
due_date?: string;
}
export interface ToggleFavoriteResponse {
id: number;
is_favorite: boolean;
}
export interface MessageResponse {
message: string;
}
// ---------------------------------------------------------------------------
// API functions
// ---------------------------------------------------------------------------
/** List all contractors for the current user. */
export function listContractors(): Promise<ContractorResponse[]> {
return apiFetch<ContractorResponse[]>('/contractors/');
}
/** List contractors for a specific residence. */
export function listContractorsByResidence(
residenceId: number,
): Promise<ContractorResponse[]> {
return apiFetch<ContractorResponse[]>(
`/contractors/by-residence/${residenceId}/`,
);
}
/** Get a single contractor by ID. */
export function getContractor(id: number): Promise<ContractorResponse> {
return apiFetch<ContractorResponse>(`/contractors/${id}/`);
}
/** Create a new contractor. */
export function createContractor(
data: CreateContractorRequest,
): Promise<ContractorResponse> {
return apiFetch<ContractorResponse>('/contractors/', {
method: 'POST',
body: JSON.stringify(data),
});
}
/** Update an existing contractor. */
export function updateContractor(
id: number,
data: UpdateContractorRequest,
): Promise<ContractorResponse> {
return apiFetch<ContractorResponse>(`/contractors/${id}/`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
/** Delete a contractor. */
export function deleteContractor(id: number): Promise<MessageResponse> {
return apiFetch<MessageResponse>(`/contractors/${id}/`, {
method: 'DELETE',
});
}
/** Toggle the favorite status of a contractor. */
export function toggleFavorite(
id: number,
): Promise<ToggleFavoriteResponse> {
return apiFetch<ToggleFavoriteResponse>(
`/contractors/${id}/toggle-favorite/`,
{ method: 'POST' },
);
}
/** Get tasks associated with a contractor. */
export function getContractorTasks(
id: number,
): Promise<ContractorTaskResponse[]> {
return apiFetch<ContractorTaskResponse[]>(`/contractors/${id}/tasks/`);
}
+252
View File
@@ -0,0 +1,252 @@
// ---------------------------------------------------------------------------
// Documents API client (client-side)
// ---------------------------------------------------------------------------
import { apiFetch } from './client';
// ---------------------------------------------------------------------------
// Request / response shapes
// TODO: import from @/lib/types once the shared types package is finalised
// ---------------------------------------------------------------------------
export type DocumentType =
| 'general'
| 'warranty'
| 'receipt'
| 'contract'
| 'insurance'
| 'manual';
export interface CreateDocumentRequest {
residence_id: number;
title: string;
description?: string;
document_type?: DocumentType;
file_url?: string;
file_name?: string;
file_size?: number;
mime_type?: string;
purchase_date?: string;
expiry_date?: string;
purchase_price?: number;
vendor?: string;
serial_number?: string;
model_number?: string;
task_id?: number;
image_urls?: string[];
}
export interface UpdateDocumentRequest {
title?: string;
description?: string;
document_type?: DocumentType;
file_url?: string;
file_name?: string;
file_size?: number;
mime_type?: string;
purchase_date?: string;
expiry_date?: string;
purchase_price?: number;
vendor?: string;
serial_number?: string;
model_number?: string;
task_id?: number;
}
export interface DocumentResponse {
id: number;
residence_id: number;
residence_name: string;
title: string;
description: string;
document_type: DocumentType;
file_url: string;
file_name: string;
file_size?: number;
mime_type: string;
purchase_date?: string;
expiry_date?: string;
purchase_price?: number;
vendor: string;
serial_number: string;
model_number: string;
is_active: boolean;
task_id?: number;
images: DocumentImageResponse[];
created_by: DocumentUserResponse;
created_at: string;
updated_at: string;
}
export interface DocumentImageResponse {
id: number;
image_url: string;
caption: string;
}
export interface DocumentUserResponse {
id: number;
username: string;
first_name: string;
last_name: string;
}
export interface DocumentActivateResponse {
message: string;
document: DocumentResponse;
}
export interface MessageResponse {
message: string;
}
// ---------------------------------------------------------------------------
// Query filter parameters for list endpoint
// ---------------------------------------------------------------------------
export interface DocumentListParams {
residence?: number;
document_type?: DocumentType;
is_active?: boolean;
expiring_soon?: number; // days
search?: string;
}
// ---------------------------------------------------------------------------
// API functions
// ---------------------------------------------------------------------------
/** List documents, optionally filtered. */
export function listDocuments(
params?: DocumentListParams,
): Promise<DocumentResponse[]> {
let queryString = '';
if (params) {
const searchParams = new URLSearchParams();
if (params.residence != null)
searchParams.set('residence', String(params.residence));
if (params.document_type)
searchParams.set('document_type', params.document_type);
if (params.is_active != null)
searchParams.set('is_active', String(params.is_active));
if (params.expiring_soon != null)
searchParams.set('expiring_soon', String(params.expiring_soon));
if (params.search) searchParams.set('search', params.search);
const qs = searchParams.toString();
if (qs) queryString = `?${qs}`;
}
return apiFetch<DocumentResponse[]>(`/documents/${queryString}`);
}
/** List warranty documents. */
export function listWarranties(): Promise<DocumentResponse[]> {
return apiFetch<DocumentResponse[]>('/documents/warranties/');
}
/** Get a single document by ID. */
export function getDocument(id: number): Promise<DocumentResponse> {
return apiFetch<DocumentResponse>(`/documents/${id}/`);
}
/** Create a new document (JSON body, no file upload). */
export function createDocument(
data: CreateDocumentRequest,
): Promise<DocumentResponse> {
return apiFetch<DocumentResponse>('/documents/', {
method: 'POST',
body: JSON.stringify(data),
});
}
/**
* Create a document with file upload (multipart/form-data).
* Use this when the user attaches a file to the document.
*/
export function createDocumentWithFile(
data: Omit<CreateDocumentRequest, 'file_url' | 'file_name' | 'file_size' | 'mime_type'>,
file: File,
): Promise<DocumentResponse> {
const formData = new FormData();
formData.append('residence_id', String(data.residence_id));
formData.append('title', data.title);
if (data.description) formData.append('description', data.description);
if (data.document_type) formData.append('document_type', data.document_type);
if (data.vendor) formData.append('vendor', data.vendor);
if (data.serial_number) formData.append('serial_number', data.serial_number);
if (data.model_number) formData.append('model_number', data.model_number);
if (data.purchase_date) formData.append('purchase_date', data.purchase_date);
if (data.expiry_date) formData.append('expiry_date', data.expiry_date);
if (data.purchase_price != null)
formData.append('purchase_price', String(data.purchase_price));
if (data.task_id != null)
formData.append('task_id', String(data.task_id));
formData.append('file', file);
return apiFetch<DocumentResponse>('/documents/', {
method: 'POST',
body: formData,
});
}
/** Update an existing document. */
export function updateDocument(
id: number,
data: UpdateDocumentRequest,
): Promise<DocumentResponse> {
return apiFetch<DocumentResponse>(`/documents/${id}/`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
/** Delete a document. */
export function deleteDocument(id: number): Promise<MessageResponse> {
return apiFetch<MessageResponse>(`/documents/${id}/`, {
method: 'DELETE',
});
}
/** Activate a document. */
export function activateDocument(
id: number,
): Promise<DocumentActivateResponse> {
return apiFetch<DocumentActivateResponse>(`/documents/${id}/activate/`, {
method: 'POST',
});
}
/** Deactivate a document. */
export function deactivateDocument(
id: number,
): Promise<DocumentActivateResponse> {
return apiFetch<DocumentActivateResponse>(`/documents/${id}/deactivate/`, {
method: 'POST',
});
}
/** Upload an image to a document. */
export function uploadDocumentImage(
documentId: number,
image: File,
caption?: string,
): Promise<DocumentImageResponse> {
const formData = new FormData();
formData.append('image', image);
if (caption) formData.append('caption', caption);
return apiFetch<DocumentImageResponse>(`/documents/${documentId}/images/`, {
method: 'POST',
body: formData,
});
}
/** Delete an image from a document. */
export function deleteDocumentImage(
documentId: number,
imageId: number,
): Promise<DocumentResponse> {
return apiFetch<DocumentResponse>(
`/documents/${documentId}/images/${imageId}/`,
{ method: 'DELETE' },
);
}
+23
View File
@@ -0,0 +1,23 @@
// ---------------------------------------------------------------------------
// Casera API Client - barrel export
// ---------------------------------------------------------------------------
// Usage:
// import { auth, residences, tasks } from '@/lib/api';
// const user = await auth.getCurrentUser();
// const homes = await residences.listResidences();
// ---------------------------------------------------------------------------
export * as auth from './auth';
export * as residences from './residences';
export * as tasks from './tasks';
export * as contractors from './contractors';
export * as documents from './documents';
export * as notifications from './notifications';
export * as subscription from './subscription';
export * as lookups from './lookups';
export * as uploads from './uploads';
export * as users from './users';
export * as media from './media';
// Re-export the base client utilities for advanced use cases
export { apiFetch, serverFetch, ApiError } from './client';
+161
View File
@@ -0,0 +1,161 @@
// ---------------------------------------------------------------------------
// Lookups / Static Data API client (client-side)
// ---------------------------------------------------------------------------
import { apiFetch } from './client';
// ---------------------------------------------------------------------------
// Response shapes
// TODO: import from @/lib/types once the shared types package is finalised
// ---------------------------------------------------------------------------
export interface ResidenceTypeResponse {
id: number;
name: string;
icon: string;
}
export interface TaskCategoryResponse {
id: number;
name: string;
icon: string;
color: string;
}
export interface TaskPriorityResponse {
id: number;
name: string;
icon: string;
color: string;
sort_order: number;
}
export interface TaskFrequencyResponse {
id: number;
name: string;
days: number;
description: string;
}
export interface ContractorSpecialtyResponse {
id: number;
name: string;
icon: string;
}
export interface TaskTemplateResponse {
id: number;
title: string;
description: string;
category_id: number;
priority_id?: number;
frequency_id?: number;
estimated_cost?: number;
}
export interface TaskTemplateGroupResponse {
category: TaskCategoryResponse;
templates: TaskTemplateResponse[];
}
export interface TaskTemplatesGroupedResponse {
groups: TaskTemplateGroupResponse[];
total_count: number;
}
export interface StaticDataResponse {
residence_types: ResidenceTypeResponse[];
task_categories: TaskCategoryResponse[];
task_priorities: TaskPriorityResponse[];
task_frequencies: TaskFrequencyResponse[];
contractor_specialties: ContractorSpecialtyResponse[];
task_templates: TaskTemplatesGroupedResponse;
}
export interface MessageResponse {
message: string;
status: string;
}
// ---------------------------------------------------------------------------
// API functions
// ---------------------------------------------------------------------------
/**
* Get all static/lookup data in a single request.
* Supports ETag-based caching (304 Not Modified).
*/
export function getStaticData(): Promise<StaticDataResponse> {
return apiFetch<StaticDataResponse>('/static_data/');
}
/** Trigger a refresh of static data cache (mostly a no-op). */
export function refreshStaticData(): Promise<MessageResponse> {
return apiFetch<MessageResponse>('/static_data/refresh/', {
method: 'POST',
});
}
// --- Individual lookup endpoints (public, no auth required) ---
/** Get all residence types. */
export function getResidenceTypes(): Promise<ResidenceTypeResponse[]> {
return apiFetch<ResidenceTypeResponse[]>('/residences/types/');
}
/** Get all task categories. */
export function getTaskCategories(): Promise<TaskCategoryResponse[]> {
return apiFetch<TaskCategoryResponse[]>('/tasks/categories/');
}
/** Get all task priorities. */
export function getTaskPriorities(): Promise<TaskPriorityResponse[]> {
return apiFetch<TaskPriorityResponse[]>('/tasks/priorities/');
}
/** Get all task frequencies. */
export function getTaskFrequencies(): Promise<TaskFrequencyResponse[]> {
return apiFetch<TaskFrequencyResponse[]>('/tasks/frequencies/');
}
/** Get all contractor specialties. */
export function getContractorSpecialties(): Promise<
ContractorSpecialtyResponse[]
> {
return apiFetch<ContractorSpecialtyResponse[]>('/contractors/specialties/');
}
// --- Task Templates ---
/** Get all task templates (flat list). */
export function getTaskTemplates(): Promise<TaskTemplateResponse[]> {
return apiFetch<TaskTemplateResponse[]>('/tasks/templates/');
}
/** Get task templates grouped by category. */
export function getTaskTemplatesGrouped(): Promise<TaskTemplatesGroupedResponse> {
return apiFetch<TaskTemplatesGroupedResponse>('/tasks/templates/grouped/');
}
/** Search task templates by query string (min 2 chars). */
export function searchTaskTemplates(
query: string,
): Promise<TaskTemplateResponse[]> {
return apiFetch<TaskTemplateResponse[]>(
`/tasks/templates/search/?q=${encodeURIComponent(query)}`,
);
}
/** Get task templates for a specific category. */
export function getTaskTemplatesByCategory(
categoryId: number,
): Promise<TaskTemplateResponse[]> {
return apiFetch<TaskTemplateResponse[]>(
`/tasks/templates/by-category/${categoryId}/`,
);
}
/** Get a single task template by ID. */
export function getTaskTemplate(id: number): Promise<TaskTemplateResponse> {
return apiFetch<TaskTemplateResponse>(`/tasks/templates/${id}/`);
}
+30
View File
@@ -0,0 +1,30 @@
// ---------------------------------------------------------------------------
// Media API client (client-side)
// ---------------------------------------------------------------------------
// Authenticated media serving endpoints. These return the actual file
// content (images, documents), not JSON. The Go API verifies residence
// access before serving.
// ---------------------------------------------------------------------------
/**
* Build a proxied media URL that the browser can use in <img> src, etc.
* The request goes through the Next.js proxy which attaches the auth cookie.
*
* NOTE: These URLs are only usable in an authenticated browser session.
* For public sharing, a different approach would be needed.
*/
/** URL for an authenticated document file. */
export function documentUrl(documentId: number): string {
return `/api/proxy/media/document/${documentId}`;
}
/** URL for an authenticated document image. */
export function documentImageUrl(imageId: number): string {
return `/api/proxy/media/document-image/${imageId}`;
}
/** URL for an authenticated task completion image. */
export function completionImageUrl(imageId: number): string {
return `/api/proxy/media/completion-image/${imageId}`;
}
+162
View File
@@ -0,0 +1,162 @@
// ---------------------------------------------------------------------------
// Notifications API client (client-side)
// ---------------------------------------------------------------------------
import { apiFetch } from './client';
// ---------------------------------------------------------------------------
// Request / response shapes
// TODO: import from @/lib/types once the shared types package is finalised
// ---------------------------------------------------------------------------
export interface NotificationResponse {
id: number;
title: string;
body: string;
notification_type: string;
is_read: boolean;
data?: Record<string, unknown>;
created_at: string;
}
export interface NotificationListResponse {
count: number;
results: NotificationResponse[];
}
export interface UnreadCountResponse {
unread_count: number;
}
export interface NotificationPreferencesResponse {
task_reminders: boolean;
task_completions: boolean;
residence_updates: boolean;
share_notifications: boolean;
marketing: boolean;
}
export interface UpdatePreferencesRequest {
task_reminders?: boolean;
task_completions?: boolean;
residence_updates?: boolean;
share_notifications?: boolean;
marketing?: boolean;
}
export interface RegisterDeviceRequest {
registration_id: string;
platform: 'ios' | 'android' | 'web';
device_name?: string;
}
export interface DeviceResponse {
id: number;
registration_id: string;
platform: string;
device_name: string;
is_active: boolean;
created_at: string;
}
export interface UnregisterDeviceRequest {
registration_id: string;
platform?: string;
}
export interface MessageResponse {
message: string;
}
// ---------------------------------------------------------------------------
// API functions
// ---------------------------------------------------------------------------
/** List notifications with optional pagination. */
export function listNotifications(
limit?: number,
offset?: number,
): Promise<NotificationListResponse> {
const params = new URLSearchParams();
if (limit != null) params.set('limit', String(limit));
if (offset != null) params.set('offset', String(offset));
const qs = params.toString();
return apiFetch<NotificationListResponse>(
`/notifications/${qs ? `?${qs}` : ''}`,
);
}
/** Get unread notification count. */
export function getUnreadCount(): Promise<UnreadCountResponse> {
return apiFetch<UnreadCountResponse>('/notifications/unread-count/');
}
/** Mark a single notification as read. */
export function markAsRead(id: number): Promise<MessageResponse> {
return apiFetch<MessageResponse>(`/notifications/${id}/read/`, {
method: 'POST',
});
}
/** Mark all notifications as read. */
export function markAllAsRead(): Promise<MessageResponse> {
return apiFetch<MessageResponse>('/notifications/mark-all-read/', {
method: 'POST',
});
}
/** Get notification preferences. */
export function getPreferences(): Promise<NotificationPreferencesResponse> {
return apiFetch<NotificationPreferencesResponse>(
'/notifications/preferences/',
);
}
/** Update notification preferences. */
export function updatePreferences(
data: UpdatePreferencesRequest,
): Promise<NotificationPreferencesResponse> {
return apiFetch<NotificationPreferencesResponse>(
'/notifications/preferences/',
{
method: 'PUT',
body: JSON.stringify(data),
},
);
}
/** Register a push notification device. */
export function registerDevice(
data: RegisterDeviceRequest,
): Promise<DeviceResponse> {
return apiFetch<DeviceResponse>('/notifications/devices/', {
method: 'POST',
body: JSON.stringify(data),
});
}
/** List registered devices. */
export function listDevices(): Promise<DeviceResponse[]> {
return apiFetch<DeviceResponse[]>('/notifications/devices/');
}
/** Unregister a push notification device by registration ID. */
export function unregisterDevice(
data: UnregisterDeviceRequest,
): Promise<MessageResponse> {
return apiFetch<MessageResponse>('/notifications/devices/unregister/', {
method: 'POST',
body: JSON.stringify(data),
});
}
/** Delete a device by ID. */
export function deleteDevice(
id: number,
platform?: string,
): Promise<MessageResponse> {
const qs = platform ? `?platform=${platform}` : '';
return apiFetch<MessageResponse>(`/notifications/devices/${id}/${qs}`, {
method: 'DELETE',
});
}
+282
View File
@@ -0,0 +1,282 @@
// ---------------------------------------------------------------------------
// Residences API client (client-side)
// ---------------------------------------------------------------------------
import { apiFetch } from './client';
// ---------------------------------------------------------------------------
// Request / response shapes
// TODO: import from @/lib/types once the shared types package is finalised
// ---------------------------------------------------------------------------
export interface CreateResidenceRequest {
name: string;
property_type_id?: number;
street_address?: string;
apartment_unit?: string;
city?: string;
state_province?: string;
postal_code?: string;
country?: string;
bedrooms?: number;
bathrooms?: number;
square_footage?: number;
lot_size?: number;
year_built?: number;
description?: string;
purchase_date?: string;
purchase_price?: number;
is_primary?: boolean;
}
export interface UpdateResidenceRequest {
name?: string;
property_type_id?: number;
street_address?: string;
apartment_unit?: string;
city?: string;
state_province?: string;
postal_code?: string;
country?: string;
bedrooms?: number;
bathrooms?: number;
square_footage?: number;
lot_size?: number;
year_built?: number;
description?: string;
purchase_date?: string;
purchase_price?: number;
is_primary?: boolean;
}
export interface ResidenceResponse {
id: number;
name: string;
property_type_id?: number;
property_type?: ResidenceTypeResponse;
street_address: string;
apartment_unit: string;
city: string;
state_province: string;
postal_code: string;
country: string;
bedrooms?: number;
bathrooms?: number;
square_footage?: number;
lot_size?: number;
year_built?: number;
description: string;
purchase_date?: string;
purchase_price?: number;
is_primary: boolean;
is_owner: boolean;
owner_id: number;
user_count: number;
created_at: string;
updated_at: string;
}
export interface ResidenceTypeResponse {
id: number;
name: string;
icon: string;
}
export interface MyResidenceResponse {
residence: ResidenceResponse;
task_summary: TaskSummary;
}
export interface TaskSummary {
total: number;
overdue: number;
due_soon: number;
in_progress: number;
completed: number;
}
export interface ResidenceSummaryResponse {
total_residences: number;
total_summary: TaskSummary;
residences: MyResidenceResponse[];
}
export interface ResidenceUserResponse {
id: number;
username: string;
email: string;
first_name: string;
last_name: string;
is_owner: boolean;
}
export interface ShareCodeResponse {
code: string;
residence_id: number;
expires_at: string;
created_at: string;
}
export interface SharePackageResponse {
code: string;
residence_id: number;
residence_name: string;
owner_name: string;
expires_at: string;
}
export interface GenerateShareCodeRequest {
expires_in_hours?: number;
}
export interface JoinWithCodeRequest {
code: string;
}
export interface TasksReportResponse {
message: string;
residence_name: string;
recipient_email: string;
pdf_generated: boolean;
email_sent: boolean;
report: Record<string, unknown>;
}
export interface MessageResponse {
message: string;
}
// ---------------------------------------------------------------------------
// API functions
// ---------------------------------------------------------------------------
/** List all residences the current user has access to. */
export function listResidences(): Promise<ResidenceResponse[]> {
return apiFetch<ResidenceResponse[]>('/residences/');
}
/** Get the user's residences with task summaries. */
export function getMyResidences(): Promise<MyResidenceResponse[]> {
return apiFetch<MyResidenceResponse[]>('/residences/my-residences/');
}
/** Get aggregated task summary across all residences. */
export function getSummary(): Promise<ResidenceSummaryResponse> {
return apiFetch<ResidenceSummaryResponse>('/residences/summary/');
}
/** Get a single residence by ID. */
export function getResidence(id: number): Promise<ResidenceResponse> {
return apiFetch<ResidenceResponse>(`/residences/${id}/`);
}
/** Create a new residence. */
export function createResidence(
data: CreateResidenceRequest,
): Promise<ResidenceResponse> {
return apiFetch<ResidenceResponse>('/residences/', {
method: 'POST',
body: JSON.stringify(data),
});
}
/** Update an existing residence. */
export function updateResidence(
id: number,
data: UpdateResidenceRequest,
): Promise<ResidenceResponse> {
return apiFetch<ResidenceResponse>(`/residences/${id}/`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
/** Delete a residence. Only the owner can delete. */
export function deleteResidence(
id: number,
): Promise<MessageResponse> {
return apiFetch<MessageResponse>(`/residences/${id}/`, {
method: 'DELETE',
});
}
/** Get the active share code for a residence. */
export function getShareCode(
residenceId: number,
): Promise<{ share_code: ShareCodeResponse | null }> {
return apiFetch<{ share_code: ShareCodeResponse | null }>(
`/residences/${residenceId}/share-code/`,
);
}
/** Generate a new share code for a residence. */
export function generateShareCode(
residenceId: number,
data?: GenerateShareCodeRequest,
): Promise<ShareCodeResponse> {
return apiFetch<ShareCodeResponse>(
`/residences/${residenceId}/generate-share-code/`,
{
method: 'POST',
body: JSON.stringify(data ?? {}),
},
);
}
/** Generate a share package (code + residence metadata). */
export function generateSharePackage(
residenceId: number,
data?: GenerateShareCodeRequest,
): Promise<SharePackageResponse> {
return apiFetch<SharePackageResponse>(
`/residences/${residenceId}/generate-share-package/`,
{
method: 'POST',
body: JSON.stringify(data ?? {}),
},
);
}
/** Join a residence using a 6-character share code. */
export function joinWithCode(
data: JoinWithCodeRequest,
): Promise<ResidenceResponse> {
return apiFetch<ResidenceResponse>('/residences/join-with-code/', {
method: 'POST',
body: JSON.stringify(data),
});
}
/** List users of a residence. */
export function getResidenceUsers(
residenceId: number,
): Promise<ResidenceUserResponse[]> {
return apiFetch<ResidenceUserResponse[]>(
`/residences/${residenceId}/users/`,
);
}
/** Remove a user from a residence. Only the owner can remove users. */
export function removeResidenceUser(
residenceId: number,
userId: number,
): Promise<MessageResponse> {
return apiFetch<MessageResponse>(
`/residences/${residenceId}/users/${userId}/`,
{ method: 'DELETE' },
);
}
/** Generate and email a tasks report PDF for a residence. */
export function generateTasksReport(
residenceId: number,
email?: string,
): Promise<TasksReportResponse> {
return apiFetch<TasksReportResponse>(
`/residences/${residenceId}/generate-tasks-report/`,
{
method: 'POST',
body: JSON.stringify(email ? { email } : {}),
},
);
}
+153
View File
@@ -0,0 +1,153 @@
// ---------------------------------------------------------------------------
// Subscription API client (client-side)
// ---------------------------------------------------------------------------
import { apiFetch } from './client';
// ---------------------------------------------------------------------------
// Request / response shapes
// TODO: import from @/lib/types once the shared types package is finalised
// ---------------------------------------------------------------------------
export interface SubscriptionResponse {
id: number;
user_id: number;
tier: string;
status: string;
platform: string;
product_id: string;
original_transaction_id: string;
expires_at?: string;
cancelled_at?: string;
created_at: string;
updated_at: string;
}
export interface SubscriptionStatusResponse {
tier: string;
status: string;
is_active: boolean;
expires_at?: string;
limits: TierLimitsResponse;
}
export interface TierLimitsResponse {
max_residences: number;
max_tasks_per_residence: number;
max_contractors: number;
max_documents: number;
can_share: boolean;
can_export: boolean;
}
export interface UpgradeTriggerResponse {
id: number;
key: string;
title: string;
description: string;
action_text: string;
icon: string;
}
export interface FeatureBenefitResponse {
id: number;
title: string;
description: string;
icon: string;
tier: string;
sort_order: number;
}
export interface PromotionResponse {
id: number;
title: string;
description: string;
discount_percentage: number;
promo_code: string;
valid_from: string;
valid_until: string;
}
export interface ProcessPurchaseRequest {
platform: 'ios' | 'android';
receipt_data?: string;
transaction_id?: string;
purchase_token?: string;
product_id?: string;
}
export interface PurchaseResponse {
message: string;
subscription: SubscriptionResponse;
}
export interface MessageResponse {
message: string;
}
// ---------------------------------------------------------------------------
// API functions
// ---------------------------------------------------------------------------
/** Get the current user's subscription details. */
export function getSubscription(): Promise<SubscriptionResponse> {
return apiFetch<SubscriptionResponse>('/subscription/');
}
/** Get the current user's subscription status with tier limits. */
export function getSubscriptionStatus(): Promise<SubscriptionStatusResponse> {
return apiFetch<SubscriptionStatusResponse>('/subscription/status/');
}
/** Get a specific upgrade trigger by key. */
export function getUpgradeTrigger(
key: string,
): Promise<UpgradeTriggerResponse> {
return apiFetch<UpgradeTriggerResponse>(
`/subscription/upgrade-trigger/${key}/`,
);
}
/** Get all upgrade triggers (public, no auth required). */
export function getAllUpgradeTriggers(): Promise<UpgradeTriggerResponse[]> {
return apiFetch<UpgradeTriggerResponse[]>(
'/subscription/upgrade-triggers/',
);
}
/** Get feature benefits for the subscription tiers. */
export function getFeatureBenefits(): Promise<FeatureBenefitResponse[]> {
return apiFetch<FeatureBenefitResponse[]>('/subscription/features/');
}
/** Get active promotions for the current user. */
export function getPromotions(): Promise<PromotionResponse[]> {
return apiFetch<PromotionResponse[]>('/subscription/promotions/');
}
/** Process a subscription purchase (iOS/Android). */
export function processPurchase(
data: ProcessPurchaseRequest,
): Promise<PurchaseResponse> {
return apiFetch<PurchaseResponse>('/subscription/purchase/', {
method: 'POST',
body: JSON.stringify(data),
});
}
/** Cancel the current subscription. */
export function cancelSubscription(): Promise<PurchaseResponse> {
return apiFetch<PurchaseResponse>('/subscription/cancel/', {
method: 'POST',
});
}
/** Restore a subscription from a previous purchase. */
export function restoreSubscription(
data: ProcessPurchaseRequest,
): Promise<PurchaseResponse> {
return apiFetch<PurchaseResponse>('/subscription/restore/', {
method: 'POST',
body: JSON.stringify(data),
});
}
+328
View File
@@ -0,0 +1,328 @@
// ---------------------------------------------------------------------------
// Tasks API client (client-side)
// ---------------------------------------------------------------------------
import { apiFetch } from './client';
// ---------------------------------------------------------------------------
// Request / response shapes
// TODO: import from @/lib/types once the shared types package is finalised
// ---------------------------------------------------------------------------
export interface CreateTaskRequest {
residence_id: number;
title: string;
description?: string;
category_id?: number;
priority_id?: number;
frequency_id?: number;
custom_interval_days?: number;
in_progress?: boolean;
assigned_to_id?: number;
due_date?: string;
estimated_cost?: number;
contractor_id?: number;
}
export interface UpdateTaskRequest {
title?: string;
description?: string;
category_id?: number;
priority_id?: number;
frequency_id?: number;
custom_interval_days?: number;
in_progress?: boolean;
assigned_to_id?: number;
due_date?: string;
estimated_cost?: number;
actual_cost?: number;
contractor_id?: number;
}
export interface TaskResponse {
id: number;
residence_id: number;
residence_name: string;
title: string;
description: string;
category_id?: number;
category?: LookupResponse;
priority_id?: number;
priority?: LookupResponse;
frequency_id?: number;
frequency?: LookupResponse;
custom_interval_days?: number;
in_progress: boolean;
is_cancelled: boolean;
is_archived: boolean;
assigned_to_id?: number;
assigned_to?: TaskUserResponse;
due_date?: string;
next_due_date?: string;
estimated_cost?: number;
actual_cost?: number;
contractor_id?: number;
contractor?: TaskContractorResponse;
completion_count: number;
last_completed_at?: string;
created_by_id: number;
created_at: string;
updated_at: string;
}
export interface LookupResponse {
id: number;
name: string;
icon?: string;
color?: string;
}
export interface TaskUserResponse {
id: number;
username: string;
first_name: string;
last_name: string;
}
export interface TaskContractorResponse {
id: number;
name: string;
company: string;
}
/** Kanban board response returned by ListTasks and GetTasksByResidence. */
export interface KanbanResponse {
columns: KanbanColumn[];
total_summary: TaskTotalSummary;
}
export interface KanbanColumn {
name: string;
display_name: string;
count: number;
tasks: TaskResponse[];
}
export interface TaskTotalSummary {
total: number;
overdue: number;
due_soon: number;
in_progress: number;
completed: number;
upcoming: number;
}
// --- Task Completions ---
export interface CreateCompletionRequest {
task_id: number;
completed_at?: string;
notes?: string;
actual_cost?: number;
rating?: number;
image_urls?: string[];
}
export interface UpdateCompletionRequest {
notes?: string;
actual_cost?: number;
rating?: number;
image_urls?: string[];
}
export interface CompletionResponse {
id: number;
task_id: number;
task_title: string;
completed_at: string;
completed_by_id: number;
completed_by?: TaskUserResponse;
notes: string;
actual_cost?: number;
rating?: number;
images: CompletionImageResponse[];
created_at: string;
}
export interface CompletionImageResponse {
id: number;
image_url: string;
caption: string;
}
export interface MessageResponse {
message: string;
}
// ---------------------------------------------------------------------------
// API functions
// ---------------------------------------------------------------------------
/**
* List all tasks for the current user (kanban board).
* @param days Number of days for "due soon" threshold (default 30)
*/
export function listTasks(days?: number): Promise<KanbanResponse> {
const params = days != null ? `?days=${days}` : '';
return apiFetch<KanbanResponse>(`/tasks/${params}`);
}
/** Get tasks for a specific residence (kanban board). */
export function getTasksByResidence(
residenceId: number,
days?: number,
): Promise<KanbanResponse> {
const params = days != null ? `?days=${days}` : '';
return apiFetch<KanbanResponse>(
`/tasks/by-residence/${residenceId}/${params}`,
);
}
/** Get a single task by ID. */
export function getTask(id: number): Promise<TaskResponse> {
return apiFetch<TaskResponse>(`/tasks/${id}/`);
}
/** Create a new task. */
export function createTask(data: CreateTaskRequest): Promise<TaskResponse> {
return apiFetch<TaskResponse>('/tasks/', {
method: 'POST',
body: JSON.stringify(data),
});
}
/** Update an existing task. */
export function updateTask(
id: number,
data: UpdateTaskRequest,
): Promise<TaskResponse> {
return apiFetch<TaskResponse>(`/tasks/${id}/`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
/** Delete a task. */
export function deleteTask(id: number): Promise<MessageResponse> {
return apiFetch<MessageResponse>(`/tasks/${id}/`, {
method: 'DELETE',
});
}
/** Mark a task as in-progress. */
export function markInProgress(id: number): Promise<TaskResponse> {
return apiFetch<TaskResponse>(`/tasks/${id}/mark-in-progress/`, {
method: 'POST',
});
}
/** Cancel a task. */
export function cancelTask(id: number): Promise<TaskResponse> {
return apiFetch<TaskResponse>(`/tasks/${id}/cancel/`, {
method: 'POST',
});
}
/** Un-cancel a previously cancelled task. */
export function uncancelTask(id: number): Promise<TaskResponse> {
return apiFetch<TaskResponse>(`/tasks/${id}/uncancel/`, {
method: 'POST',
});
}
/** Archive a task. */
export function archiveTask(id: number): Promise<TaskResponse> {
return apiFetch<TaskResponse>(`/tasks/${id}/archive/`, {
method: 'POST',
});
}
/** Un-archive a previously archived task. */
export function unarchiveTask(id: number): Promise<TaskResponse> {
return apiFetch<TaskResponse>(`/tasks/${id}/unarchive/`, {
method: 'POST',
});
}
/** Quick-complete a task (lightweight, for widgets). Returns void (204). */
export function quickComplete(id: number): Promise<void> {
return apiFetch<void>(`/tasks/${id}/quick-complete/`, {
method: 'POST',
});
}
// --- Task Completions ---
/** Get completions for a specific task. */
export function getTaskCompletions(
taskId: number,
): Promise<CompletionResponse[]> {
return apiFetch<CompletionResponse[]>(`/tasks/${taskId}/completions/`);
}
/** List all completions for the current user. */
export function listCompletions(): Promise<CompletionResponse[]> {
return apiFetch<CompletionResponse[]>('/task-completions/');
}
/** Get a single completion by ID. */
export function getCompletion(id: number): Promise<CompletionResponse> {
return apiFetch<CompletionResponse>(`/task-completions/${id}/`);
}
/** Create a new task completion (JSON). */
export function createCompletion(
data: CreateCompletionRequest,
): Promise<CompletionResponse> {
return apiFetch<CompletionResponse>('/task-completions/', {
method: 'POST',
body: JSON.stringify(data),
});
}
/**
* Create a task completion with image upload (multipart/form-data).
* Use this when the user attaches photos to the completion.
*/
export function createCompletionWithImages(
data: {
task_id: number;
notes?: string;
actual_cost?: number;
completed_at?: string;
},
images: File[],
): Promise<CompletionResponse> {
const formData = new FormData();
formData.append('task_id', String(data.task_id));
if (data.notes) formData.append('notes', data.notes);
if (data.actual_cost != null)
formData.append('actual_cost', String(data.actual_cost));
if (data.completed_at) formData.append('completed_at', data.completed_at);
for (const image of images) {
formData.append('images', image);
}
return apiFetch<CompletionResponse>('/task-completions/', {
method: 'POST',
body: formData,
});
}
/** Update an existing completion. */
export function updateCompletion(
id: number,
data: UpdateCompletionRequest,
): Promise<CompletionResponse> {
return apiFetch<CompletionResponse>(`/task-completions/${id}/`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
/** Delete a completion. */
export function deleteCompletion(id: number): Promise<MessageResponse> {
return apiFetch<MessageResponse>(`/task-completions/${id}/`, {
method: 'DELETE',
});
}
+83
View File
@@ -0,0 +1,83 @@
// ---------------------------------------------------------------------------
// Uploads API client (client-side)
// ---------------------------------------------------------------------------
import { apiFetch } from './client';
// ---------------------------------------------------------------------------
// Response shapes
// TODO: import from @/lib/types once the shared types package is finalised
// ---------------------------------------------------------------------------
export interface UploadResult {
url: string;
file_name: string;
file_size: number;
mime_type: string;
}
export interface MessageResponse {
message: string;
}
// ---------------------------------------------------------------------------
// API functions
// ---------------------------------------------------------------------------
/**
* Upload an image file.
* @param file The image file to upload
* @param category Storage category (default: "images")
*/
export function uploadImage(
file: File,
category?: string,
): Promise<UploadResult> {
const formData = new FormData();
formData.append('file', file);
const qs = category ? `?category=${encodeURIComponent(category)}` : '';
return apiFetch<UploadResult>(`/uploads/image/${qs}`, {
method: 'POST',
body: formData,
});
}
/**
* Upload a document file.
* @param file The document file to upload
*/
export function uploadDocument(file: File): Promise<UploadResult> {
const formData = new FormData();
formData.append('file', file);
return apiFetch<UploadResult>('/uploads/document/', {
method: 'POST',
body: formData,
});
}
/**
* Upload a task completion photo.
* @param file The photo file to upload
*/
export function uploadCompletion(file: File): Promise<UploadResult> {
const formData = new FormData();
formData.append('file', file);
return apiFetch<UploadResult>('/uploads/completion/', {
method: 'POST',
body: formData,
});
}
/**
* Delete an uploaded file by URL.
* @param url The URL of the file to delete
*/
export function deleteFile(url: string): Promise<MessageResponse> {
return apiFetch<MessageResponse>('/uploads/', {
method: 'DELETE',
body: JSON.stringify({ url }),
});
}
+62
View File
@@ -0,0 +1,62 @@
// ---------------------------------------------------------------------------
// Users API client (client-side)
// ---------------------------------------------------------------------------
import { apiFetch } from './client';
// ---------------------------------------------------------------------------
// Response shapes
// TODO: import from @/lib/types once the shared types package is finalised
// ---------------------------------------------------------------------------
export interface UserResponse {
id: number;
username: string;
email: string;
first_name: string;
last_name: string;
}
export interface UserProfileResponse {
id: number;
user_id: number;
username: string;
first_name: string;
last_name: string;
profile_image_url?: string;
}
export interface UserListResponse {
count: number;
results: UserResponse[];
}
export interface ProfileListResponse {
count: number;
results: UserProfileResponse[];
}
// ---------------------------------------------------------------------------
// API functions
// ---------------------------------------------------------------------------
/**
* List users who share residences with the current user.
*/
export function listUsers(): Promise<UserListResponse> {
return apiFetch<UserListResponse>('/users/');
}
/**
* Get a user by ID (must share a residence with the current user).
*/
export function getUser(id: number): Promise<UserResponse> {
return apiFetch<UserResponse>(`/users/${id}/`);
}
/**
* List profiles of users in shared residences.
*/
export function listProfiles(): Promise<ProfileListResponse> {
return apiFetch<ProfileListResponse>('/users/profiles/');
}
+8
View File
@@ -0,0 +1,8 @@
export * from './use-lookups';
export * from './use-auth';
export * from './use-residences';
export * from './use-tasks';
export * from './use-contractors';
export * from './use-documents';
export * from './use-notifications';
export * from './use-subscription';
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as authApi from '@/lib/api/auth';
import { useRouter } from 'next/navigation';
export function useCurrentUser() {
return useQuery({
queryKey: ['auth', 'user'],
queryFn: () => authApi.getCurrentUser(),
retry: false,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
export function useLogout() {
const queryClient = useQueryClient();
const router = useRouter();
return useMutation({
mutationFn: () => authApi.logout(),
onSuccess: () => {
queryClient.clear();
router.push('/login');
},
});
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as contractorsApi from '@/lib/api/contractors';
import type { CreateContractorRequest, UpdateContractorRequest } from '@/lib/api/contractors';
// ---------------------------------------------------------------------------
// Query hooks
// ---------------------------------------------------------------------------
export function useContractors() {
return useQuery({
queryKey: ['contractors'],
queryFn: () => contractorsApi.listContractors(),
});
}
export function useContractor(id: number) {
return useQuery({
queryKey: ['contractors', id],
queryFn: () => contractorsApi.getContractor(id),
enabled: !!id,
});
}
export function useContractorTasks(id: number) {
return useQuery({
queryKey: ['contractors', id, 'tasks'],
queryFn: () => contractorsApi.getContractorTasks(id),
enabled: !!id,
});
}
// ---------------------------------------------------------------------------
// Mutation hooks
// ---------------------------------------------------------------------------
export function useCreateContractor() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateContractorRequest) =>
contractorsApi.createContractor(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contractors'] });
},
});
}
export function useUpdateContractor(id: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateContractorRequest) =>
contractorsApi.updateContractor(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contractors'] });
queryClient.invalidateQueries({ queryKey: ['contractors', id] });
},
});
}
export function useDeleteContractor() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => contractorsApi.deleteContractor(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contractors'] });
},
});
}
export function useToggleFavorite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => contractorsApi.toggleFavorite(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contractors'] });
},
});
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as documentsApi from '@/lib/api/documents';
import type { DocumentListParams, CreateDocumentRequest, UpdateDocumentRequest } from '@/lib/api/documents';
// ---------------------------------------------------------------------------
// Query hooks
// ---------------------------------------------------------------------------
export function useDocuments(params?: DocumentListParams) {
return useQuery({ queryKey: ['documents', params], queryFn: () => documentsApi.listDocuments(params) });
}
export function useWarranties() {
return useQuery({ queryKey: ['documents', 'warranties'], queryFn: () => documentsApi.listWarranties() });
}
export function useDocument(id: number) {
return useQuery({ queryKey: ['documents', id], queryFn: () => documentsApi.getDocument(id), enabled: !!id });
}
// ---------------------------------------------------------------------------
// Mutation hooks
// ---------------------------------------------------------------------------
export function useCreateDocument() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ data, file }: { data: CreateDocumentRequest; file?: File }) => {
if (file) {
return documentsApi.createDocumentWithFile(data, file);
}
return documentsApi.createDocument(data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents'] });
},
});
}
export function useUpdateDocument(id: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateDocumentRequest) =>
documentsApi.updateDocument(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents'] });
queryClient.invalidateQueries({ queryKey: ['documents', id] });
},
});
}
export function useDeleteDocument() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => documentsApi.deleteDocument(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents'] });
},
});
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useQuery } from '@tanstack/react-query';
import * as lookupsApi from '@/lib/api/lookups';
// ---------------------------------------------------------------------------
// Main hook — fetches all static data in a single request
// ---------------------------------------------------------------------------
export function useLookups() {
return useQuery({
queryKey: ['lookups', 'static-data'],
queryFn: () => lookupsApi.getStaticData(),
staleTime: Infinity, // ETag-based; never auto-refetch
});
}
// ---------------------------------------------------------------------------
// Convenience hooks for individual lookup arrays
// ---------------------------------------------------------------------------
export function useTaskCategories() {
const { data, ...rest } = useLookups();
return { data: data?.task_categories ?? [], ...rest };
}
export function useTaskPriorities() {
const { data, ...rest } = useLookups();
return { data: data?.task_priorities ?? [], ...rest };
}
export function useTaskFrequencies() {
const { data, ...rest } = useLookups();
return { data: data?.task_frequencies ?? [], ...rest };
}
export function useContractorSpecialties() {
const { data, ...rest } = useLookups();
return { data: data?.contractor_specialties ?? [], ...rest };
}
export function useResidenceTypes() {
const { data, ...rest } = useLookups();
return { data: data?.residence_types ?? [], ...rest };
}
// ---------------------------------------------------------------------------
// O(1) lookup helpers — find a single item by ID from cached data
// ---------------------------------------------------------------------------
export function useTaskCategory(id: number | null | undefined) {
const { data: categories } = useTaskCategories();
if (!id) return null;
return categories.find((c) => c.id === id) ?? null;
}
export function useTaskPriority(id: number | null | undefined) {
const { data: priorities } = useTaskPriorities();
if (!id) return null;
return priorities.find((p) => p.id === id) ?? null;
}
export function useTaskFrequency(id: number | null | undefined) {
const { data: frequencies } = useTaskFrequencies();
if (!id) return null;
return frequencies.find((f) => f.id === id) ?? null;
}
export function useContractorSpecialty(id: number | null | undefined) {
const { data: specialties } = useContractorSpecialties();
if (!id) return null;
return specialties.find((s) => s.id === id) ?? null;
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as notificationsApi from '@/lib/api/notifications';
import type { UpdatePreferencesRequest } from '@/lib/api/notifications';
export function useNotifications(limit?: number) {
return useQuery({
queryKey: ['notifications', limit],
queryFn: () => notificationsApi.listNotifications(limit),
});
}
export function useUnreadCount() {
return useQuery({
queryKey: ['notifications', 'unread-count'],
queryFn: () => notificationsApi.getUnreadCount(),
refetchInterval: 60_000,
});
}
export function useNotificationPreferences() {
return useQuery({
queryKey: ['notifications', 'preferences'],
queryFn: () => notificationsApi.getPreferences(),
});
}
export function useUpdatePreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdatePreferencesRequest) => notificationsApi.updatePreferences(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications', 'preferences'] });
},
});
}
export function useMarkAsRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => notificationsApi.markAsRead(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
queryClient.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
},
});
}
export function useMarkAllAsRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => notificationsApi.markAllAsRead(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
queryClient.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
},
});
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as residencesApi from '@/lib/api/residences';
import type { CreateResidenceRequest, UpdateResidenceRequest } from '@/lib/api/residences';
// ---------------------------------------------------------------------------
// Query hooks
// ---------------------------------------------------------------------------
export function useResidences() {
return useQuery({
queryKey: ['residences'],
queryFn: () => residencesApi.getMyResidences(),
});
}
export function useResidence(id: number) {
return useQuery({
queryKey: ['residences', id],
queryFn: () => residencesApi.getResidence(id),
enabled: !!id,
});
}
// ---------------------------------------------------------------------------
// Mutation hooks
// ---------------------------------------------------------------------------
export function useCreateResidence() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateResidenceRequest) =>
residencesApi.createResidence(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences'] });
},
});
}
export function useUpdateResidence(id: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateResidenceRequest) =>
residencesApi.updateResidence(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences'] });
queryClient.invalidateQueries({ queryKey: ['residences', id] });
},
});
}
export function useDeleteResidence() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => residencesApi.deleteResidence(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences'] });
},
});
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as residencesApi from '@/lib/api/residences';
// ---------------------------------------------------------------------------
// Query hooks
// ---------------------------------------------------------------------------
export function useShareCode(residenceId: number) {
return useQuery({
queryKey: ['residences', residenceId, 'share-code'],
queryFn: () => residencesApi.getShareCode(residenceId),
enabled: !!residenceId,
});
}
export function useResidenceUsers(residenceId: number) {
return useQuery({
queryKey: ['residences', residenceId, 'users'],
queryFn: () => residencesApi.getResidenceUsers(residenceId),
enabled: !!residenceId,
});
}
// ---------------------------------------------------------------------------
// Mutation hooks
// ---------------------------------------------------------------------------
export function useGenerateShareCode(residenceId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => residencesApi.generateShareCode(residenceId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences', residenceId, 'share-code'] });
},
});
}
export function useRemoveResidenceUser(residenceId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (userId: number) => residencesApi.removeResidenceUser(residenceId, userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences', residenceId, 'users'] });
},
});
}
export function useJoinResidence() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (code: string) => residencesApi.joinWithCode({ code }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences'] });
},
});
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useQuery } from '@tanstack/react-query';
import * as subscriptionApi from '@/lib/api/subscription';
export function useSubscriptionStatus() {
return useQuery({
queryKey: ['subscription', 'status'],
queryFn: () => subscriptionApi.getSubscriptionStatus(),
});
}
export function useFeatureBenefits() {
return useQuery({
queryKey: ['subscription', 'features'],
queryFn: () => subscriptionApi.getFeatureBenefits(),
staleTime: Infinity,
});
}
export function useUpgradeTriggers() {
return useQuery({
queryKey: ['subscription', 'upgrade-triggers'],
queryFn: () => subscriptionApi.getAllUpgradeTriggers(),
staleTime: Infinity,
});
}
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as tasksApi from '@/lib/api/tasks';
import type { CreateTaskRequest, UpdateTaskRequest, CreateCompletionRequest } from '@/lib/api/tasks';
// ---------------------------------------------------------------------------
// Query hooks
// ---------------------------------------------------------------------------
export function useTasks() {
return useQuery({
queryKey: ['tasks'],
queryFn: () => tasksApi.listTasks(),
});
}
export function useTasksByResidence(residenceId: number) {
return useQuery({
queryKey: ['tasks', 'by-residence', residenceId],
queryFn: () => tasksApi.getTasksByResidence(residenceId),
enabled: !!residenceId,
});
}
export function useTask(id: number) {
return useQuery({
queryKey: ['tasks', id],
queryFn: () => tasksApi.getTask(id),
enabled: !!id,
});
}
export function useKanbanBoard(residenceId: number) {
return useQuery({
queryKey: ['tasks', 'kanban', residenceId],
queryFn: () => tasksApi.getTasksByResidence(residenceId),
enabled: !!residenceId,
});
}
export function useTaskCompletions(taskId: number) {
return useQuery({
queryKey: ['tasks', taskId, 'completions'],
queryFn: () => tasksApi.getTaskCompletions(taskId),
enabled: !!taskId,
});
}
// ---------------------------------------------------------------------------
// Mutation hooks
// ---------------------------------------------------------------------------
export function useCreateTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateTaskRequest) => tasksApi.createTask(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['residences'] });
},
});
}
export function useUpdateTask(id: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateTaskRequest) => tasksApi.updateTask(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['tasks', id] });
queryClient.invalidateQueries({ queryKey: ['residences'] });
},
});
}
export function useDeleteTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => tasksApi.deleteTask(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['residences'] });
},
});
}
export function useMarkInProgress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => tasksApi.markInProgress(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
}
export function useCancelTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => tasksApi.cancelTask(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
}
export function useArchiveTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => tasksApi.archiveTask(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
}
export function useCreateCompletion() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
data,
images,
}: {
data: CreateCompletionRequest;
images: File[];
}) => {
if (images.length > 0) {
return tasksApi.createCompletionWithImages(
{
task_id: data.task_id,
notes: data.notes,
actual_cost: data.actual_cost,
completed_at: data.completed_at,
},
images,
);
}
return tasksApi.createCompletion(data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['residences'] });
},
});
}
+26
View File
@@ -0,0 +1,26 @@
import { QueryClient } from '@tanstack/react-query';
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 3_600_000, // 1 hour (matches DataManager cache timeout)
retry: 1,
refetchOnWindowFocus: true,
},
mutations: {
retry: 0,
},
},
});
}
let browserQueryClient: QueryClient | undefined;
export function getQueryClient() {
// Server: always make a new query client
if (typeof window === 'undefined') return makeQueryClient();
// Browser: use a singleton
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { getQueryClient } from './query-client';
export function QueryProvider({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
+331
View File
@@ -0,0 +1,331 @@
export interface ThemeColors {
primary: string;
secondary: string;
accent: string;
error: string;
bgPrimary: string;
bgSecondary: string;
textPrimary: string;
textSecondary: string;
textOnPrimary: string;
}
export interface ThemeDefinition {
id: string;
name: string;
description: string;
light: ThemeColors;
dark: ThemeColors;
}
/**
* All 11 themes matching the KMM/iOS theme system.
* Hex values are taken directly from ThemeColors.kt.
*/
export const themes: ThemeDefinition[] = [
{
id: "default",
name: "Default",
description: "Vibrant iOS system colors",
light: {
primary: "#0079FF",
secondary: "#5AC7F9",
accent: "#FF9400",
error: "#FF3A2F",
bgPrimary: "#FFFFFF",
bgSecondary: "#F1F7F7",
textPrimary: "#111111",
textSecondary: "#3C3C3C",
textOnPrimary: "#FFFFFF",
},
dark: {
primary: "#0984FF",
secondary: "#63D2FF",
accent: "#FF9F09",
error: "#FF4539",
bgPrimary: "#1C1C1C",
bgSecondary: "#2C2C2C",
textPrimary: "#FFFFFF",
textSecondary: "#EBEBEB",
textOnPrimary: "#FFFFFF",
},
},
{
id: "teal",
name: "Teal",
description: "Blue-green with warm accents",
light: {
primary: "#069FC3",
secondary: "#0054A4",
accent: "#EFC707",
error: "#DD1C1A",
bgPrimary: "#FFF0D0",
bgSecondary: "#FFFFFF",
textPrimary: "#111111",
textSecondary: "#444444",
textOnPrimary: "#FFFFFF",
},
dark: {
primary: "#60CCE2",
secondary: "#60A5D8",
accent: "#EFC707",
error: "#FF5244",
bgPrimary: "#091829",
bgSecondary: "#1A2E3E",
textPrimary: "#F5F5F5",
textSecondary: "#C6C6C6",
textOnPrimary: "#FFFFFF",
},
},
{
id: "ocean",
name: "Ocean",
description: "Deep blues and coral tones",
light: {
primary: "#006B8F",
secondary: "#008A8A",
accent: "#FF7E50",
error: "#DD1C1A",
bgPrimary: "#E4EBF1",
bgSecondary: "#BCCAD5",
textPrimary: "#111111",
textSecondary: "#444444",
textOnPrimary: "#FFFFFF",
},
dark: {
primary: "#49B5D1",
secondary: "#60D1C6",
accent: "#FF7E50",
error: "#FF5244",
bgPrimary: "#161B22",
bgSecondary: "#313A4B",
textPrimary: "#F5F5F5",
textSecondary: "#C6C6C6",
textOnPrimary: "#FFFFFF",
},
},
{
id: "forest",
name: "Forest",
description: "Earth greens and golden hues",
light: {
primary: "#2C5015",
secondary: "#6B8E22",
accent: "#FFD600",
error: "#DD1C1A",
bgPrimary: "#EBEEE2",
bgSecondary: "#C1C8AD",
textPrimary: "#111111",
textSecondary: "#444444",
textOnPrimary: "#FFFFFF",
},
dark: {
primary: "#93C66B",
secondary: "#AFD182",
accent: "#FFD600",
error: "#FF5244",
bgPrimary: "#181E17",
bgSecondary: "#384436",
textPrimary: "#F5F5F5",
textSecondary: "#C6C6C6",
textOnPrimary: "#FFFFFF",
},
},
{
id: "sunset",
name: "Sunset",
description: "Warm oranges and reds",
light: {
primary: "#FF4500",
secondary: "#FF6246",
accent: "#FFD600",
error: "#DD1C1A",
bgPrimary: "#F7F0E8",
bgSecondary: "#DCD0BA",
textPrimary: "#111111",
textSecondary: "#444444",
textOnPrimary: "#FFFFFF",
},
dark: {
primary: "#FF9E60",
secondary: "#FFAD7C",
accent: "#FFD600",
error: "#FF5244",
bgPrimary: "#201813",
bgSecondary: "#433329",
textPrimary: "#F5F5F5",
textSecondary: "#C6C6C6",
textOnPrimary: "#FFFFFF",
},
},
{
id: "monochrome",
name: "Monochrome",
description: "Elegant grayscale",
light: {
primary: "#333333",
secondary: "#666666",
accent: "#999999",
error: "#DD1C1A",
bgPrimary: "#F0F0F0",
bgSecondary: "#D4D4D4",
textPrimary: "#111111",
textSecondary: "#444444",
textOnPrimary: "#FFFFFF",
},
dark: {
primary: "#E5E5E5",
secondary: "#BFBFBF",
accent: "#D1D1D1",
error: "#FF5244",
bgPrimary: "#161616",
bgSecondary: "#3B3B3B",
textPrimary: "#F5F5F5",
textSecondary: "#C6C6C6",
textOnPrimary: "#FFFFFF",
},
},
{
id: "lavender",
name: "Lavender",
description: "Soft purple with pink accents",
light: {
primary: "#6B418A",
secondary: "#8A60AF",
accent: "#E24982",
error: "#DD1C1A",
bgPrimary: "#F1EFF5",
bgSecondary: "#D9D1DF",
textPrimary: "#111111",
textSecondary: "#444444",
textOnPrimary: "#FFFFFF",
},
dark: {
primary: "#D1AFE2",
secondary: "#DDBFEA",
accent: "#FF9EC6",
error: "#FF5244",
bgPrimary: "#17131E",
bgSecondary: "#393042",
textPrimary: "#F5F5F5",
textSecondary: "#C6C6C6",
textOnPrimary: "#FFFFFF",
},
},
{
id: "crimson",
name: "Crimson",
description: "Bold red with warm highlights",
light: {
primary: "#B51E28",
secondary: "#992D38",
accent: "#E26000",
error: "#DD1C1A",
bgPrimary: "#F6EDEB",
bgSecondary: "#DECFCC",
textPrimary: "#111111",
textSecondary: "#444444",
textOnPrimary: "#FFFFFF",
},
dark: {
primary: "#FF827C",
secondary: "#F99993",
accent: "#FFB56B",
error: "#FF5244",
bgPrimary: "#1B1215",
bgSecondary: "#412E39",
textPrimary: "#F5F5F5",
textSecondary: "#C6C6C6",
textOnPrimary: "#FFFFFF",
},
},
{
id: "midnight",
name: "Midnight",
description: "Deep navy with sky blue",
light: {
primary: "#1E4993",
secondary: "#2D60AF",
accent: "#4993E2",
error: "#DD1C1A",
bgPrimary: "#EDF0F7",
bgSecondary: "#CCD5E2",
textPrimary: "#111111",
textSecondary: "#444444",
textOnPrimary: "#FFFFFF",
},
dark: {
primary: "#82B5EA",
secondary: "#93C6F2",
accent: "#9ED8FF",
error: "#FF5244",
bgPrimary: "#12161F",
bgSecondary: "#2F3848",
textPrimary: "#F5F5F5",
textSecondary: "#C6C6C6",
textOnPrimary: "#FFFFFF",
},
},
{
id: "desert",
name: "Desert",
description: "Warm terracotta and sand tones",
light: {
primary: "#AF6049",
secondary: "#9E7C60",
accent: "#D1932D",
error: "#DD1C1A",
bgPrimary: "#F6F0EA",
bgSecondary: "#E5D8C6",
textPrimary: "#111111",
textSecondary: "#444444",
textOnPrimary: "#FFFFFF",
},
dark: {
primary: "#F2B593",
secondary: "#EAD1AF",
accent: "#FFD86B",
error: "#FF5244",
bgPrimary: "#1F1C16",
bgSecondary: "#494138",
textPrimary: "#F5F5F5",
textSecondary: "#C6C6C6",
textOnPrimary: "#FFFFFF",
},
},
{
id: "mint",
name: "Mint",
description: "Fresh green with turquoise",
light: {
primary: "#38AF93",
secondary: "#60C6AF",
accent: "#2D9EAF",
error: "#DD1C1A",
bgPrimary: "#EDF6F0",
bgSecondary: "#D1E2D8",
textPrimary: "#111111",
textSecondary: "#444444",
textOnPrimary: "#FFFFFF",
},
dark: {
primary: "#93F2D8",
secondary: "#BFF9EA",
accent: "#6BEAF2",
error: "#FF5244",
bgPrimary: "#161F1F",
bgSecondary: "#384949",
textPrimary: "#F5F5F5",
textSecondary: "#C6C6C6",
textOnPrimary: "#FFFFFF",
},
},
];
/** Default theme ID */
export const DEFAULT_THEME_ID = "default";
/** Get a theme definition by ID, falls back to Default */
export function getThemeById(id: string): ThemeDefinition {
return themes.find((t) => t.id === id) ?? themes[0];
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { useEffect } from "react";
import { useThemeStore } from "@/stores/theme";
/**
* ThemeProvider syncs the Zustand theme store with the DOM.
*
* It sets:
* - `data-theme` attribute on <html> for CSS theme selection
* - `dark` class on <html> for dark mode (respects system preference when mode is "system")
*
* Render this component once, near the root of the app (e.g., in layout.tsx).
* It renders no visible DOM — it only produces side effects.
*/
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const themeId = useThemeStore((s) => s.themeId);
const mode = useThemeStore((s) => s.mode);
// Sync data-theme attribute
useEffect(() => {
document.documentElement.setAttribute("data-theme", themeId);
}, [themeId]);
// Sync dark class based on mode + system preference
useEffect(() => {
const applyDarkClass = (isDark: boolean) => {
if (isDark) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
};
if (mode === "dark") {
applyDarkClass(true);
return;
}
if (mode === "light") {
applyDarkClass(false);
return;
}
// mode === "system" — follow OS preference
const mql = window.matchMedia("(prefers-color-scheme: dark)");
applyDarkClass(mql.matches);
const handler = (e: MediaQueryListEvent) => applyDarkClass(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [mode]);
return <>{children}</>;
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useState } from "react";
import { useThemeStore, type ColorMode } from "@/stores/theme";
import { themes, getThemeById, type ThemeDefinition } from "./theme-config";
/**
* Convenience hook wrapping the Zustand theme store.
*
* Returns the current theme definition, mode, resolved effective mode
* (accounting for system preference), and setters.
*/
export function useTheme() {
const { themeId, mode, setTheme, setMode } = useThemeStore();
const [effectiveMode, setEffectiveMode] = useState<"light" | "dark">("light");
useEffect(() => {
if (mode !== "system") {
setEffectiveMode(mode);
return;
}
const mql = window.matchMedia("(prefers-color-scheme: dark)");
setEffectiveMode(mql.matches ? "dark" : "light");
const handler = (e: MediaQueryListEvent) =>
setEffectiveMode(e.matches ? "dark" : "light");
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [mode]);
const currentTheme: ThemeDefinition = getThemeById(themeId);
return {
/** Current theme ID string */
themeId,
/** Current mode setting (light | dark | system) */
mode,
/** Resolved mode after considering system preference */
effectiveMode,
/** Full theme definition for the active theme */
currentTheme,
/** All available theme definitions */
themes,
/** Set the active theme by ID */
setTheme,
/** Set the color mode */
setMode,
};
}
export type { ThemeDefinition, ColorMode };
+39
View File
@@ -0,0 +1,39 @@
// ============================================================================
// API-level types: errors, pagination, common response wrappers
// Generated from myCribAPI-go/internal/dto/responses/auth.go (ErrorResponse, MessageResponse)
// ============================================================================
/**
* Structured API error with HTTP status code.
* Throw this from fetch wrappers so callers can inspect `status`.
*/
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
/** Wire format returned by the Go error handler. */
export interface ErrorResponse {
error: string;
details?: Record<string, string>;
}
/** Simple message-only response used by many endpoints. */
export interface MessageResponse {
message: string;
}
/**
* Generic paginated response wrapper.
* The Go API currently returns bare arrays for most list endpoints,
* but notifications use `{ count, results }`.
*/
export interface PaginatedResponse<T> {
count: number;
results: T[];
}
+163
View File
@@ -0,0 +1,163 @@
// ============================================================================
// Auth request / response types
// Generated from:
// myCribAPI-go/internal/dto/requests/auth.go
// myCribAPI-go/internal/dto/responses/auth.go
// ============================================================================
// ---------------------------------------------------------------------------
// Requests
// ---------------------------------------------------------------------------
export interface LoginRequest {
username?: string;
email?: string;
password: string;
}
export interface RegisterRequest {
username: string;
email: string;
password: string;
first_name?: string;
last_name?: string;
}
export interface VerifyEmailRequest {
code: string;
}
export interface ForgotPasswordRequest {
email: string;
}
export interface VerifyResetCodeRequest {
email: string;
code: string;
}
export interface ResetPasswordRequest {
reset_token: string;
new_password: string;
}
export interface UpdateProfileRequest {
email?: string | null;
first_name?: string | null;
last_name?: string | null;
}
export interface ResendVerificationRequest {
// No body needed - uses authenticated user's email
}
export interface AppleSignInRequest {
id_token: string;
user_id: string;
email?: string | null;
first_name?: string | null;
last_name?: string | null;
}
export interface GoogleSignInRequest {
id_token: string;
}
// ---------------------------------------------------------------------------
// Responses
// ---------------------------------------------------------------------------
export interface UserResponse {
id: number;
username: string;
email: string;
first_name: string;
last_name: string;
is_active: boolean;
verified: boolean;
date_joined: string;
last_login?: string | null;
}
export interface UserProfileResponse {
id: number;
user_id: number;
verified: boolean;
bio: string;
phone_number: string;
date_of_birth?: string | null;
profile_picture: string;
}
export interface LoginResponse {
token: string;
user: UserResponse;
}
export interface RegisterResponse {
token: string;
user: UserResponse;
message: string;
}
export interface CurrentUserResponse {
id: number;
username: string;
email: string;
first_name: string;
last_name: string;
is_active: boolean;
date_joined: string;
last_login?: string | null;
profile?: UserProfileResponse | null;
}
export interface VerifyEmailResponse {
message: string;
verified: boolean;
}
export interface ForgotPasswordResponse {
message: string;
}
export interface VerifyResetCodeResponse {
message: string;
reset_token: string;
}
export interface ResetPasswordResponse {
message: string;
}
export interface AppleSignInResponse {
token: string;
user: UserResponse;
is_new_user: boolean;
}
export interface GoogleSignInResponse {
token: string;
user: UserResponse;
is_new_user: boolean;
}
// ---------------------------------------------------------------------------
// User summary types (from responses/user.go)
// ---------------------------------------------------------------------------
export interface UserSummary {
id: number;
username: string;
email: string;
first_name: string;
last_name: string;
}
export interface UserProfileSummary {
id: number;
user_id: number;
bio?: string;
profile_picture?: string;
phone_number?: string;
}
+93
View File
@@ -0,0 +1,93 @@
// ============================================================================
// Contractor request / response types
// Generated from:
// myCribAPI-go/internal/dto/requests/contractor.go
// myCribAPI-go/internal/dto/responses/contractor.go
// ============================================================================
// ---------------------------------------------------------------------------
// Requests
// ---------------------------------------------------------------------------
export interface CreateContractorRequest {
residence_id?: number | null;
name: string;
company?: string;
phone?: string;
email?: string;
website?: string;
notes?: string;
street_address?: string;
city?: string;
state_province?: string;
postal_code?: string;
specialty_ids?: number[];
rating?: number | null;
is_favorite?: boolean | null;
}
export interface UpdateContractorRequest {
name?: string | null;
company?: string | null;
phone?: string | null;
email?: string | null;
website?: string | null;
notes?: string | null;
street_address?: string | null;
city?: string | null;
state_province?: string | null;
postal_code?: string | null;
specialty_ids?: number[];
rating?: number | null;
is_favorite?: boolean | null;
residence_id?: number | null;
}
// ---------------------------------------------------------------------------
// Responses
// ---------------------------------------------------------------------------
export interface ContractorSpecialtyResponse {
id: number;
name: string;
description: string;
icon: string;
display_order: number;
}
export interface ContractorUserResponse {
id: number;
username: string;
first_name: string;
last_name: string;
}
export interface ContractorResponse {
id: number;
residence_id: number | null;
created_by_id: number;
added_by: number;
created_by?: ContractorUserResponse | null;
name: string;
company: string;
phone: string;
email: string;
website: string;
notes: string;
street_address: string;
city: string;
state_province: string;
postal_code: string;
specialties: ContractorSpecialtyResponse[];
rating: number | null;
is_favorite: boolean;
is_active: boolean;
task_count?: number;
created_at: string;
updated_at: string;
}
export interface ToggleFavoriteResponse {
message: string;
is_favorite: boolean;
}
+105
View File
@@ -0,0 +1,105 @@
// ============================================================================
// Document request / response types
// Generated from:
// myCribAPI-go/internal/dto/requests/document.go
// myCribAPI-go/internal/dto/responses/document.go
// myCribAPI-go/internal/models/document.go (DocumentType enum)
// ============================================================================
// ---------------------------------------------------------------------------
// Enums
// ---------------------------------------------------------------------------
/** Matches models.DocumentType string enum in Go. */
export type DocumentType =
| "general"
| "warranty"
| "receipt"
| "contract"
| "insurance"
| "manual";
// ---------------------------------------------------------------------------
// Requests
// ---------------------------------------------------------------------------
export interface CreateDocumentRequest {
residence_id: number;
title: string;
description?: string;
document_type?: DocumentType;
file_url?: string;
file_name?: string;
file_size?: number | null;
mime_type?: string;
purchase_date?: string | null;
expiry_date?: string | null;
purchase_price?: string | null;
vendor?: string;
serial_number?: string;
model_number?: string;
task_id?: number | null;
image_urls?: string[];
}
export interface UpdateDocumentRequest {
title?: string | null;
description?: string | null;
document_type?: DocumentType | null;
file_url?: string | null;
file_name?: string | null;
file_size?: number | null;
mime_type?: string | null;
purchase_date?: string | null;
expiry_date?: string | null;
purchase_price?: string | null;
vendor?: string | null;
serial_number?: string | null;
model_number?: string | null;
task_id?: number | null;
}
// ---------------------------------------------------------------------------
// Responses
// ---------------------------------------------------------------------------
export interface DocumentUserResponse {
id: number;
username: string;
first_name: string;
last_name: string;
}
export interface DocumentImageResponse {
id: number;
image_url: string;
media_url: string;
caption: string;
}
export interface DocumentResponse {
id: number;
residence_id: number;
residence: number;
created_by_id: number;
created_by?: DocumentUserResponse | null;
title: string;
description: string;
document_type: DocumentType;
file_url: string;
media_url: string;
file_name: string;
file_size: number | null;
mime_type: string;
purchase_date: string | null;
expiry_date: string | null;
purchase_price: string | null;
vendor: string;
serial_number: string;
model_number: string;
task_id: number | null;
is_active: boolean;
images: DocumentImageResponse[];
created_at: string;
updated_at: string;
}
+133
View File
@@ -0,0 +1,133 @@
// ============================================================================
// Central re-export barrel for all API types
// ============================================================================
// API-level types
export { ApiError } from "./api";
export type { ErrorResponse, MessageResponse, PaginatedResponse } from "./api";
// Auth
export type {
LoginRequest,
RegisterRequest,
VerifyEmailRequest,
ForgotPasswordRequest,
VerifyResetCodeRequest,
ResetPasswordRequest,
UpdateProfileRequest,
ResendVerificationRequest,
AppleSignInRequest,
GoogleSignInRequest,
UserResponse,
UserProfileResponse,
LoginResponse,
RegisterResponse,
CurrentUserResponse,
VerifyEmailResponse,
ForgotPasswordResponse,
VerifyResetCodeResponse,
ResetPasswordResponse,
AppleSignInResponse,
GoogleSignInResponse,
UserSummary,
UserProfileSummary,
} from "./auth";
// Residence
export type {
CreateResidenceRequest,
UpdateResidenceRequest,
JoinWithCodeRequest,
GenerateShareCodeRequest,
ResidenceTypeResponse,
ResidenceUserResponse,
ResidenceResponse,
TotalSummary,
MyResidencesResponse,
ResidenceWithSummaryResponse,
ResidenceDeleteWithSummaryResponse,
ShareCodeResponse,
JoinResidenceResponse,
GenerateShareCodeResponse,
SharePackageResponse,
} from "./residence";
// Task
export type {
CreateTaskRequest,
UpdateTaskRequest,
CreateTaskCompletionRequest,
UpdateTaskCompletionRequest,
CompletionImageInput,
TaskCategoryResponse,
TaskPriorityResponse,
TaskFrequencyResponse,
TaskUserResponse,
TaskCompletionImageResponse,
TaskCompletionResponse,
TaskResponse,
KanbanColumnResponse,
KanbanBoardResponse,
TaskWithSummaryResponse,
TaskCompletionWithSummaryResponse,
DeleteWithSummaryResponse,
} from "./task";
// Contractor
export type {
CreateContractorRequest,
UpdateContractorRequest,
ContractorSpecialtyResponse,
ContractorUserResponse,
ContractorResponse,
ToggleFavoriteResponse,
} from "./contractor";
// Document
export type { DocumentType } from "./document";
export type {
CreateDocumentRequest,
UpdateDocumentRequest,
DocumentUserResponse,
DocumentImageResponse,
DocumentResponse,
} from "./document";
// Task Template
export type {
TaskTemplateResponse,
TaskTemplateCategoryGroup,
TaskTemplatesGroupedResponse,
} from "./task-template";
// Lookups
export type { StaticDataResponse } from "./lookups";
// Subscription
export type { SubscriptionTier } from "./subscription";
export type {
ProcessPurchaseRequest,
SubscriptionResponse,
TierLimitsResponse,
UsageResponse,
TierLimitsClientResponse,
SubscriptionStatusResponse,
UpgradeTriggerResponse,
UpgradeTriggerDataResponse,
FeatureBenefitResponse,
PromotionResponse,
PurchaseResponse,
} from "./subscription";
// Notification
export type { NotificationType } from "./notification";
export type {
UpdatePreferencesRequest,
RegisterDeviceRequest,
UnregisterDeviceRequest,
NotificationResponse,
NotificationPreferencesResponse,
DeviceResponse,
UnreadCountResponse,
NotificationListResponse,
} from "./notification";
+31
View File
@@ -0,0 +1,31 @@
// ============================================================================
// Static / lookup data types
// Generated from:
// myCribAPI-go/internal/handlers/static_data_handler.go (SeededDataResponse)
// myCribAPI-go/internal/dto/responses/residence.go (ResidenceTypeResponse)
// myCribAPI-go/internal/dto/responses/task.go (TaskCategory/Priority/Frequency)
// myCribAPI-go/internal/dto/responses/contractor.go (ContractorSpecialtyResponse)
// myCribAPI-go/internal/dto/responses/task_template.go (TaskTemplatesGroupedResponse)
// ============================================================================
import type { ResidenceTypeResponse } from "./residence";
import type {
TaskCategoryResponse,
TaskPriorityResponse,
TaskFrequencyResponse,
} from "./task";
import type { ContractorSpecialtyResponse } from "./contractor";
import type { TaskTemplatesGroupedResponse } from "./task-template";
/**
* Unified response from GET /api/static_data/.
* Contains all lookup/reference data in a single payload with ETag support.
*/
export interface StaticDataResponse {
residence_types: ResidenceTypeResponse[];
task_categories: TaskCategoryResponse[];
task_priorities: TaskPriorityResponse[];
task_frequencies: TaskFrequencyResponse[];
contractor_specialties: ContractorSpecialtyResponse[];
task_templates: TaskTemplatesGroupedResponse;
}
+104
View File
@@ -0,0 +1,104 @@
// ============================================================================
// Notification request / response types
// Generated from:
// myCribAPI-go/internal/services/notification_service.go
// myCribAPI-go/internal/models/notification.go (NotificationType enum)
// ============================================================================
// ---------------------------------------------------------------------------
// Enums
// ---------------------------------------------------------------------------
/** Matches models.NotificationType string enum in Go. */
export type NotificationType =
| "task_due_soon"
| "task_overdue"
| "task_completed"
| "task_assigned"
| "residence_shared"
| "warranty_expiring";
// ---------------------------------------------------------------------------
// Requests
// ---------------------------------------------------------------------------
export interface UpdatePreferencesRequest {
task_due_soon?: boolean | null;
task_overdue?: boolean | null;
task_completed?: boolean | null;
task_assigned?: boolean | null;
residence_shared?: boolean | null;
warranty_expiring?: boolean | null;
daily_digest?: boolean | null;
email_task_completed?: boolean | null;
task_due_soon_hour?: number | null;
task_overdue_hour?: number | null;
warranty_expiring_hour?: number | null;
daily_digest_hour?: number | null;
}
export interface RegisterDeviceRequest {
name?: string;
device_id: string;
registration_id: string;
platform: "ios" | "android";
}
export interface UnregisterDeviceRequest {
registration_id: string;
platform?: string;
}
// ---------------------------------------------------------------------------
// Responses
// ---------------------------------------------------------------------------
export interface NotificationResponse {
id: number;
user_id: number;
notification_type: NotificationType;
title: string;
body: string;
data: Record<string, unknown>;
read: boolean;
read_at: string | null;
sent: boolean;
sent_at: string | null;
created_at: string;
}
export interface NotificationPreferencesResponse {
task_due_soon: boolean;
task_overdue: boolean;
task_completed: boolean;
task_assigned: boolean;
residence_shared: boolean;
warranty_expiring: boolean;
daily_digest: boolean;
email_task_completed: boolean;
task_due_soon_hour: number | null;
task_overdue_hour: number | null;
warranty_expiring_hour: number | null;
daily_digest_hour: number | null;
}
export interface DeviceResponse {
id: number;
name: string;
device_id: string;
registration_id: string;
platform: string;
active: boolean;
date_created: string;
}
/** Response shape from GET /api/notifications/unread-count/ */
export interface UnreadCountResponse {
unread_count: number;
}
/** Response shape from GET /api/notifications/ */
export interface NotificationListResponse {
count: number;
results: NotificationResponse[];
}
+155
View File
@@ -0,0 +1,155 @@
// ============================================================================
// Residence request / response types
// Generated from:
// myCribAPI-go/internal/dto/requests/residence.go
// myCribAPI-go/internal/dto/responses/residence.go
// ============================================================================
// ---------------------------------------------------------------------------
// Requests
// ---------------------------------------------------------------------------
export interface CreateResidenceRequest {
name: string;
property_type_id?: number | null;
street_address?: string;
apartment_unit?: string;
city?: string;
state_province?: string;
postal_code?: string;
country?: string;
bedrooms?: number | null;
bathrooms?: string | null;
square_footage?: number | null;
lot_size?: string | null;
year_built?: number | null;
description?: string;
purchase_date?: string | null;
purchase_price?: string | null;
is_primary?: boolean | null;
}
export interface UpdateResidenceRequest {
name?: string | null;
property_type_id?: number | null;
street_address?: string | null;
apartment_unit?: string | null;
city?: string | null;
state_province?: string | null;
postal_code?: string | null;
country?: string | null;
bedrooms?: number | null;
bathrooms?: string | null;
square_footage?: number | null;
lot_size?: string | null;
year_built?: number | null;
description?: string | null;
purchase_date?: string | null;
purchase_price?: string | null;
is_primary?: boolean | null;
}
export interface JoinWithCodeRequest {
code: string;
}
export interface GenerateShareCodeRequest {
expires_in_hours?: number;
}
// ---------------------------------------------------------------------------
// Responses
// ---------------------------------------------------------------------------
export interface ResidenceTypeResponse {
id: number;
name: string;
}
export interface ResidenceUserResponse {
id: number;
username: string;
email: string;
first_name: string;
last_name: string;
}
export interface ResidenceResponse {
id: number;
owner_id: number;
owner?: ResidenceUserResponse | null;
users?: ResidenceUserResponse[];
name: string;
property_type_id: number | null;
property_type?: ResidenceTypeResponse | null;
street_address: string;
apartment_unit: string;
city: string;
state_province: string;
postal_code: string;
country: string;
bedrooms: number | null;
bathrooms: string | null;
square_footage: number | null;
lot_size: string | null;
year_built: number | null;
description: string;
purchase_date: string | null;
purchase_price: string | null;
is_primary: boolean;
is_active: boolean;
overdue_count: number;
created_at: string;
updated_at: string;
}
export interface TotalSummary {
total_residences: number;
total_tasks: number;
total_pending: number;
total_overdue: number;
tasks_due_next_week: number;
tasks_due_next_month: number;
}
export interface MyResidencesResponse {
residences: ResidenceResponse[];
}
export interface ResidenceWithSummaryResponse {
data: ResidenceResponse;
summary: TotalSummary;
}
export interface ResidenceDeleteWithSummaryResponse {
data: string;
summary: TotalSummary;
}
export interface ShareCodeResponse {
id: number;
code: string;
residence_id: number;
created_by_id: number;
is_active: boolean;
expires_at: string | null;
created_at: string;
}
export interface JoinResidenceResponse {
message: string;
residence: ResidenceResponse;
summary: TotalSummary;
}
export interface GenerateShareCodeResponse {
message: string;
share_code: ShareCodeResponse;
}
export interface SharePackageResponse {
share_code: string;
residence_name: string;
shared_by: string;
expires_at?: string | null;
}
+111
View File
@@ -0,0 +1,111 @@
// ============================================================================
// Subscription request / response types
// Generated from:
// myCribAPI-go/internal/services/subscription_service.go
// myCribAPI-go/internal/models/subscription.go (SubscriptionTier enum)
// ============================================================================
// ---------------------------------------------------------------------------
// Enums
// ---------------------------------------------------------------------------
/** Matches models.SubscriptionTier string enum in Go. */
export type SubscriptionTier = "free" | "pro";
// ---------------------------------------------------------------------------
// Requests
// ---------------------------------------------------------------------------
export interface ProcessPurchaseRequest {
receipt_data?: string;
transaction_id?: string;
purchase_token?: string;
product_id?: string;
platform: "ios" | "android";
}
// ---------------------------------------------------------------------------
// Responses
// ---------------------------------------------------------------------------
export interface SubscriptionResponse {
tier: string;
subscribed_at: string | null;
expires_at: string | null;
auto_renew: boolean;
cancelled_at: string | null;
platform: string;
is_active: boolean;
is_pro: boolean;
}
export interface TierLimitsResponse {
tier: string;
properties_limit: number | null;
tasks_limit: number | null;
contractors_limit: number | null;
documents_limit: number | null;
}
export interface UsageResponse {
properties_count: number;
tasks_count: number;
contractors_count: number;
documents_count: number;
}
export interface TierLimitsClientResponse {
properties: number | null;
tasks: number | null;
contractors: number | null;
documents: number | null;
}
export interface SubscriptionStatusResponse {
subscribed_at: string | null;
expires_at: string | null;
auto_renew: boolean;
usage: UsageResponse | null;
limits: Record<string, TierLimitsClientResponse>;
limitations_enabled: boolean;
}
export interface UpgradeTriggerResponse {
trigger_key: string;
title: string;
message: string;
promo_html: string;
button_text: string;
}
export interface UpgradeTriggerDataResponse {
title: string;
message: string;
promo_html: string | null;
button_text: string;
}
export interface FeatureBenefitResponse {
feature_name: string;
free_tier_text: string;
pro_tier_text: string;
display_order: number;
}
export interface PromotionResponse {
promotion_id: string;
title: string;
message: string;
link: string | null;
start_date: string;
end_date: string;
}
// ---------------------------------------------------------------------------
// Purchase response wrappers (inline maps from handler)
// ---------------------------------------------------------------------------
export interface PurchaseResponse {
message: string;
subscription: SubscriptionResponse;
}
+36
View File
@@ -0,0 +1,36 @@
// ============================================================================
// Task template response types
// Generated from:
// myCribAPI-go/internal/dto/responses/task_template.go
// ============================================================================
import type { TaskCategoryResponse, TaskFrequencyResponse } from "./task";
export interface TaskTemplateResponse {
id: number;
title: string;
description: string;
category_id: number | null;
category?: TaskCategoryResponse | null;
frequency_id: number | null;
frequency?: TaskFrequencyResponse | null;
icon_ios: string;
icon_android: string;
tags: string[];
display_order: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface TaskTemplateCategoryGroup {
category_name: string;
category_id: number | null;
templates: TaskTemplateResponse[];
count: number;
}
export interface TaskTemplatesGroupedResponse {
categories: TaskTemplateCategoryGroup[];
total_count: number;
}
+184
View File
@@ -0,0 +1,184 @@
// ============================================================================
// Task request / response types
// Generated from:
// myCribAPI-go/internal/dto/requests/task.go
// myCribAPI-go/internal/dto/responses/task.go
// ============================================================================
import type { TotalSummary } from "./residence";
// ---------------------------------------------------------------------------
// Requests
// ---------------------------------------------------------------------------
export interface CreateTaskRequest {
residence_id: number;
title: string;
description?: string;
category_id?: number | null;
priority_id?: number | null;
frequency_id?: number | null;
custom_interval_days?: number | null;
in_progress?: boolean;
assigned_to_id?: number | null;
due_date?: string | null;
estimated_cost?: string | null;
contractor_id?: number | null;
}
export interface UpdateTaskRequest {
title?: string | null;
description?: string | null;
category_id?: number | null;
priority_id?: number | null;
frequency_id?: number | null;
custom_interval_days?: number | null;
in_progress?: boolean | null;
assigned_to_id?: number | null;
due_date?: string | null;
estimated_cost?: string | null;
actual_cost?: string | null;
contractor_id?: number | null;
}
export interface CreateTaskCompletionRequest {
task_id: number;
completed_at?: string | null;
notes?: string;
actual_cost?: string | null;
rating?: number | null;
image_urls?: string[];
}
export interface UpdateTaskCompletionRequest {
notes?: string | null;
actual_cost?: string | null;
rating?: number | null;
image_urls?: string[];
}
export interface CompletionImageInput {
image_url: string;
caption?: string;
}
// ---------------------------------------------------------------------------
// Responses
// ---------------------------------------------------------------------------
export interface TaskCategoryResponse {
id: number;
name: string;
description: string;
icon: string;
color: string;
display_order: number;
}
export interface TaskPriorityResponse {
id: number;
name: string;
level: number;
color: string;
display_order: number;
}
export interface TaskFrequencyResponse {
id: number;
name: string;
days: number | null;
display_order: number;
}
export interface TaskUserResponse {
id: number;
username: string;
email: string;
first_name: string;
last_name: string;
}
export interface TaskCompletionImageResponse {
id: number;
image_url: string;
media_url: string;
caption: string;
}
export interface TaskCompletionResponse {
id: number;
task_id: number;
completed_by?: TaskUserResponse | null;
completed_at: string;
notes: string;
actual_cost: string | null;
rating: number | null;
images: TaskCompletionImageResponse[];
created_at: string;
task?: TaskResponse | null;
}
export interface TaskResponse {
id: number;
residence_id: number;
created_by_id: number;
created_by?: TaskUserResponse | null;
assigned_to_id: number | null;
assigned_to?: TaskUserResponse | null;
title: string;
description: string;
category_id: number | null;
category?: TaskCategoryResponse | null;
priority_id: number | null;
priority?: TaskPriorityResponse | null;
frequency_id: number | null;
frequency?: TaskFrequencyResponse | null;
in_progress: boolean;
due_date: string | null;
next_due_date: string | null;
estimated_cost: string | null;
actual_cost: string | null;
contractor_id: number | null;
is_cancelled: boolean;
is_archived: boolean;
parent_task_id: number | null;
completion_count: number;
kanban_column?: string;
created_at: string;
updated_at: string;
}
export interface KanbanColumnResponse {
name: string;
display_name: string;
button_types: string[];
icons: Record<string, string>;
color: string;
tasks: TaskResponse[];
count: number;
}
export interface KanbanBoardResponse {
columns: KanbanColumnResponse[];
days_threshold: number;
residence_id: string;
}
// ---------------------------------------------------------------------------
// Response wrappers with summary
// ---------------------------------------------------------------------------
export interface TaskWithSummaryResponse {
data: TaskResponse;
summary: TotalSummary;
}
export interface TaskCompletionWithSummaryResponse {
data: TaskCompletionResponse;
summary: TotalSummary;
}
export interface DeleteWithSummaryResponse {
data: string;
summary: TotalSummary;
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+84
View File
@@ -0,0 +1,84 @@
import { z } from 'zod';
// ---------------------------------------------------------------------------
// Login
// ---------------------------------------------------------------------------
export const loginSchema = z.object({
username: z.string().min(1, 'Username or email is required'),
password: z.string().min(1, 'Password is required'),
});
export type LoginFormData = z.infer<typeof loginSchema>;
// ---------------------------------------------------------------------------
// Register
// ---------------------------------------------------------------------------
export const registerSchema = z
.object({
first_name: z.string().min(1, 'First name is required').max(150),
last_name: z.string().min(1, 'Last name is required').max(150),
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.max(150),
email: z.string().email('Invalid email address').max(254),
password: z.string().min(8, 'Password must be at least 8 characters'),
confirm_password: z.string(),
})
.refine((data) => data.password === data.confirm_password, {
message: "Passwords don't match",
path: ['confirm_password'],
});
export type RegisterFormData = z.infer<typeof registerSchema>;
// ---------------------------------------------------------------------------
// Verify email
// ---------------------------------------------------------------------------
export const verifyEmailSchema = z.object({
code: z.string().length(6, 'Code must be 6 digits'),
});
export type VerifyEmailFormData = z.infer<typeof verifyEmailSchema>;
// ---------------------------------------------------------------------------
// Forgot password
// ---------------------------------------------------------------------------
export const forgotPasswordSchema = z.object({
email: z.string().email('Invalid email address'),
});
export type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
// ---------------------------------------------------------------------------
// Verify reset code
// ---------------------------------------------------------------------------
export const verifyResetCodeSchema = z.object({
email: z.string().email(),
code: z.string().length(6, 'Code must be 6 digits'),
});
export type VerifyResetCodeFormData = z.infer<typeof verifyResetCodeSchema>;
// ---------------------------------------------------------------------------
// Reset password
// ---------------------------------------------------------------------------
export const resetPasswordSchema = z
.object({
email: z.string().email(),
code: z.string(),
new_password: z.string().min(8, 'Password must be at least 8 characters'),
confirm_password: z.string(),
})
.refine((data) => data.new_password === data.confirm_password, {
message: "Passwords don't match",
path: ['confirm_password'],
});
export type ResetPasswordFormData = z.infer<typeof resetPasswordSchema>;