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:
332
frontend/src/hooks/useAppointmentWebSocket.ts
Normal file
332
frontend/src/hooks/useAppointmentWebSocket.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
279
frontend/src/hooks/useAppointments.ts
Normal file
279
frontend/src/hooks/useAppointments.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
231
frontend/src/hooks/useAuth.ts
Normal file
231
frontend/src/hooks/useAuth.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
};
|
||||
144
frontend/src/hooks/useBusiness.ts
Normal file
144
frontend/src/hooks/useBusiness.ts
Normal 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
|
||||
});
|
||||
};
|
||||
34
frontend/src/hooks/useBusinessOAuth.ts
Normal file
34
frontend/src/hooks/useBusinessOAuth.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
};
|
||||
32
frontend/src/hooks/useBusinessOAuthCredentials.ts
Normal file
32
frontend/src/hooks/useBusinessOAuthCredentials.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
};
|
||||
83
frontend/src/hooks/useCustomDomains.ts
Normal file
83
frontend/src/hooks/useCustomDomains.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
118
frontend/src/hooks/useCustomers.ts
Normal file
118
frontend/src/hooks/useCustomers.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
190
frontend/src/hooks/useDomains.ts
Normal file
190
frontend/src/hooks/useDomains.ts
Normal 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';
|
||||
104
frontend/src/hooks/useOAuth.ts
Normal file
104
frontend/src/hooks/useOAuth.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
154
frontend/src/hooks/usePayments.ts
Normal file
154
frontend/src/hooks/usePayments.ts
Normal 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() });
|
||||
},
|
||||
});
|
||||
};
|
||||
41
frontend/src/hooks/usePlatform.ts
Normal file
41
frontend/src/hooks/usePlatform.ts
Normal 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
|
||||
});
|
||||
};
|
||||
36
frontend/src/hooks/usePlatformOAuth.ts
Normal file
36
frontend/src/hooks/usePlatformOAuth.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
};
|
||||
193
frontend/src/hooks/usePlatformSettings.ts
Normal file
193
frontend/src/hooks/usePlatformSettings.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
248
frontend/src/hooks/useProfile.ts
Normal file
248
frontend/src/hooks/useProfile.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
118
frontend/src/hooks/useResources.ts
Normal file
118
frontend/src/hooks/useResources.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
15
frontend/src/hooks/useScrollToTop.ts
Normal file
15
frontend/src/hooks/useScrollToTop.ts
Normal 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]);
|
||||
}
|
||||
112
frontend/src/hooks/useServices.ts
Normal file
112
frontend/src/hooks/useServices.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
197
frontend/src/hooks/useTransactionAnalytics.ts
Normal file
197
frontend/src/hooks/useTransactionAnalytics.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
208
frontend/src/hooks/useUserNotifications.ts
Normal file
208
frontend/src/hooks/useUserNotifications.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user