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:
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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/`);
|
||||
}
|
||||
@@ -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' },
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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}/`);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -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/');
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user