Initial commit: SmoothSchedule multi-tenant scheduling platform

This commit includes:
- Django backend with multi-tenancy (django-tenants)
- React + TypeScript frontend with Vite
- Platform administration API with role-based access control
- Authentication system with token-based auth
- Quick login dev tools for testing different user roles
- CORS and CSRF configuration for local development
- Docker development environment setup

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-11-27 01:43:20 -05:00
commit 2e111364a2
567 changed files with 96410 additions and 0 deletions

View File

@@ -0,0 +1,332 @@
/**
* WebSocket hook for real-time appointment updates.
* Connects to the backend WebSocket and updates React Query cache.
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { getCookie } from '../utils/cookies';
import { getSubdomain } from '../api/config';
import { Appointment } from '../types';
interface WebSocketMessage {
type: 'connection_established' | 'appointment_created' | 'appointment_updated' | 'appointment_deleted' | 'pong';
appointment?: {
id: string;
business_id: string;
service_id: string;
resource_id: string | null;
customer_id: string;
customer_name: string;
start_time: string;
end_time: string;
duration_minutes: number;
status: string;
notes: string;
};
appointment_id?: string;
message?: string;
}
interface UseAppointmentWebSocketOptions {
enabled?: boolean;
onConnected?: () => void;
onDisconnected?: () => void;
onError?: (error: Event) => void;
}
/**
* Transform backend appointment format to frontend format
*/
function transformAppointment(data: WebSocketMessage['appointment']): Appointment | null {
if (!data) return null;
return {
id: data.id,
resourceId: data.resource_id,
customerId: data.customer_id,
customerName: data.customer_name,
serviceId: data.service_id,
startTime: new Date(data.start_time),
durationMinutes: data.duration_minutes,
status: data.status as Appointment['status'],
notes: data.notes,
};
}
/**
* Hook for real-time appointment updates via WebSocket.
* Handles React StrictMode's double-effect invocation gracefully.
*/
export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions = {}) {
const { enabled = true, onConnected, onDisconnected, onError } = options;
const queryClient = useQueryClient();
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
const isCleaningUpRef = useRef(false);
const maxReconnectAttempts = 5;
const [isConnected, setIsConnected] = useState(false);
// Store callbacks in refs to avoid effect re-runs
const onConnectedRef = useRef(onConnected);
const onDisconnectedRef = useRef(onDisconnected);
const onErrorRef = useRef(onError);
useEffect(() => {
onConnectedRef.current = onConnected;
onDisconnectedRef.current = onDisconnected;
onErrorRef.current = onError;
}, [onConnected, onDisconnected, onError]);
// Get WebSocket URL - not a callback to avoid recreating
const getWebSocketUrl = () => {
const token = getCookie('access_token');
const subdomain = getSubdomain();
if (!token || !subdomain) {
return null;
}
// Determine WebSocket host - use api subdomain for WebSocket
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = `api.lvh.me:8000`; // In production, this would come from config
return `${wsProtocol}//${wsHost}/ws/appointments/?token=${token}&subdomain=${subdomain}`;
};
const updateQueryCache = useCallback((message: WebSocketMessage) => {
const queryCache = queryClient.getQueryCache();
const appointmentQueries = queryCache.findAll({ queryKey: ['appointments'] });
appointmentQueries.forEach((query) => {
queryClient.setQueryData<Appointment[]>(query.queryKey, (old) => {
if (!old) return old;
switch (message.type) {
case 'appointment_created': {
const newAppointment = transformAppointment(message.appointment);
if (!newAppointment) return old;
// Check if appointment already exists (avoid duplicates)
if (old.some(apt => apt.id === newAppointment.id)) {
return old;
}
return [...old, newAppointment];
}
case 'appointment_updated': {
const updatedAppointment = transformAppointment(message.appointment);
if (!updatedAppointment) return old;
return old.map(apt =>
apt.id === updatedAppointment.id ? updatedAppointment : apt
);
}
case 'appointment_deleted': {
if (!message.appointment_id) return old;
return old.filter(apt => apt.id !== message.appointment_id);
}
default:
return old;
}
});
});
}, [queryClient]);
// Main effect to manage WebSocket connection
// Only depends on `enabled` - other values are read from refs or called as functions
useEffect(() => {
if (!enabled) {
return;
}
// Reset cleanup flag at start of effect
isCleaningUpRef.current = false;
// Track the current effect's abort controller to handle StrictMode
let effectAborted = false;
const connect = () => {
// Don't connect if effect was aborted or we're cleaning up
if (effectAborted || isCleaningUpRef.current) {
return;
}
const url = getWebSocketUrl();
if (!url) {
console.log('WebSocket: Missing token or subdomain, skipping connection');
return;
}
// Close existing connection if any
if (wsRef.current && wsRef.current.readyState !== WebSocket.CLOSED) {
wsRef.current.close();
}
console.log('WebSocket: Connecting to', url.replace(/token=[^&]+/, 'token=***'));
const ws = new WebSocket(url);
ws.onopen = () => {
// Don't process if effect was aborted or cleaning up
if (effectAborted || isCleaningUpRef.current) {
ws.close();
return;
}
console.log('WebSocket: Connected');
reconnectAttemptsRef.current = 0;
setIsConnected(true);
onConnectedRef.current?.();
// Start ping interval to keep connection alive
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
}
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN && !effectAborted) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000); // Ping every 30 seconds
};
ws.onmessage = (event) => {
// Ignore messages if effect was aborted
if (effectAborted) return;
try {
const message: WebSocketMessage = JSON.parse(event.data);
switch (message.type) {
case 'connection_established':
console.log('WebSocket: Connection confirmed -', message.message);
break;
case 'pong':
// Heartbeat response, ignore
break;
case 'appointment_created':
case 'appointment_updated':
case 'appointment_deleted':
console.log('WebSocket: Received', message.type);
updateQueryCache(message);
break;
default:
console.log('WebSocket: Unknown message type', message);
}
} catch (err) {
console.error('WebSocket: Failed to parse message', err);
}
};
ws.onerror = (error) => {
// Only log error if not aborted (StrictMode cleanup causes expected errors)
if (!effectAborted) {
console.error('WebSocket: Error', error);
onErrorRef.current?.(error);
}
};
ws.onclose = (event) => {
// Don't log or handle if effect was aborted (expected during StrictMode)
if (effectAborted) {
return;
}
console.log('WebSocket: Disconnected', event.code, event.reason);
setIsConnected(false);
onDisconnectedRef.current?.();
// Clear ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Only attempt reconnection if not cleaning up
if (!isCleaningUpRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
console.log(`WebSocket: Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`);
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current++;
connect();
}, delay);
}
};
wsRef.current = ws;
};
connect();
// Cleanup function
return () => {
effectAborted = true;
isCleaningUpRef.current = true;
// Clear reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
// Clear ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Close WebSocket
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
};
}, [enabled]); // Only re-run when enabled changes
const reconnect = useCallback(() => {
isCleaningUpRef.current = false;
reconnectAttemptsRef.current = 0;
// Close existing connection
if (wsRef.current) {
wsRef.current.close();
}
// Connection will be re-established by the effect when we force re-render
// For now, we'll rely on the onclose handler to trigger reconnection
}, []);
const disconnect = useCallback(() => {
isCleaningUpRef.current = true;
// Clear reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
// Clear ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Close WebSocket
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
}, []);
return {
isConnected,
reconnect,
disconnect,
};
}

View File

@@ -0,0 +1,279 @@
/**
* Appointment Management Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { Appointment, AppointmentStatus } from '../types';
import { format } from 'date-fns';
interface AppointmentFilters {
resource?: string;
status?: AppointmentStatus;
startDate?: Date;
endDate?: Date;
}
/**
* Hook to fetch appointments with optional filters
*/
export const useAppointments = (filters?: AppointmentFilters) => {
return useQuery<Appointment[]>({
queryKey: ['appointments', filters],
queryFn: async () => {
const params = new URLSearchParams();
if (filters?.resource) params.append('resource', filters.resource);
if (filters?.status) params.append('status', filters.status);
// Send full ISO datetime strings to avoid timezone issues
// The backend will compare datetime fields properly
if (filters?.startDate) {
// Start of day in local timezone, converted to ISO
const startOfDay = new Date(filters.startDate);
startOfDay.setHours(0, 0, 0, 0);
params.append('start_date', startOfDay.toISOString());
}
if (filters?.endDate) {
// End of day (or start of next day) in local timezone, converted to ISO
const endOfDay = new Date(filters.endDate);
endOfDay.setHours(0, 0, 0, 0);
params.append('end_date', endOfDay.toISOString());
}
const { data } = await apiClient.get(`/api/appointments/?${params}`);
// Transform backend format to frontend format
return data.map((a: any) => ({
id: String(a.id),
resourceId: a.resource_id ? String(a.resource_id) : null,
customerId: String(a.customer_id || a.customer),
customerName: a.customer_name || '',
serviceId: String(a.service_id || a.service),
startTime: new Date(a.start_time),
durationMinutes: a.duration_minutes || calculateDuration(a.start_time, a.end_time),
status: a.status as AppointmentStatus,
notes: a.notes || '',
}));
},
});
};
/**
* Calculate duration in minutes from start and end times
*/
function calculateDuration(startTime: string, endTime: string): number {
const start = new Date(startTime);
const end = new Date(endTime);
return Math.round((end.getTime() - start.getTime()) / (1000 * 60));
}
/**
* Hook to get a single appointment
*/
export const useAppointment = (id: string) => {
return useQuery<Appointment>({
queryKey: ['appointments', id],
queryFn: async () => {
const { data } = await apiClient.get(`/api/appointments/${id}/`);
return {
id: String(data.id),
resourceId: data.resource_id ? String(data.resource_id) : null,
customerId: String(data.customer_id || data.customer),
customerName: data.customer_name || '',
serviceId: String(data.service_id || data.service),
startTime: new Date(data.start_time),
durationMinutes: data.duration_minutes || calculateDuration(data.start_time, data.end_time),
status: data.status as AppointmentStatus,
notes: data.notes || '',
};
},
enabled: !!id,
});
};
/**
* Hook to create an appointment
*/
export const useCreateAppointment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (appointmentData: Omit<Appointment, 'id'>) => {
const startTime = appointmentData.startTime;
const endTime = new Date(startTime.getTime() + appointmentData.durationMinutes * 60000);
const backendData: Record<string, unknown> = {
service: parseInt(appointmentData.serviceId),
resource: appointmentData.resourceId ? parseInt(appointmentData.resourceId) : null,
start_time: startTime.toISOString(),
end_time: endTime.toISOString(),
notes: appointmentData.notes || '',
};
// Include customer if provided (for business-created appointments)
if (appointmentData.customerId) {
backendData.customer = parseInt(appointmentData.customerId);
}
const { data } = await apiClient.post('/api/appointments/', backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['appointments'] });
},
});
};
/**
* Hook to update an appointment with optimistic updates for instant UI feedback
*/
export const useUpdateAppointment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Appointment> }) => {
const backendData: any = {};
if (updates.serviceId) backendData.service = parseInt(updates.serviceId);
if (updates.resourceId !== undefined) {
backendData.resource = updates.resourceId ? parseInt(updates.resourceId) : null;
}
if (updates.startTime) {
backendData.start_time = updates.startTime.toISOString();
// Calculate end_time if we have duration, otherwise backend will keep existing duration
if (updates.durationMinutes) {
const endTime = new Date(updates.startTime.getTime() + updates.durationMinutes * 60000);
backendData.end_time = endTime.toISOString();
}
} else if (updates.durationMinutes) {
// If only duration changed, we need to get the current appointment to calculate new end time
// For now, just send duration and let backend handle it
// This case is handled by the resize logic which sends both startTime and durationMinutes
}
if (updates.status) backendData.status = updates.status;
if (updates.notes !== undefined) backendData.notes = updates.notes;
const { data } = await apiClient.patch(`/api/appointments/${id}/`, backendData);
return data;
},
// Optimistic update: update UI immediately before API call completes
onMutate: async ({ id, updates }) => {
// Cancel any outgoing refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ['appointments'] });
// Get all appointment queries and update them optimistically
const queryCache = queryClient.getQueryCache();
const appointmentQueries = queryCache.findAll({ queryKey: ['appointments'] });
const previousData: { queryKey: unknown[]; data: Appointment[] | undefined }[] = [];
appointmentQueries.forEach((query) => {
const data = queryClient.getQueryData<Appointment[]>(query.queryKey);
if (data) {
previousData.push({ queryKey: query.queryKey, data });
queryClient.setQueryData<Appointment[]>(query.queryKey, (old) => {
if (!old) return old;
return old.map((apt) =>
apt.id === id ? { ...apt, ...updates } : apt
);
});
}
});
// Return context with the previous values for rollback
return { previousData };
},
// If mutation fails, rollback to the previous values
onError: (error, _variables, context) => {
console.error('Failed to update appointment', error);
if (context?.previousData) {
context.previousData.forEach(({ queryKey, data }) => {
queryClient.setQueryData(queryKey, data);
});
}
},
// Always refetch after error or success to ensure server state
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['appointments'] });
},
});
};
/**
* Hook to delete an appointment with optimistic updates
*/
export const useDeleteAppointment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/api/appointments/${id}/`);
return id;
},
// Optimistic update: remove from UI immediately
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['appointments'] });
// Get all appointment queries and update them optimistically
const queryCache = queryClient.getQueryCache();
const appointmentQueries = queryCache.findAll({ queryKey: ['appointments'] });
const previousData: { queryKey: unknown[]; data: Appointment[] | undefined }[] = [];
appointmentQueries.forEach((query) => {
const data = queryClient.getQueryData<Appointment[]>(query.queryKey);
if (data) {
previousData.push({ queryKey: query.queryKey, data });
queryClient.setQueryData<Appointment[]>(query.queryKey, (old) => {
if (!old) return old;
return old.filter((apt) => apt.id !== id);
});
}
});
return { previousData };
},
onError: (error, _id, context) => {
console.error('Failed to delete appointment', error);
if (context?.previousData) {
context.previousData.forEach(({ queryKey, data }) => {
queryClient.setQueryData(queryKey, data);
});
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['appointments'] });
},
});
};
/**
* Hook to reschedule an appointment (update start time and resource)
*/
export const useRescheduleAppointment = () => {
const updateMutation = useUpdateAppointment();
return useMutation({
mutationFn: async ({
id,
newStartTime,
newResourceId,
}: {
id: string;
newStartTime: Date;
newResourceId?: string | null;
}) => {
const appointment = await apiClient.get(`/api/appointments/${id}/`);
const durationMinutes = appointment.data.duration_minutes;
return updateMutation.mutateAsync({
id,
updates: {
startTime: newStartTime,
durationMinutes,
resourceId: newResourceId !== undefined ? newResourceId : undefined,
},
});
},
});
};

View File

@@ -0,0 +1,231 @@
/**
* Authentication Hooks
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
login,
logout,
getCurrentUser,
masquerade,
stopMasquerade,
LoginCredentials,
User,
MasqueradeStackEntry
} from '../api/auth';
import { getCookie, setCookie, deleteCookie } from '../utils/cookies';
/**
* Hook to get current user
*/
export const useCurrentUser = () => {
return useQuery<User | null, Error>({
queryKey: ['currentUser'],
queryFn: async () => {
// Check if token exists before making request (from cookie)
const token = getCookie('access_token');
if (!token) {
return null; // No token, return null instead of making request
}
try {
return await getCurrentUser();
} catch (error) {
// If getCurrentUser fails (e.g., 401), return null
// The API client interceptor will handle token refresh
console.error('Failed to get current user:', error);
return null;
}
},
retry: 1, // Retry once in case of token refresh
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnMount: true, // Always refetch when component mounts
refetchOnWindowFocus: false,
});
};
/**
* Hook to login
*/
export const useLogin = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: login,
onSuccess: (data) => {
// Store tokens in cookies (domain=.lvh.me for cross-subdomain access)
setCookie('access_token', data.access, 7);
setCookie('refresh_token', data.refresh, 7);
// Set user in cache
queryClient.setQueryData(['currentUser'], data.user);
},
});
};
/**
* Hook to logout
*/
export const useLogout = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: logout,
onSuccess: () => {
// Clear tokens (from cookies)
deleteCookie('access_token');
deleteCookie('refresh_token');
// Clear user cache
queryClient.removeQueries({ queryKey: ['currentUser'] });
queryClient.clear();
// Redirect to login page
window.location.href = '/login';
},
});
};
/**
* Check if user is authenticated
*/
export const useIsAuthenticated = (): boolean => {
const { data: user, isLoading } = useCurrentUser();
return !isLoading && !!user;
};
/**
* Hook to masquerade as another user
*/
export const useMasquerade = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (username: string) => {
// Get current masquerading stack from localStorage
const stackJson = localStorage.getItem('masquerade_stack');
const currentStack: MasqueradeStackEntry[] = stackJson ? JSON.parse(stackJson) : [];
// Call masquerade API with current stack
return masquerade(username, currentStack);
},
onSuccess: async (data) => {
// Store the updated masquerading stack
if (data.masquerade_stack) {
localStorage.setItem('masquerade_stack', JSON.stringify(data.masquerade_stack));
}
const user = data.user;
const currentHostname = window.location.hostname;
const currentPort = window.location.port;
let targetSubdomain: string | null = null;
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
targetSubdomain = 'platform';
} else if (user.business_subdomain) {
targetSubdomain = user.business_subdomain;
}
const needsRedirect = targetSubdomain && currentHostname !== `${targetSubdomain}.lvh.me`;
if (needsRedirect) {
// CRITICAL: Clear the session cookie BEFORE redirect
// Call logout API to clear HttpOnly sessionid cookie
try {
await fetch('http://api.lvh.me:8000/api/auth/logout/', {
method: 'POST',
credentials: 'include',
});
} catch (e) {
// Continue anyway
}
const portStr = currentPort ? `:${currentPort}` : '';
// Pass tokens AND masquerading stack in URL (for cross-domain transfer)
const stackEncoded = encodeURIComponent(JSON.stringify(data.masquerade_stack || []));
const redirectUrl = `http://${targetSubdomain}.lvh.me${portStr}/?access_token=${data.access}&refresh_token=${data.refresh}&masquerade_stack=${stackEncoded}`;
window.location.href = redirectUrl;
return;
}
// If no redirect needed (same subdomain), we can just set cookies and reload
setCookie('access_token', data.access, 7);
setCookie('refresh_token', data.refresh, 7);
queryClient.setQueryData(['currentUser'], data.user);
window.location.reload();
},
});
};
/**
* Hook to stop masquerading and return to previous user
*/
export const useStopMasquerade = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
// Get current masquerading stack from localStorage
const stackJson = localStorage.getItem('masquerade_stack');
const currentStack: MasqueradeStackEntry[] = stackJson ? JSON.parse(stackJson) : [];
if (currentStack.length === 0) {
throw new Error('No masquerading session to stop');
}
// Call stop_masquerade API with current stack
return stopMasquerade(currentStack);
},
onSuccess: async (data) => {
// Update the masquerading stack
if (data.masquerade_stack && data.masquerade_stack.length > 0) {
localStorage.setItem('masquerade_stack', JSON.stringify(data.masquerade_stack));
} else {
// Clear the stack if empty
localStorage.removeItem('masquerade_stack');
}
const user = data.user;
const currentHostname = window.location.hostname;
const currentPort = window.location.port;
let targetSubdomain: string | null = null;
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
targetSubdomain = 'platform';
} else if (user.business_subdomain) {
targetSubdomain = user.business_subdomain;
}
const needsRedirect = targetSubdomain && currentHostname !== `${targetSubdomain}.lvh.me`;
if (needsRedirect) {
// CRITICAL: Clear the session cookie BEFORE redirect
try {
await fetch('http://api.lvh.me:8000/api/auth/logout/', {
method: 'POST',
credentials: 'include',
});
} catch (e) {
// Continue anyway
}
const portStr = currentPort ? `:${currentPort}` : '';
// Pass tokens AND masquerading stack in URL (for cross-domain transfer)
const stackEncoded = encodeURIComponent(JSON.stringify(data.masquerade_stack || []));
const redirectUrl = `http://${targetSubdomain}.lvh.me${portStr}/?access_token=${data.access}&refresh_token=${data.refresh}&masquerade_stack=${stackEncoded}`;
window.location.href = redirectUrl;
return;
}
// If no redirect needed (same subdomain), we can just set cookies and reload
setCookie('access_token', data.access, 7);
setCookie('refresh_token', data.refresh, 7);
queryClient.setQueryData(['currentUser'], data.user);
window.location.reload();
},
});
};

View File

@@ -0,0 +1,144 @@
/**
* Business Management Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { Business } from '../types';
import { getCookie } from '../utils/cookies';
/**
* Hook to get current business
*/
export const useCurrentBusiness = () => {
// Check token outside the query to use as dependency
const token = getCookie('access_token');
return useQuery<Business | null>({
queryKey: ['currentBusiness', !!token], // Include token presence in query key to refetch when token changes
queryFn: async () => {
// Check if token exists before making request (from cookie)
const currentToken = getCookie('access_token');
if (!currentToken) {
return null; // No token, return null instead of making request
}
const { data } = await apiClient.get('/api/business/current/');
// Transform backend format to frontend format
return {
id: String(data.id),
name: data.name,
subdomain: data.subdomain,
primaryColor: data.primary_color,
secondaryColor: data.secondary_color,
logoUrl: data.logo_url,
whitelabelEnabled: data.whitelabel_enabled,
plan: data.tier, // Map tier to plan
status: data.status,
joinedAt: data.created_at ? new Date(data.created_at) : undefined,
resourcesCanReschedule: data.resources_can_reschedule,
requirePaymentMethodToBook: data.require_payment_method_to_book,
cancellationWindowHours: data.cancellation_window_hours,
lateCancellationFeePercent: data.late_cancellation_fee_percent,
initialSetupComplete: data.initial_setup_complete,
websitePages: data.website_pages || {},
customerDashboardContent: data.customer_dashboard_content || [],
};
},
});
};
/**
* Hook to update business settings
*/
export const useUpdateBusiness = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (updates: Partial<Business>) => {
const backendData: any = {};
// Map frontend fields to backend fields
if (updates.name) backendData.name = updates.name;
if (updates.primaryColor) backendData.primary_color = updates.primaryColor;
if (updates.secondaryColor) backendData.secondary_color = updates.secondaryColor;
if (updates.logoUrl !== undefined) backendData.logo_url = updates.logoUrl;
if (updates.whitelabelEnabled !== undefined) {
backendData.whitelabel_enabled = updates.whitelabelEnabled;
}
if (updates.resourcesCanReschedule !== undefined) {
backendData.resources_can_reschedule = updates.resourcesCanReschedule;
}
if (updates.requirePaymentMethodToBook !== undefined) {
backendData.require_payment_method_to_book = updates.requirePaymentMethodToBook;
}
if (updates.cancellationWindowHours !== undefined) {
backendData.cancellation_window_hours = updates.cancellationWindowHours;
}
if (updates.lateCancellationFeePercent !== undefined) {
backendData.late_cancellation_fee_percent = updates.lateCancellationFeePercent;
}
if (updates.initialSetupComplete !== undefined) {
backendData.initial_setup_complete = updates.initialSetupComplete;
}
if (updates.websitePages !== undefined) {
backendData.website_pages = updates.websitePages;
}
if (updates.customerDashboardContent !== undefined) {
backendData.customer_dashboard_content = updates.customerDashboardContent;
}
const { data } = await apiClient.patch('/api/business/current/update/', backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currentBusiness'] });
},
});
};
/**
* Hook to get all resources for the current business
*/
export const useResources = () => {
return useQuery({
queryKey: ['resources'],
queryFn: async () => {
const { data } = await apiClient.get('/api/resources/');
return data;
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
/**
* Hook to create a new resource
*/
export const useCreateResource = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (resourceData: { name: string; type: string; user_id?: string }) => {
const { data } = await apiClient.post('/api/resources/', resourceData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
},
});
};
/**
* Hook to get all users for the current business
*/
export const useBusinessUsers = () => {
return useQuery({
queryKey: ['businessUsers'],
queryFn: async () => {
const { data } = await apiClient.get('/api/business/users/');
return data;
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
};

View File

@@ -0,0 +1,34 @@
/**
* Business OAuth Settings Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getBusinessOAuthSettings, updateBusinessOAuthSettings } from '../api/business';
import { BusinessOAuthSettings, BusinessOAuthSettingsResponse } from '../types';
/**
* Hook to get business OAuth settings and available providers
*/
export const useBusinessOAuthSettings = () => {
return useQuery<BusinessOAuthSettingsResponse>({
queryKey: ['businessOAuthSettings'],
queryFn: getBusinessOAuthSettings,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
/**
* Hook to update business OAuth settings
*/
export const useUpdateBusinessOAuthSettings = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (settings: Partial<BusinessOAuthSettings>) =>
updateBusinessOAuthSettings(settings),
onSuccess: (data) => {
// Update the cached data
queryClient.setQueryData(['businessOAuthSettings'], data);
},
});
};

View File

@@ -0,0 +1,32 @@
/**
* React Query hooks for managing business OAuth credentials
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getBusinessOAuthCredentials, updateBusinessOAuthCredentials } from '../api/business';
import { BusinessOAuthCredentials } from '../types';
/**
* Fetch business OAuth credentials
*/
export const useBusinessOAuthCredentials = () => {
return useQuery({
queryKey: ['businessOAuthCredentials'],
queryFn: getBusinessOAuthCredentials,
});
};
/**
* Update business OAuth credentials
*/
export const useUpdateBusinessOAuthCredentials = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (credentials: Partial<BusinessOAuthCredentials>) =>
updateBusinessOAuthCredentials(credentials),
onSuccess: (data) => {
queryClient.setQueryData(['businessOAuthCredentials'], data);
},
});
};

View File

@@ -0,0 +1,83 @@
/**
* React Query hooks for custom domain management
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getCustomDomains,
addCustomDomain,
deleteCustomDomain,
verifyCustomDomain,
setPrimaryDomain,
} from '../api/customDomains';
import { CustomDomain } from '../types';
/**
* Hook to fetch all custom domains for the current business
*/
export const useCustomDomains = () => {
return useQuery<CustomDomain[], Error>({
queryKey: ['customDomains'],
queryFn: getCustomDomains,
});
};
/**
* Hook to add a new custom domain
*/
export const useAddCustomDomain = () => {
const queryClient = useQueryClient();
return useMutation<CustomDomain, Error, string>({
mutationFn: addCustomDomain,
onSuccess: () => {
// Invalidate and refetch custom domains
queryClient.invalidateQueries({ queryKey: ['customDomains'] });
},
});
};
/**
* Hook to delete a custom domain
*/
export const useDeleteCustomDomain = () => {
const queryClient = useQueryClient();
return useMutation<void, Error, number>({
mutationFn: deleteCustomDomain,
onSuccess: () => {
// Invalidate and refetch custom domains
queryClient.invalidateQueries({ queryKey: ['customDomains'] });
},
});
};
/**
* Hook to verify a custom domain
*/
export const useVerifyCustomDomain = () => {
const queryClient = useQueryClient();
return useMutation<{ verified: boolean; message: string }, Error, number>({
mutationFn: verifyCustomDomain,
onSuccess: () => {
// Invalidate and refetch custom domains
queryClient.invalidateQueries({ queryKey: ['customDomains'] });
},
});
};
/**
* Hook to set a custom domain as primary
*/
export const useSetPrimaryDomain = () => {
const queryClient = useQueryClient();
return useMutation<CustomDomain, Error, number>({
mutationFn: setPrimaryDomain,
onSuccess: () => {
// Invalidate and refetch custom domains
queryClient.invalidateQueries({ queryKey: ['customDomains'] });
},
});
};

View File

@@ -0,0 +1,118 @@
/**
* Customer Management Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { Customer } from '../types';
interface CustomerFilters {
status?: 'Active' | 'Inactive' | 'Blocked';
search?: string;
}
/**
* Hook to fetch customers with optional filters
*/
export const useCustomers = (filters?: CustomerFilters) => {
return useQuery<Customer[]>({
queryKey: ['customers', filters],
queryFn: async () => {
const params = new URLSearchParams();
if (filters?.status) params.append('status', filters.status);
if (filters?.search) params.append('search', filters.search);
const { data } = await apiClient.get(`/api/customers/?${params}`);
// Transform backend format to frontend format
return data.map((c: any) => ({
id: String(c.id),
name: c.name || c.user?.name || '',
email: c.email || c.user?.email || '',
phone: c.phone || '',
city: c.city,
state: c.state,
zip: c.zip,
totalSpend: parseFloat(c.total_spend || 0),
lastVisit: c.last_visit ? new Date(c.last_visit) : null,
status: c.status,
avatarUrl: c.avatar_url,
tags: c.tags || [],
userId: String(c.user_id || c.user),
paymentMethods: [], // Will be populated when payment feature is implemented
user_data: c.user_data, // Include user_data for masquerading
}));
},
});
};
/**
* Hook to create a customer
*/
export const useCreateCustomer = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (customerData: Partial<Customer>) => {
const backendData = {
user: customerData.userId ? parseInt(customerData.userId) : undefined,
phone: customerData.phone,
city: customerData.city,
state: customerData.state,
zip: customerData.zip,
status: customerData.status,
avatar_url: customerData.avatarUrl,
tags: customerData.tags,
};
const { data } = await apiClient.post('/api/customers/', backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
},
});
};
/**
* Hook to update a customer
*/
export const useUpdateCustomer = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Customer> }) => {
const backendData = {
phone: updates.phone,
city: updates.city,
state: updates.state,
zip: updates.zip,
status: updates.status,
avatar_url: updates.avatarUrl,
tags: updates.tags,
};
const { data } = await apiClient.patch(`/api/customers/${id}/`, backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
},
});
};
/**
* Hook to delete a customer
*/
export const useDeleteCustomer = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/api/customers/${id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
},
});
};

View File

@@ -0,0 +1,190 @@
/**
* Domain Management Hooks
* React Query hooks for NameSilo domain integration
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as domainsApi from '../api/domains';
import type {
DomainAvailability,
DomainPrice,
DomainRegisterRequest,
DomainRegistration,
DomainSearchHistory,
} from '../api/domains';
// Query keys
const domainKeys = {
all: ['domains'] as const,
prices: () => [...domainKeys.all, 'prices'] as const,
registrations: () => [...domainKeys.all, 'registrations'] as const,
registration: (id: number) => [...domainKeys.registrations(), id] as const,
history: () => [...domainKeys.all, 'history'] as const,
search: (query: string, tlds: string[]) => [...domainKeys.all, 'search', query, tlds] as const,
};
// ============================================
// Search & Pricing
// ============================================
/**
* Hook to search for domain availability
*/
export const useDomainSearch = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ query, tlds }: { query: string; tlds?: string[] }) =>
domainsApi.searchDomains(query, tlds),
onSuccess: () => {
// Invalidate search history since new search was added
queryClient.invalidateQueries({ queryKey: domainKeys.history() });
},
});
};
/**
* Hook to get TLD pricing
*/
export const useDomainPrices = () => {
return useQuery({
queryKey: domainKeys.prices(),
queryFn: domainsApi.getDomainPrices,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
// ============================================
// Registration
// ============================================
/**
* Hook to register a new domain
*/
export const useRegisterDomain = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: DomainRegisterRequest) => domainsApi.registerDomain(data),
onSuccess: () => {
// Invalidate registrations list
queryClient.invalidateQueries({ queryKey: domainKeys.registrations() });
// Also invalidate custom domains since we auto-configure
queryClient.invalidateQueries({ queryKey: ['customDomains'] });
},
});
};
/**
* Hook to get all registered domains
*/
export const useRegisteredDomains = () => {
return useQuery({
queryKey: domainKeys.registrations(),
queryFn: domainsApi.getRegisteredDomains,
staleTime: 30 * 1000, // 30 seconds
});
};
/**
* Hook to get a single domain registration
*/
export const useDomainRegistration = (id: number) => {
return useQuery({
queryKey: domainKeys.registration(id),
queryFn: () => domainsApi.getDomainRegistration(id),
enabled: !!id,
});
};
// ============================================
// Domain Management
// ============================================
/**
* Hook to update nameservers
*/
export const useUpdateNameservers = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, nameservers }: { id: number; nameservers: string[] }) =>
domainsApi.updateNameservers(id, nameservers),
onSuccess: (data) => {
queryClient.setQueryData(domainKeys.registration(data.id), data);
queryClient.invalidateQueries({ queryKey: domainKeys.registrations() });
},
});
};
/**
* Hook to toggle auto-renewal
*/
export const useToggleAutoRenew = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, autoRenew }: { id: number; autoRenew: boolean }) =>
domainsApi.toggleAutoRenew(id, autoRenew),
onSuccess: (data) => {
queryClient.setQueryData(domainKeys.registration(data.id), data);
queryClient.invalidateQueries({ queryKey: domainKeys.registrations() });
},
});
};
/**
* Hook to renew a domain
*/
export const useRenewDomain = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, years }: { id: number; years?: number }) =>
domainsApi.renewDomain(id, years),
onSuccess: (data) => {
queryClient.setQueryData(domainKeys.registration(data.id), data);
queryClient.invalidateQueries({ queryKey: domainKeys.registrations() });
},
});
};
/**
* Hook to sync domain info from NameSilo
*/
export const useSyncDomain = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => domainsApi.syncDomain(id),
onSuccess: (data) => {
queryClient.setQueryData(domainKeys.registration(data.id), data);
queryClient.invalidateQueries({ queryKey: domainKeys.registrations() });
},
});
};
// ============================================
// History
// ============================================
/**
* Hook to get search history
*/
export const useSearchHistory = () => {
return useQuery({
queryKey: domainKeys.history(),
queryFn: domainsApi.getSearchHistory,
staleTime: 60 * 1000, // 1 minute
});
};
// Re-export types for convenience
export type {
DomainAvailability,
DomainPrice,
DomainRegisterRequest,
DomainRegistration,
DomainSearchHistory,
RegistrantContact,
} from '../api/domains';

View File

@@ -0,0 +1,104 @@
/**
* OAuth Hooks
* React Query hooks for OAuth authentication flows
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
getOAuthProviders,
getOAuthConnections,
initiateOAuth,
handleOAuthCallback,
disconnectOAuth,
OAuthProvider,
OAuthConnection,
OAuthTokenResponse,
} from '../api/oauth';
import { setCookie } from '../utils/cookies';
/**
* Hook to get list of enabled OAuth providers
*/
export const useOAuthProviders = () => {
return useQuery<OAuthProvider[], Error>({
queryKey: ['oauthProviders'],
queryFn: getOAuthProviders,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
});
};
/**
* Hook to get user's connected OAuth accounts
*/
export const useOAuthConnections = () => {
return useQuery<OAuthConnection[], Error>({
queryKey: ['oauthConnections'],
queryFn: getOAuthConnections,
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
});
};
/**
* Hook to initiate OAuth flow
*/
export const useInitiateOAuth = () => {
return useMutation({
mutationFn: async (provider: string) => {
const response = await initiateOAuth(provider);
return { provider, authorizationUrl: response.authorization_url };
},
onSuccess: ({ authorizationUrl }) => {
// Open OAuth authorization URL in current window
window.location.href = authorizationUrl;
},
});
};
/**
* Hook to handle OAuth callback and exchange code for tokens
*/
export const useOAuthCallback = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
provider,
code,
state,
}: {
provider: string;
code: string;
state: string;
}) => {
return handleOAuthCallback(provider, code, state);
},
onSuccess: (data: OAuthTokenResponse) => {
// Store tokens in cookies
setCookie('access_token', data.access, 7);
setCookie('refresh_token', data.refresh, 7);
// Set user in cache
queryClient.setQueryData(['currentUser'], data.user);
// Invalidate OAuth connections to refetch
queryClient.invalidateQueries({ queryKey: ['oauthConnections'] });
},
});
};
/**
* Hook to disconnect OAuth account
*/
export const useDisconnectOAuth = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: disconnectOAuth,
onSuccess: () => {
// Invalidate connections list to refetch
queryClient.invalidateQueries({ queryKey: ['oauthConnections'] });
},
});
};

View File

@@ -0,0 +1,154 @@
/**
* Payment Hooks
* React Query hooks for payment configuration management
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as paymentsApi from '../api/payments';
// ============================================================================
// Query Keys
// ============================================================================
export const paymentKeys = {
all: ['payments'] as const,
config: () => [...paymentKeys.all, 'config'] as const,
apiKeys: () => [...paymentKeys.all, 'apiKeys'] as const,
connectStatus: () => [...paymentKeys.all, 'connectStatus'] as const,
};
// ============================================================================
// Unified Configuration Hook
// ============================================================================
/**
* Get unified payment configuration status.
* Returns the complete payment setup for the business.
*/
export const usePaymentConfig = () => {
return useQuery({
queryKey: paymentKeys.config(),
queryFn: () => paymentsApi.getPaymentConfig().then(res => res.data),
staleTime: 30 * 1000, // 30 seconds
});
};
// ============================================================================
// API Keys Hooks (Free Tier)
// ============================================================================
/**
* Get current API key configuration (masked).
*/
export const useApiKeys = () => {
return useQuery({
queryKey: paymentKeys.apiKeys(),
queryFn: () => paymentsApi.getApiKeys().then(res => res.data),
staleTime: 30 * 1000,
});
};
/**
* Validate API keys without saving.
*/
export const useValidateApiKeys = () => {
return useMutation({
mutationFn: ({ secretKey, publishableKey }: { secretKey: string; publishableKey: string }) =>
paymentsApi.validateApiKeys(secretKey, publishableKey).then(res => res.data),
});
};
/**
* Save API keys.
*/
export const useSaveApiKeys = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ secretKey, publishableKey }: { secretKey: string; publishableKey: string }) =>
paymentsApi.saveApiKeys(secretKey, publishableKey).then(res => res.data),
onSuccess: () => {
// Invalidate payment config to refresh status
queryClient.invalidateQueries({ queryKey: paymentKeys.config() });
queryClient.invalidateQueries({ queryKey: paymentKeys.apiKeys() });
},
});
};
/**
* Re-validate stored API keys.
*/
export const useRevalidateApiKeys = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => paymentsApi.revalidateApiKeys().then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: paymentKeys.config() });
queryClient.invalidateQueries({ queryKey: paymentKeys.apiKeys() });
},
});
};
/**
* Delete stored API keys.
*/
export const useDeleteApiKeys = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => paymentsApi.deleteApiKeys().then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: paymentKeys.config() });
queryClient.invalidateQueries({ queryKey: paymentKeys.apiKeys() });
},
});
};
// ============================================================================
// Stripe Connect Hooks (Paid Tiers)
// ============================================================================
/**
* Get current Connect account status.
*/
export const useConnectStatus = () => {
return useQuery({
queryKey: paymentKeys.connectStatus(),
queryFn: () => paymentsApi.getConnectStatus().then(res => res.data),
staleTime: 30 * 1000,
// Only fetch if we might have a Connect account
enabled: true,
});
};
/**
* Initiate Connect account onboarding.
*/
export const useConnectOnboarding = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ refreshUrl, returnUrl }: { refreshUrl: string; returnUrl: string }) =>
paymentsApi.initiateConnectOnboarding(refreshUrl, returnUrl).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: paymentKeys.config() });
queryClient.invalidateQueries({ queryKey: paymentKeys.connectStatus() });
},
});
};
/**
* Refresh Connect onboarding link.
*/
export const useRefreshConnectLink = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ refreshUrl, returnUrl }: { refreshUrl: string; returnUrl: string }) =>
paymentsApi.refreshConnectOnboardingLink(refreshUrl, returnUrl).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: paymentKeys.connectStatus() });
},
});
};

View File

@@ -0,0 +1,41 @@
/**
* Platform Hooks
* React Query hooks for platform-level operations
*/
import { useQuery } from '@tanstack/react-query';
import { getBusinesses, getUsers, getBusinessUsers } from '../api/platform';
/**
* Hook to get all businesses (platform admin only)
*/
export const useBusinesses = () => {
return useQuery({
queryKey: ['platform', 'businesses'],
queryFn: getBusinesses,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
/**
* Hook to get all users (platform admin only)
*/
export const usePlatformUsers = () => {
return useQuery({
queryKey: ['platform', 'users'],
queryFn: getUsers,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
/**
* Hook to get users for a specific business
*/
export const useBusinessUsers = (businessId: number | null) => {
return useQuery({
queryKey: ['platform', 'business-users', businessId],
queryFn: () => getBusinessUsers(businessId!),
enabled: !!businessId,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};

View File

@@ -0,0 +1,36 @@
/**
* Platform OAuth Settings Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getPlatformOAuthSettings,
updatePlatformOAuthSettings,
PlatformOAuthSettings,
PlatformOAuthSettingsUpdate,
} from '../api/platformOAuth';
/**
* Hook to get platform OAuth settings
*/
export const usePlatformOAuthSettings = () => {
return useQuery<PlatformOAuthSettings>({
queryKey: ['platformOAuthSettings'],
queryFn: getPlatformOAuthSettings,
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
/**
* Hook to update platform OAuth settings
*/
export const useUpdatePlatformOAuthSettings = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updatePlatformOAuthSettings,
onSuccess: (data) => {
queryClient.setQueryData(['platformOAuthSettings'], data);
},
});
};

View File

@@ -0,0 +1,193 @@
/**
* Platform Settings Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
export interface PlatformSettings {
stripe_secret_key_masked: string;
stripe_publishable_key_masked: string;
stripe_webhook_secret_masked: string;
stripe_account_id: string;
stripe_account_name: string;
stripe_keys_validated_at: string | null;
stripe_validation_error: string;
has_stripe_keys: boolean;
stripe_keys_from_env: boolean;
updated_at: string;
}
export interface StripeKeysUpdate {
stripe_secret_key?: string;
stripe_publishable_key?: string;
stripe_webhook_secret?: string;
}
export interface SubscriptionPlan {
id: number;
name: string;
description: string;
plan_type: 'base' | 'addon';
stripe_product_id: string;
stripe_price_id: string;
price_monthly: string | null;
price_yearly: string | null;
business_tier: string;
features: string[];
transaction_fee_percent: string;
transaction_fee_fixed: string;
is_active: boolean;
is_public: boolean;
created_at: string;
updated_at: string;
}
export interface SubscriptionPlanCreate {
name: string;
description?: string;
plan_type?: 'base' | 'addon';
price_monthly?: number | null;
price_yearly?: number | null;
business_tier?: string;
features?: string[];
transaction_fee_percent?: number;
transaction_fee_fixed?: number;
is_active?: boolean;
is_public?: boolean;
create_stripe_product?: boolean;
stripe_product_id?: string;
stripe_price_id?: string;
}
/**
* Hook to get platform settings
*/
export const usePlatformSettings = () => {
return useQuery<PlatformSettings>({
queryKey: ['platformSettings'],
queryFn: async () => {
const { data } = await apiClient.get('/api/platform/settings/');
return data;
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
/**
* Hook to update platform Stripe keys
*/
export const useUpdateStripeKeys = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (keys: StripeKeysUpdate) => {
const { data } = await apiClient.post('/api/platform/settings/stripe/keys/', keys);
return data;
},
onSuccess: (data) => {
queryClient.setQueryData(['platformSettings'], data);
},
});
};
/**
* Hook to validate platform Stripe keys
*/
export const useValidateStripeKeys = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const { data } = await apiClient.post('/api/platform/settings/stripe/validate/');
return data;
},
onSuccess: (data) => {
if (data.settings) {
queryClient.setQueryData(['platformSettings'], data.settings);
}
},
});
};
/**
* Hook to get subscription plans
*/
export const useSubscriptionPlans = () => {
return useQuery<SubscriptionPlan[]>({
queryKey: ['subscriptionPlans'],
queryFn: async () => {
const { data } = await apiClient.get('/api/platform/subscription-plans/');
return data;
},
staleTime: 5 * 60 * 1000,
});
};
/**
* Hook to create a subscription plan
*/
export const useCreateSubscriptionPlan = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (plan: SubscriptionPlanCreate) => {
const { data } = await apiClient.post('/api/platform/subscription-plans/', plan);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscriptionPlans'] });
},
});
};
/**
* Hook to update a subscription plan
*/
export const useUpdateSubscriptionPlan = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...updates }: Partial<SubscriptionPlan> & { id: number }) => {
const { data } = await apiClient.patch(`/api/platform/subscription-plans/${id}/`, updates);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscriptionPlans'] });
},
});
};
/**
* Hook to delete a subscription plan
*/
export const useDeleteSubscriptionPlan = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const { data } = await apiClient.delete(`/api/platform/subscription-plans/${id}/`);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscriptionPlans'] });
},
});
};
/**
* Hook to sync plans with Stripe
*/
export const useSyncPlansWithStripe = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const { data } = await apiClient.post('/api/platform/subscription-plans/sync_with_stripe/');
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscriptionPlans'] });
},
});
};

View File

@@ -0,0 +1,248 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as profileApi from '../api/profile';
// Profile hooks
export const useProfile = () => {
return useQuery({
queryKey: ['profile'],
queryFn: profileApi.getProfile,
});
};
export const useUpdateProfile = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.updateProfile,
onSuccess: (data) => {
queryClient.setQueryData(['profile'], data);
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
export const useUploadAvatar = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.uploadAvatar,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
export const useDeleteAvatar = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.deleteAvatar,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
// Email hooks
export const useSendVerificationEmail = () => {
return useMutation({
mutationFn: profileApi.sendVerificationEmail,
});
};
export const useVerifyEmail = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.verifyEmail,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
export const useRequestEmailChange = () => {
return useMutation({
mutationFn: profileApi.requestEmailChange,
});
};
export const useConfirmEmailChange = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.confirmEmailChange,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
// Password hooks
export const useChangePassword = () => {
return useMutation({
mutationFn: ({
currentPassword,
newPassword,
}: {
currentPassword: string;
newPassword: string;
}) => profileApi.changePassword(currentPassword, newPassword),
});
};
// 2FA hooks
export const useSetupTOTP = () => {
return useMutation({
mutationFn: profileApi.setupTOTP,
});
};
export const useVerifyTOTP = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.verifyTOTP,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
export const useDisableTOTP = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.disableTOTP,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
export const useRecoveryCodes = () => {
return useQuery({
queryKey: ['recoveryCodes'],
queryFn: profileApi.getRecoveryCodes,
enabled: false, // Only fetch on demand
});
};
export const useRegenerateRecoveryCodes = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.regenerateRecoveryCodes,
onSuccess: (codes) => {
queryClient.setQueryData(['recoveryCodes'], codes);
},
});
};
// Phone verification hooks
export const useSendPhoneVerification = () => {
return useMutation({
mutationFn: profileApi.sendPhoneVerification,
});
};
export const useVerifyPhoneCode = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.verifyPhoneCode,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};
// Session hooks
export const useSessions = () => {
return useQuery({
queryKey: ['sessions'],
queryFn: profileApi.getSessions,
});
};
export const useRevokeSession = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.revokeSession,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
};
export const useRevokeOtherSessions = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.revokeOtherSessions,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
};
export const useLoginHistory = () => {
return useQuery({
queryKey: ['loginHistory'],
queryFn: profileApi.getLoginHistory,
});
};
// Multiple email hooks
export const useUserEmails = () => {
return useQuery({
queryKey: ['userEmails'],
queryFn: profileApi.getUserEmails,
});
};
export const useAddUserEmail = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.addUserEmail,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['userEmails'] });
},
});
};
export const useDeleteUserEmail = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.deleteUserEmail,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['userEmails'] });
},
});
};
export const useSendUserEmailVerification = () => {
return useMutation({
mutationFn: profileApi.sendUserEmailVerification,
});
};
export const useVerifyUserEmail = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ emailId, token }: { emailId: number; token: string }) =>
profileApi.verifyUserEmail(emailId, token),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['userEmails'] });
},
});
};
export const useSetPrimaryEmail = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: profileApi.setPrimaryEmail,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['userEmails'] });
queryClient.invalidateQueries({ queryKey: ['profile'] });
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
},
});
};

View File

@@ -0,0 +1,118 @@
/**
* Resource Management Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { Resource, ResourceType } from '../types';
interface ResourceFilters {
type?: ResourceType;
}
/**
* Hook to fetch resources with optional type filter
*/
export const useResources = (filters?: ResourceFilters) => {
return useQuery<Resource[]>({
queryKey: ['resources', filters],
queryFn: async () => {
const params = new URLSearchParams();
if (filters?.type) params.append('type', filters.type);
const { data } = await apiClient.get(`/api/resources/?${params}`);
// Transform backend format to frontend format
return data.map((r: any) => ({
id: String(r.id),
name: r.name,
type: r.type as ResourceType,
userId: r.user_id ? String(r.user_id) : undefined,
}));
},
});
};
/**
* Hook to get a single resource
*/
export const useResource = (id: string) => {
return useQuery<Resource>({
queryKey: ['resources', id],
queryFn: async () => {
const { data } = await apiClient.get(`/api/resources/${id}/`);
return {
id: String(data.id),
name: data.name,
type: data.type as ResourceType,
userId: data.user_id ? String(data.user_id) : undefined,
};
},
enabled: !!id,
});
};
/**
* Hook to create a resource
*/
export const useCreateResource = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (resourceData: Omit<Resource, 'id'>) => {
const backendData = {
name: resourceData.name,
type: resourceData.type,
user: resourceData.userId ? parseInt(resourceData.userId) : null,
timezone: 'UTC', // Default timezone
};
const { data } = await apiClient.post('/api/resources/', backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
},
});
};
/**
* Hook to update a resource
*/
export const useUpdateResource = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Resource> }) => {
const backendData: any = {};
if (updates.name) backendData.name = updates.name;
if (updates.type) backendData.type = updates.type;
if (updates.userId !== undefined) {
backendData.user = updates.userId ? parseInt(updates.userId) : null;
}
const { data } = await apiClient.patch(`/api/resources/${id}/`, backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
},
});
};
/**
* Hook to delete a resource
*/
export const useDeleteResource = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/api/resources/${id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
},
});
};

View File

@@ -0,0 +1,15 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
/**
* Hook to scroll to top on route changes
* Should be used in layout components to ensure scroll restoration
* works consistently across all routes
*/
export function useScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
}

View File

@@ -0,0 +1,112 @@
/**
* Service Management Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { Service } from '../types';
/**
* Hook to fetch all services for current business
*/
export const useServices = () => {
return useQuery<Service[]>({
queryKey: ['services'],
queryFn: async () => {
const { data } = await apiClient.get('/api/services/');
// Transform backend format to frontend format
return data.map((s: any) => ({
id: String(s.id),
name: s.name,
durationMinutes: s.duration || s.duration_minutes,
price: parseFloat(s.price),
description: s.description || '',
}));
},
});
};
/**
* Hook to get a single service
*/
export const useService = (id: string) => {
return useQuery<Service>({
queryKey: ['services', id],
queryFn: async () => {
const { data } = await apiClient.get(`/api/services/${id}/`);
return {
id: String(data.id),
name: data.name,
durationMinutes: data.duration || data.duration_minutes,
price: parseFloat(data.price),
description: data.description || '',
};
},
enabled: !!id,
});
};
/**
* Hook to create a service
*/
export const useCreateService = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (serviceData: Omit<Service, 'id'>) => {
const backendData = {
name: serviceData.name,
duration: serviceData.durationMinutes,
price: serviceData.price.toString(),
description: serviceData.description,
};
const { data } = await apiClient.post('/api/services/', backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['services'] });
},
});
};
/**
* Hook to update a service
*/
export const useUpdateService = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Service> }) => {
const backendData: any = {};
if (updates.name) backendData.name = updates.name;
if (updates.durationMinutes) backendData.duration = updates.durationMinutes;
if (updates.price) backendData.price = updates.price.toString();
if (updates.description !== undefined) backendData.description = updates.description;
const { data } = await apiClient.patch(`/api/services/${id}/`, backendData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['services'] });
},
});
};
/**
* Hook to delete a service
*/
export const useDeleteService = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/api/services/${id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['services'] });
},
});
};

View File

@@ -0,0 +1,197 @@
/**
* Transaction Analytics Hooks
*
* React Query hooks for fetching and managing transaction analytics data.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getTransactions,
getTransaction,
getTransactionSummary,
getStripeCharges,
getStripePayouts,
getStripeBalance,
exportTransactions,
getTransactionDetail,
refundTransaction,
TransactionFilters,
ExportRequest,
RefundRequest,
} from '../api/payments';
/**
* Hook to fetch paginated transaction list with optional filters.
*/
export const useTransactions = (filters?: TransactionFilters) => {
return useQuery({
queryKey: ['transactions', filters],
queryFn: async () => {
const { data } = await getTransactions(filters);
return data;
},
staleTime: 30 * 1000, // 30 seconds
});
};
/**
* Hook to fetch a single transaction by ID.
*/
export const useTransaction = (id: number) => {
return useQuery({
queryKey: ['transaction', id],
queryFn: async () => {
const { data } = await getTransaction(id);
return data;
},
enabled: !!id,
});
};
/**
* Hook to fetch transaction summary/analytics.
*/
export const useTransactionSummary = (filters?: Pick<TransactionFilters, 'start_date' | 'end_date'>) => {
return useQuery({
queryKey: ['transactionSummary', filters],
queryFn: async () => {
const { data } = await getTransactionSummary(filters);
return data;
},
staleTime: 60 * 1000, // 1 minute
});
};
/**
* Hook to fetch Stripe charges directly from Stripe API.
*/
export const useStripeCharges = (limit: number = 20) => {
return useQuery({
queryKey: ['stripeCharges', limit],
queryFn: async () => {
const { data } = await getStripeCharges(limit);
return data;
},
staleTime: 30 * 1000,
});
};
/**
* Hook to fetch Stripe payouts.
*/
export const useStripePayouts = (limit: number = 20) => {
return useQuery({
queryKey: ['stripePayouts', limit],
queryFn: async () => {
const { data } = await getStripePayouts(limit);
return data;
},
staleTime: 30 * 1000,
});
};
/**
* Hook to fetch current Stripe balance.
*/
export const useStripeBalance = () => {
return useQuery({
queryKey: ['stripeBalance'],
queryFn: async () => {
const { data } = await getStripeBalance();
return data;
},
staleTime: 60 * 1000, // 1 minute
refetchInterval: 5 * 60 * 1000, // Refresh every 5 minutes
});
};
/**
* Hook to export transaction data.
* Returns a mutation that triggers file download.
*/
export const useExportTransactions = () => {
return useMutation({
mutationFn: async (request: ExportRequest) => {
const response = await exportTransactions(request);
return response;
},
onSuccess: (response, request) => {
// Create blob URL and trigger download
const blob = new Blob([response.data], { type: response.headers['content-type'] });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
// Determine file extension based on format
const extensions: Record<string, string> = {
csv: 'csv',
xlsx: 'xlsx',
pdf: 'pdf',
quickbooks: 'iif',
};
const ext = extensions[request.format] || 'txt';
link.download = `transactions.${ext}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
},
});
};
/**
* Hook to invalidate all transaction-related queries.
* Useful after actions that modify transaction data.
*/
export const useInvalidateTransactions = () => {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['transactionSummary'] });
queryClient.invalidateQueries({ queryKey: ['stripeCharges'] });
queryClient.invalidateQueries({ queryKey: ['stripePayouts'] });
queryClient.invalidateQueries({ queryKey: ['stripeBalance'] });
queryClient.invalidateQueries({ queryKey: ['transactionDetail'] });
};
};
/**
* Hook to fetch detailed transaction information including refund data.
*/
export const useTransactionDetail = (id: number | null) => {
return useQuery({
queryKey: ['transactionDetail', id],
queryFn: async () => {
if (!id) return null;
const { data } = await getTransactionDetail(id);
return data;
},
enabled: !!id,
staleTime: 10 * 1000, // 10 seconds (refresh often for live data)
});
};
/**
* Hook to issue a refund for a transaction.
* Automatically invalidates transaction queries on success.
*/
export const useRefundTransaction = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ transactionId, request }: { transactionId: number; request?: RefundRequest }) => {
const { data } = await refundTransaction(transactionId, request);
return data;
},
onSuccess: (data, variables) => {
// Invalidate all relevant queries
queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['transactionSummary'] });
queryClient.invalidateQueries({ queryKey: ['transactionDetail', variables.transactionId] });
queryClient.invalidateQueries({ queryKey: ['stripeCharges'] });
queryClient.invalidateQueries({ queryKey: ['stripeBalance'] });
},
});
};

View File

@@ -0,0 +1,208 @@
/**
* WebSocket hook for real-time user notifications.
* Connects to the backend WebSocket and updates React Query cache.
*/
import { useEffect, useRef, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { getCookie } from '../utils/cookies';
import { UserEmail } from '../api/profile';
interface WebSocketMessage {
type: 'connection_established' | 'email_verified' | 'profile_updated' | 'pong';
email_id?: number;
email?: string;
user_id?: string;
message?: string;
fields?: string[];
}
interface UseUserNotificationsOptions {
enabled?: boolean;
onConnected?: () => void;
onDisconnected?: () => void;
onError?: (error: Event) => void;
onEmailVerified?: (emailId: number, email: string) => void;
}
/**
* Hook for real-time user notifications via WebSocket.
*/
export function useUserNotifications(options: UseUserNotificationsOptions = {}) {
const { enabled = true, onConnected, onDisconnected, onError, onEmailVerified } = options;
const queryClient = useQueryClient();
// Use refs for callbacks to avoid recreating connect function
const onConnectedRef = useRef(onConnected);
const onDisconnectedRef = useRef(onDisconnected);
const onErrorRef = useRef(onError);
const onEmailVerifiedRef = useRef(onEmailVerified);
// Update refs when callbacks change
useEffect(() => {
onConnectedRef.current = onConnected;
onDisconnectedRef.current = onDisconnected;
onErrorRef.current = onError;
onEmailVerifiedRef.current = onEmailVerified;
}, [onConnected, onDisconnected, onError, onEmailVerified]);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttemptsRef = useRef(0);
const isConnectingRef = useRef(false);
const maxReconnectAttempts = 5;
const updateEmailQueryCache = useCallback((emailId: number) => {
// Update the userEmails query cache to mark the email as verified
queryClient.setQueryData<UserEmail[]>(['userEmails'], (old) => {
if (!old) return old;
return old.map((email) =>
email.id === emailId ? { ...email, verified: true } : email
);
});
}, [queryClient]);
const disconnect = useCallback(() => {
// Clear reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
// Clear ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Close WebSocket
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
isConnectingRef.current = false;
}, []);
const connect = useCallback(() => {
// Prevent multiple simultaneous connection attempts
if (isConnectingRef.current || wsRef.current?.readyState === WebSocket.OPEN) {
return;
}
const token = getCookie('access_token');
if (!token) {
console.log('UserNotifications WebSocket: Missing token, skipping connection');
return;
}
isConnectingRef.current = true;
// Close existing connection if any
if (wsRef.current) {
wsRef.current.close();
}
// Determine WebSocket host - use api subdomain for WebSocket
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = `api.lvh.me:8000`; // In production, this would come from config
const url = `${wsProtocol}//${wsHost}/ws/user/?token=${token}`;
console.log('UserNotifications WebSocket: Connecting');
const ws = new WebSocket(url);
ws.onopen = () => {
console.log('UserNotifications WebSocket: Connected');
reconnectAttemptsRef.current = 0;
isConnectingRef.current = false;
onConnectedRef.current?.();
// Start ping interval to keep connection alive
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000); // Ping every 30 seconds
};
ws.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
switch (message.type) {
case 'connection_established':
console.log('UserNotifications WebSocket: Connection confirmed -', message.message);
break;
case 'pong':
// Heartbeat response, ignore
break;
case 'email_verified':
console.log('UserNotifications WebSocket: Email verified', message.email);
if (message.email_id) {
updateEmailQueryCache(message.email_id);
onEmailVerifiedRef.current?.(message.email_id, message.email || '');
}
break;
case 'profile_updated':
console.log('UserNotifications WebSocket: Profile updated', message.fields);
// Invalidate profile queries to refresh data
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
break;
default:
console.log('UserNotifications WebSocket: Unknown message type', message);
}
} catch (err) {
console.error('UserNotifications WebSocket: Failed to parse message', err);
}
};
ws.onerror = (error) => {
console.error('UserNotifications WebSocket: Error', error);
isConnectingRef.current = false;
onErrorRef.current?.(error);
};
ws.onclose = (event) => {
console.log('UserNotifications WebSocket: Disconnected', event.code, event.reason);
isConnectingRef.current = false;
onDisconnectedRef.current?.();
// Clear ping interval
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
// Only attempt reconnection if this wasn't a deliberate close
// Code 1000 = normal closure, 1001 = going away (page unload)
if (event.code !== 1000 && event.code !== 1001 && reconnectAttemptsRef.current < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
console.log(`UserNotifications WebSocket: Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`);
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current++;
connect();
}, delay);
}
};
wsRef.current = ws;
}, [queryClient, updateEmailQueryCache]);
useEffect(() => {
if (enabled) {
connect();
}
return () => {
disconnect();
};
}, [enabled, connect, disconnect]);
return {
isConnected: wsRef.current?.readyState === WebSocket.OPEN,
reconnect: connect,
disconnect,
};
}