feat: hackathon
This commit is contained in:
@@ -65,3 +65,16 @@ export const postGoogleCallback = async (code: string, state: string): Promise<T
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getGitHubAuthUrl = async (): Promise<string> => {
|
||||
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||
return `${baseUrl}/auth/github/login`;
|
||||
};
|
||||
|
||||
export const postGitHubCallback = async (code: string, state: string): Promise<TGoogleCallbackResponse> => {
|
||||
const { data } = await api({
|
||||
method: 'GET',
|
||||
url: `/auth/github/callback?code=${code}&state=${state}`,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { api } from '../index';
|
||||
import type {
|
||||
TCreateTeamRequest,
|
||||
TUpdateTeamRequest,
|
||||
TInviteMemberRequest,
|
||||
TJoinTeamRequest,
|
||||
TManageMemberRequest,
|
||||
TSubmitProjectRequest,
|
||||
TTeamListResponse,
|
||||
TTeamDetailResponse,
|
||||
TTeamMembersResponse,
|
||||
TTeamInvitationsResponse,
|
||||
TTeamJoinRequestsResponse,
|
||||
TProjectSubmissionResponse,
|
||||
} from '../../types/teams';
|
||||
|
||||
const TEAMS_BASE_URL = '/teams';
|
||||
|
||||
// Team CRUD
|
||||
export const getTeams = async (params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
city?: string;
|
||||
visibility?: string;
|
||||
search?: string;
|
||||
}) => {
|
||||
const response = await api.get<TTeamListResponse>(TEAMS_BASE_URL, { params });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getTeamById = async (teamId: string) => {
|
||||
const response = await api.get<TTeamDetailResponse>(`${TEAMS_BASE_URL}/${teamId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createTeam = async (data: TCreateTeamRequest) => {
|
||||
const response = await api.post<TTeamDetailResponse>(TEAMS_BASE_URL, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateTeam = async (teamId: string, data: TUpdateTeamRequest) => {
|
||||
const response = await api.put<TTeamDetailResponse>(`${TEAMS_BASE_URL}/${teamId}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Team Members
|
||||
export const getTeamMembers = async (teamId: string) => {
|
||||
const response = await api.get<TTeamMembersResponse>(`${TEAMS_BASE_URL}/${teamId}/members`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const inviteMember = async (teamId: string, data: TInviteMemberRequest) => {
|
||||
const response = await api.post(`${TEAMS_BASE_URL}/${teamId}/invite`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const manageMember = async (teamId: string, userId: string, data: TManageMemberRequest) => {
|
||||
const response = await api.put(`${TEAMS_BASE_URL}/${teamId}/members/${userId}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const removeMember = async (teamId: string, userId: string) => {
|
||||
const response = await api.delete(`${TEAMS_BASE_URL}/${teamId}/members/${userId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Join Requests
|
||||
export const joinTeam = async (teamId: string, data: TJoinTeamRequest) => {
|
||||
const response = await api.post(`${TEAMS_BASE_URL}/${teamId}/join-request`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getTeamJoinRequests = async (teamId: string) => {
|
||||
const response = await api.get<TTeamJoinRequestsResponse>(`${TEAMS_BASE_URL}/${teamId}/join-requests`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const respondToJoinRequest = async (teamId: string, requestId: string, action: 'approve' | 'reject') => {
|
||||
const response = await api.put(`${TEAMS_BASE_URL}/${teamId}/join-requests/${requestId}`, { action });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Invitations
|
||||
export const getMyInvitations = async () => {
|
||||
const response = await api.get<TTeamInvitationsResponse>(`${TEAMS_BASE_URL}/invitations/me`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const respondToInvitation = async (invitationId: string, action: 'accept' | 'reject') => {
|
||||
const response = await api.put(`${TEAMS_BASE_URL}/invitations/${invitationId}`, { action });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// User's Teams
|
||||
export const getMyTeams = async () => {
|
||||
const response = await api.get<TTeamListResponse>(`${TEAMS_BASE_URL}/me`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Project Submission
|
||||
export const submitProject = async (teamId: string, data: TSubmitProjectRequest) => {
|
||||
const response = await api.post<TProjectSubmissionResponse>(`${TEAMS_BASE_URL}/${teamId}/submission`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getTeamSubmission = async (teamId: string) => {
|
||||
const response = await api.get<TProjectSubmissionResponse>(`${TEAMS_BASE_URL}/${teamId}/submission`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const leaveTeam = async (teamId: string) => {
|
||||
const response = await api.post(`${TEAMS_BASE_URL}/${teamId}/leave`);
|
||||
return response.data;
|
||||
};
|
||||
@@ -1,83 +1,72 @@
|
||||
import { useMutation, UseMutationResult } from '@tanstack/react-query';
|
||||
import { postLogin, postRegister, postSendOtp, postVerifyEmail, getGoogleAuthUrl, postGoogleCallback } from '../../api/auth';
|
||||
import {
|
||||
TLoginRequest,
|
||||
TLoginResponse,
|
||||
TRegisterRequest,
|
||||
TSendOTPRequest,
|
||||
TVerifyEmailRequest,
|
||||
TGoogleCallbackResponse,
|
||||
} from '../../types/auth';
|
||||
import { supabase } from '../../supabase';
|
||||
|
||||
import { TResponseError, TResponseMessage } from '../../types/common';
|
||||
// Supabase GitHub OAuth hook
|
||||
export const useGitHubAuth = () => {
|
||||
const signInWithGitHub = async () => {
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'github',
|
||||
options: {
|
||||
redirectTo: `${globalThis.location.origin}/auth/callback`,
|
||||
},
|
||||
});
|
||||
|
||||
export const usePostLogin = (): UseMutationResult<
|
||||
TLoginResponse,
|
||||
TResponseError,
|
||||
TLoginRequest,
|
||||
unknown
|
||||
> => {
|
||||
return useMutation({
|
||||
mutationKey: ['post-login'],
|
||||
mutationFn: async (payload) => await postLogin(payload),
|
||||
});
|
||||
};
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
export const usePostRegister = (): UseMutationResult<
|
||||
TResponseMessage,
|
||||
TResponseError,
|
||||
TRegisterRequest,
|
||||
unknown
|
||||
> => {
|
||||
return useMutation({
|
||||
mutationKey: ['post-register'],
|
||||
mutationFn: async (payload) => await postRegister(payload),
|
||||
});
|
||||
};
|
||||
|
||||
export const usePostVerifyEmail = (): UseMutationResult<
|
||||
TResponseMessage,
|
||||
TResponseError,
|
||||
TVerifyEmailRequest,
|
||||
unknown
|
||||
> => {
|
||||
return useMutation({
|
||||
mutationKey: ['post-verify-email'],
|
||||
mutationFn: async (payload) => await postVerifyEmail(payload),
|
||||
});
|
||||
};
|
||||
|
||||
export const usePostSendOTP = (): UseMutationResult<
|
||||
TResponseMessage,
|
||||
TResponseError,
|
||||
TSendOTPRequest,
|
||||
unknown
|
||||
> => {
|
||||
return useMutation({
|
||||
mutationKey: ['post-send-otp'],
|
||||
mutationFn: async (payload) => await postSendOtp(payload),
|
||||
});
|
||||
};
|
||||
|
||||
export const useGoogleAuth = () => {
|
||||
const redirectToGoogle = async (): Promise<string> => {
|
||||
return await getGoogleAuthUrl();
|
||||
// Return the OAuth URL for debugging
|
||||
return data;
|
||||
};
|
||||
|
||||
return {
|
||||
redirectToGoogle,
|
||||
signInWithGitHub,
|
||||
};
|
||||
};
|
||||
|
||||
export const useGoogleCallback = (): UseMutationResult<
|
||||
TGoogleCallbackResponse,
|
||||
TResponseError,
|
||||
{ code: string; state: string },
|
||||
unknown
|
||||
> => {
|
||||
return useMutation({
|
||||
mutationKey: ['google-callback'],
|
||||
mutationFn: async ({ code, state }) => await postGoogleCallback(code, state),
|
||||
});
|
||||
};
|
||||
// Supabase Email/Password authentication hook
|
||||
export const useEmailAuth = () => {
|
||||
const signInWithEmail = async (email: string, password: string) => {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const signUpWithEmail = async (email: string, password: string, fullname: string) => {
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: {
|
||||
full_name: fullname,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
signInWithEmail,
|
||||
signUpWithEmail,
|
||||
signOut,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,3 +3,5 @@ export * from './gacha';
|
||||
export * from './users';
|
||||
export * from './mentors';
|
||||
export * from './upload';
|
||||
export * from './teams';
|
||||
export * from './messages';
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { supabase } from '../../supabase';
|
||||
import { useAuthStore } from '@imphnen-frontend-service/utils';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
team_id: string;
|
||||
user_id: string;
|
||||
message: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user?: {
|
||||
id: string;
|
||||
fullname: string;
|
||||
avatar: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Query keys
|
||||
export const messageKeys = {
|
||||
all: ['messages'] as const,
|
||||
team: (teamId: string) => [...messageKeys.all, 'team', teamId] as const,
|
||||
};
|
||||
|
||||
// Fetch messages for a team
|
||||
export const useTeamMessages = (teamId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: messageKeys.team(teamId),
|
||||
queryFn: async () => {
|
||||
const { data: messages, error } = await supabase
|
||||
.from('team_messages')
|
||||
.select(`
|
||||
id,
|
||||
team_id,
|
||||
user_id,
|
||||
message,
|
||||
created_at,
|
||||
updated_at,
|
||||
user:users(id, fullname, avatar, email)
|
||||
`)
|
||||
.eq('team_id', teamId)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to fetch messages:', error);
|
||||
throw new Error(error.message || 'Failed to fetch messages');
|
||||
}
|
||||
|
||||
return messages as Message[];
|
||||
},
|
||||
enabled: !!teamId,
|
||||
});
|
||||
|
||||
// Subscribe to realtime updates
|
||||
useEffect(() => {
|
||||
if (!teamId) return;
|
||||
|
||||
const channel = supabase
|
||||
.channel(`team_messages:${teamId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'team_messages',
|
||||
filter: `team_id=eq.${teamId}`,
|
||||
},
|
||||
async (payload) => {
|
||||
console.log('[Realtime] New message:', payload);
|
||||
|
||||
// Fetch the full message with user data
|
||||
const { data: newMessage } = await supabase
|
||||
.from('team_messages')
|
||||
.select(`
|
||||
id,
|
||||
team_id,
|
||||
user_id,
|
||||
message,
|
||||
created_at,
|
||||
updated_at,
|
||||
user:users(id, fullname, avatar, email)
|
||||
`)
|
||||
.eq('id', payload.new.id)
|
||||
.single();
|
||||
|
||||
if (newMessage) {
|
||||
queryClient.setQueryData<Message[]>(
|
||||
messageKeys.team(teamId),
|
||||
(old) => [...(old || []), newMessage as Message]
|
||||
);
|
||||
}
|
||||
}
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'DELETE',
|
||||
schema: 'public',
|
||||
table: 'team_messages',
|
||||
filter: `team_id=eq.${teamId}`,
|
||||
},
|
||||
(payload) => {
|
||||
console.log('[Realtime] Message deleted:', payload);
|
||||
queryClient.setQueryData<Message[]>(
|
||||
messageKeys.team(teamId),
|
||||
(old) => old?.filter((msg) => msg.id !== payload.old.id) || []
|
||||
);
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [teamId, queryClient]);
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
// Send a message
|
||||
export const useSendMessage = (teamId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (message: string) => {
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('You must be logged in to send messages');
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('team_messages')
|
||||
.insert({
|
||||
team_id: teamId,
|
||||
user_id: session.user.id,
|
||||
message,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
throw new Error(error.message || 'Failed to send message');
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Realtime will handle adding the message to the list
|
||||
// But we can invalidate to ensure consistency
|
||||
queryClient.invalidateQueries({ queryKey: messageKeys.team(teamId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Delete a message
|
||||
export const useDeleteMessage = (teamId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (messageId: string) => {
|
||||
const { error } = await supabase
|
||||
.from('team_messages')
|
||||
.delete()
|
||||
.eq('id', messageId);
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to delete message:', error);
|
||||
throw new Error(error.message || 'Failed to delete message');
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Realtime will handle removing the message from the list
|
||||
queryClient.invalidateQueries({ queryKey: messageKeys.team(teamId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,692 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import * as teamsApi from '../../api/teams';
|
||||
import { supabase, getAuthenticatedClient } from '../../supabase';
|
||||
import { useAuthStore } from '@imphnen-frontend-service/utils';
|
||||
import type {
|
||||
TCreateTeamRequest,
|
||||
TUpdateTeamRequest,
|
||||
TInviteMemberRequest,
|
||||
TJoinTeamRequest,
|
||||
TSubmitProjectRequest,
|
||||
} from '../../types/teams';
|
||||
|
||||
// Query keys
|
||||
export const teamKeys = {
|
||||
all: ['teams'] as const,
|
||||
lists: () => [...teamKeys.all, 'list'] as const,
|
||||
list: (filters?: Record<string, unknown>) => [...teamKeys.lists(), filters] as const,
|
||||
details: () => [...teamKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...teamKeys.details(), id] as const,
|
||||
members: (id: string) => [...teamKeys.detail(id), 'members'] as const,
|
||||
joinRequests: (id: string) => [...teamKeys.detail(id), 'join-requests'] as const,
|
||||
submission: (id: string) => [...teamKeys.detail(id), 'submission'] as const,
|
||||
myTeams: () => [...teamKeys.all, 'my-teams'] as const,
|
||||
myInvitations: () => [...teamKeys.all, 'my-invitations'] as const,
|
||||
};
|
||||
|
||||
// Team CRUD Hooks
|
||||
export const useTeams = (params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
city?: string;
|
||||
visibility?: string;
|
||||
search?: string;
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryKey: teamKeys.list(params),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
// Supabase client now has auth context from setSession()
|
||||
let query = supabase.from('teams').select('*');
|
||||
|
||||
// Filter by visibility
|
||||
if (params?.visibility) {
|
||||
query = query.eq('visibility', params.visibility);
|
||||
}
|
||||
|
||||
// Filter by city
|
||||
if (params?.city) {
|
||||
query = query.eq('city', params.city);
|
||||
}
|
||||
|
||||
// Search by name
|
||||
if (params?.search) {
|
||||
query = query.ilike('name', `%${params.search}%`);
|
||||
}
|
||||
|
||||
// Pagination
|
||||
if (params?.page && params?.limit) {
|
||||
const from = (params.page - 1) * params.limit;
|
||||
const to = from + params.limit - 1;
|
||||
query = query.range(from, to);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) {
|
||||
// If error is 401/403, it means RLS policies need to be set up
|
||||
// Return empty array for now
|
||||
console.warn('Teams query error (RLS policies may need to be configured):', error);
|
||||
return { data: [] };
|
||||
}
|
||||
|
||||
return { data: data || [] };
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch teams:', err);
|
||||
return { data: [] };
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useTeamById = (teamId: string, enabled = true) => {
|
||||
return useQuery({
|
||||
queryKey: teamKeys.detail(teamId),
|
||||
queryFn: async () => {
|
||||
// Supabase client now has auth context from setSession()
|
||||
const { data: team, error } = await supabase
|
||||
.from('teams')
|
||||
.select(`
|
||||
*,
|
||||
leader:users!leader_id(id, email, fullname, avatar),
|
||||
members:team_members(
|
||||
id,
|
||||
role,
|
||||
status,
|
||||
joined_at,
|
||||
user:users(id, email, fullname, avatar)
|
||||
)
|
||||
`)
|
||||
.eq('id', teamId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to fetch team:', error);
|
||||
throw new Error(error.message || 'Failed to fetch team');
|
||||
}
|
||||
|
||||
// Count active members
|
||||
const activeMemberCount = team?.members?.filter((m: any) => m.status === 'active').length || 0;
|
||||
|
||||
// Check if team has a submission
|
||||
const { data: submission } = await supabase
|
||||
.from('project_submissions')
|
||||
.select('id')
|
||||
.eq('team_id', teamId)
|
||||
.maybeSingle();
|
||||
|
||||
return {
|
||||
data: {
|
||||
...team,
|
||||
member_count: activeMemberCount,
|
||||
has_submission: !!submission,
|
||||
},
|
||||
};
|
||||
},
|
||||
enabled: enabled && !!teamId,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateTeam = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: TCreateTeamRequest) => {
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('You must be logged in to create a team');
|
||||
}
|
||||
|
||||
// Supabase client now has auth context from setSession()
|
||||
const { data: team, error: teamError } = await supabase
|
||||
.from('teams')
|
||||
.insert({
|
||||
name: data.name,
|
||||
logo: data.logo,
|
||||
banner: data.banner,
|
||||
description: data.description,
|
||||
city: data.city,
|
||||
visibility: data.visibility,
|
||||
leader_id: session.user.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (teamError) {
|
||||
console.error('Failed to create team:', teamError);
|
||||
throw new Error(teamError.message || 'Failed to create team');
|
||||
}
|
||||
|
||||
// Insert team creator as leader in team_members table
|
||||
const { error: memberError } = await supabase
|
||||
.from('team_members')
|
||||
.insert({
|
||||
team_id: team.id,
|
||||
user_id: session.user.id,
|
||||
role: 'leader',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
if (memberError) {
|
||||
// If duplicate key error (23505), it means leader is already a member (possibly by trigger)
|
||||
// This is acceptable, so we can ignore it
|
||||
if (memberError.code === '23505') {
|
||||
console.log('Team leader already exists in team_members (likely added by trigger)');
|
||||
} else {
|
||||
// For other errors, clean up and throw
|
||||
console.error('Failed to add team leader as member:', memberError);
|
||||
await supabase.from('teams').delete().eq('id', team.id);
|
||||
throw new Error('Failed to set up team membership');
|
||||
}
|
||||
}
|
||||
|
||||
return { data: team };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.myTeams() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateTeam = (teamId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: TUpdateTeamRequest) => {
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('You must be logged in to update a team');
|
||||
}
|
||||
|
||||
// Supabase client now has auth context from setSession()
|
||||
const { data: team, error } = await supabase
|
||||
.from('teams')
|
||||
.update({
|
||||
name: data.name,
|
||||
logo: data.logo,
|
||||
banner: data.banner,
|
||||
description: data.description,
|
||||
city: data.city,
|
||||
visibility: data.visibility,
|
||||
})
|
||||
.eq('id', teamId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to update team:', error);
|
||||
throw new Error(error.message || 'Failed to update team');
|
||||
}
|
||||
|
||||
return { data: team };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Team Members Hooks
|
||||
export const useTeamMembers = (teamId: string, enabled = true) => {
|
||||
return useQuery({
|
||||
queryKey: teamKeys.members(teamId),
|
||||
queryFn: async () => {
|
||||
// Supabase client now has auth context from setSession()
|
||||
const { data: members, error } = await supabase
|
||||
.from('team_members')
|
||||
.select(`
|
||||
id,
|
||||
team_id,
|
||||
user_id,
|
||||
role,
|
||||
status,
|
||||
joined_at,
|
||||
user:users(id, email, fullname, avatar)
|
||||
`)
|
||||
.eq('team_id', teamId)
|
||||
.order('joined_at', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to fetch team members:', error);
|
||||
throw new Error(error.message || 'Failed to fetch team members');
|
||||
}
|
||||
|
||||
return { data: members || [] };
|
||||
},
|
||||
enabled: enabled && !!teamId,
|
||||
});
|
||||
};
|
||||
|
||||
export const useInviteMember = (teamId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: TInviteMemberRequest) => {
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('You must be logged in to invite a member');
|
||||
}
|
||||
|
||||
// Insert invitation into team_invitations table
|
||||
const { data: invitation, error } = await supabase
|
||||
.from('team_invitations')
|
||||
.insert({
|
||||
team_id: teamId,
|
||||
inviter_id: session.user.id,
|
||||
invitee_email: data.email,
|
||||
status: 'pending',
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to create invitation:', error);
|
||||
throw new Error(error.message || 'Failed to send invitation');
|
||||
}
|
||||
|
||||
return { data: invitation };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.members(teamId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useManageMember = (teamId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, data }: { userId: string; data: any }) =>
|
||||
teamsApi.manageMember(teamId, userId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.members(teamId) });
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRemoveMember = (teamId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (userId: string) => teamsApi.removeMember(teamId, userId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.members(teamId) });
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Join Requests Hooks
|
||||
export const useJoinTeam = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ teamId, data }: { teamId: string; data: TJoinTeamRequest }) => {
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('You must be logged in to join a team');
|
||||
}
|
||||
|
||||
// Insert join request into team_join_requests table
|
||||
const { data: joinRequest, error } = await supabase
|
||||
.from('team_join_requests')
|
||||
.insert({
|
||||
team_id: teamId,
|
||||
user_id: session.user.id,
|
||||
message: data.message,
|
||||
status: 'pending',
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to create join request:', error);
|
||||
throw new Error(error.message || 'Failed to send join request');
|
||||
}
|
||||
|
||||
return { data: joinRequest };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useTeamJoinRequests = (teamId: string, enabled = true) => {
|
||||
return useQuery({
|
||||
queryKey: teamKeys.joinRequests(teamId),
|
||||
queryFn: async () => {
|
||||
// Fetch join requests with user information
|
||||
const { data: requests, error } = await supabase
|
||||
.from('team_join_requests')
|
||||
.select(`
|
||||
id,
|
||||
team_id,
|
||||
user_id,
|
||||
message,
|
||||
status,
|
||||
created_at,
|
||||
user:users(id, email, fullname, avatar)
|
||||
`)
|
||||
.eq('team_id', teamId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to fetch join requests:', error);
|
||||
throw new Error(error.message || 'Failed to fetch join requests');
|
||||
}
|
||||
|
||||
return { data: requests || [] };
|
||||
},
|
||||
enabled: enabled && !!teamId,
|
||||
});
|
||||
};
|
||||
|
||||
export const useRespondToJoinRequest = (teamId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ requestId, action }: { requestId: string; action: 'approve' | 'reject' }) => {
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('You must be logged in to respond to join requests');
|
||||
}
|
||||
|
||||
// First, get the join request details
|
||||
const { data: joinRequest, error: fetchError } = await supabase
|
||||
.from('team_join_requests')
|
||||
.select('id, team_id, user_id, status')
|
||||
.eq('id', requestId)
|
||||
.single();
|
||||
|
||||
if (fetchError || !joinRequest) {
|
||||
throw new Error('Join request not found');
|
||||
}
|
||||
|
||||
if (joinRequest.status !== 'pending') {
|
||||
throw new Error('Join request has already been processed');
|
||||
}
|
||||
|
||||
if (action === 'approve') {
|
||||
// Update join request status
|
||||
const { error: updateError } = await supabase
|
||||
.from('team_join_requests')
|
||||
.update({ status: 'accepted' })
|
||||
.eq('id', requestId);
|
||||
|
||||
if (updateError) {
|
||||
throw new Error('Failed to update join request: ' + updateError.message);
|
||||
}
|
||||
|
||||
// Add user as team member
|
||||
const { error: memberError } = await supabase
|
||||
.from('team_members')
|
||||
.insert({
|
||||
team_id: joinRequest.team_id,
|
||||
user_id: joinRequest.user_id,
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
if (memberError) {
|
||||
// If member creation fails, rollback join request update
|
||||
await supabase
|
||||
.from('team_join_requests')
|
||||
.update({ status: 'pending' })
|
||||
.eq('id', requestId);
|
||||
|
||||
throw new Error('Failed to add member to team: ' + memberError.message);
|
||||
}
|
||||
|
||||
return { success: true, action: 'accepted' };
|
||||
} else {
|
||||
// Reject join request
|
||||
const { error: updateError } = await supabase
|
||||
.from('team_join_requests')
|
||||
.update({ status: 'rejected' })
|
||||
.eq('id', requestId);
|
||||
|
||||
if (updateError) {
|
||||
throw new Error('Failed to update join request: ' + updateError.message);
|
||||
}
|
||||
|
||||
return { success: true, action: 'rejected' };
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.joinRequests(teamId) });
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.members(teamId) });
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Invitations Hooks
|
||||
export const useMyInvitations = () => {
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useQuery({
|
||||
queryKey: teamKeys.myInvitations(),
|
||||
queryFn: async () => {
|
||||
if (!session?.user?.email) {
|
||||
return { data: [] };
|
||||
}
|
||||
|
||||
// Query team_invitations where invitee_email matches current user's email
|
||||
const { data: invitations, error } = await supabase
|
||||
.from('team_invitations')
|
||||
.select(`
|
||||
id,
|
||||
team_id,
|
||||
inviter_id,
|
||||
invitee_email,
|
||||
invitee_id,
|
||||
status,
|
||||
created_at,
|
||||
team:teams(
|
||||
id,
|
||||
name,
|
||||
logo,
|
||||
banner,
|
||||
description,
|
||||
city,
|
||||
visibility,
|
||||
leader_id
|
||||
),
|
||||
inviter:users!team_invitations_inviter_id_fkey(
|
||||
id,
|
||||
fullname,
|
||||
email,
|
||||
avatar
|
||||
)
|
||||
`)
|
||||
.eq('invitee_email', session.user.email)
|
||||
.eq('status', 'pending');
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to fetch invitations:', error);
|
||||
return { data: [] };
|
||||
}
|
||||
|
||||
return { data: invitations || [] };
|
||||
},
|
||||
enabled: !!session?.user?.email,
|
||||
});
|
||||
};
|
||||
|
||||
export const useRespondToInvitation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ invitationId, action }: { invitationId: string; action: 'accept' | 'reject' }) => {
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
// First, get the invitation details
|
||||
const { data: invitation, error: fetchError } = await supabase
|
||||
.from('team_invitations')
|
||||
.select('id, team_id, invitee_email, status')
|
||||
.eq('id', invitationId)
|
||||
.single();
|
||||
|
||||
if (fetchError || !invitation) {
|
||||
throw new Error('Invitation not found');
|
||||
}
|
||||
|
||||
if (invitation.status !== 'pending') {
|
||||
throw new Error('Invitation has already been responded to');
|
||||
}
|
||||
|
||||
if (action === 'accept') {
|
||||
// Update invitation status and set invitee_id
|
||||
const { error: updateError } = await supabase
|
||||
.from('team_invitations')
|
||||
.update({
|
||||
status: 'accepted',
|
||||
invitee_id: session.user.id,
|
||||
})
|
||||
.eq('id', invitationId);
|
||||
|
||||
if (updateError) {
|
||||
throw new Error('Failed to update invitation: ' + updateError.message);
|
||||
}
|
||||
|
||||
// Create team_members record
|
||||
const { error: memberError } = await supabase
|
||||
.from('team_members')
|
||||
.insert({
|
||||
team_id: invitation.team_id,
|
||||
user_id: session.user.id,
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
if (memberError) {
|
||||
// If member creation fails, rollback invitation update
|
||||
await supabase
|
||||
.from('team_invitations')
|
||||
.update({
|
||||
status: 'pending',
|
||||
invitee_id: null,
|
||||
})
|
||||
.eq('id', invitationId);
|
||||
|
||||
throw new Error('Failed to add member to team: ' + memberError.message);
|
||||
}
|
||||
|
||||
return { success: true, action: 'accepted' };
|
||||
} else {
|
||||
// Reject invitation
|
||||
const { error: updateError } = await supabase
|
||||
.from('team_invitations')
|
||||
.update({
|
||||
status: 'rejected',
|
||||
invitee_id: session.user.id,
|
||||
})
|
||||
.eq('id', invitationId);
|
||||
|
||||
if (updateError) {
|
||||
throw new Error('Failed to update invitation: ' + updateError.message);
|
||||
}
|
||||
|
||||
return { success: true, action: 'rejected' };
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.myInvitations() });
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.myTeams() });
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// User's Teams
|
||||
export const useMyTeams = () => {
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useQuery({
|
||||
queryKey: teamKeys.myTeams(),
|
||||
queryFn: async () => {
|
||||
if (!session?.user?.id) {
|
||||
return { data: [] };
|
||||
}
|
||||
|
||||
// Query team_members to find teams where user is a member
|
||||
const { data: memberships, error: membershipsError } = await supabase
|
||||
.from('team_members')
|
||||
.select(`
|
||||
team_id,
|
||||
team:teams(
|
||||
id,
|
||||
name,
|
||||
logo,
|
||||
banner,
|
||||
description,
|
||||
city,
|
||||
visibility,
|
||||
leader_id,
|
||||
created_at
|
||||
)
|
||||
`)
|
||||
.eq('user_id', session.user.id)
|
||||
.eq('status', 'active');
|
||||
|
||||
if (membershipsError) {
|
||||
console.error('Failed to fetch user teams:', membershipsError);
|
||||
return { data: [] };
|
||||
}
|
||||
|
||||
// Extract teams from memberships
|
||||
const teams = memberships?.map((m: any) => m.team).filter(Boolean) || [];
|
||||
|
||||
return { data: teams };
|
||||
},
|
||||
enabled: !!session?.user?.id,
|
||||
});
|
||||
};
|
||||
|
||||
// Project Submission Hooks
|
||||
export const useSubmitProject = (teamId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: TSubmitProjectRequest) => {
|
||||
return await teamsApi.submitProject(teamId, data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.submission(teamId) });
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useTeamSubmission = (teamId: string, enabled = true) => {
|
||||
return useQuery({
|
||||
queryKey: teamKeys.submission(teamId),
|
||||
queryFn: async () => {
|
||||
const submission = await teamsApi.getTeamSubmission(teamId);
|
||||
return { data: submission };
|
||||
},
|
||||
enabled: enabled && !!teamId,
|
||||
});
|
||||
};
|
||||
|
||||
// Leave Team Hook
|
||||
export const useLeaveTeam = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (teamId: string) => {
|
||||
return await teamsApi.leaveTeam(teamId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.myTeams() });
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,39 +1,119 @@
|
||||
import { useMutation, UseMutationResult } from '@tanstack/react-query';
|
||||
import { uploadService, UploadResponse } from '../../api/upload';
|
||||
import { TResponseError } from '../../types/common';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { supabase, getAuthenticatedClient } from '../../supabase';
|
||||
import { useAuthStore } from '@imphnen-frontend-service/utils';
|
||||
|
||||
// Supabase Storage-based upload hooks
|
||||
|
||||
export const useUploadFile = () => {
|
||||
const { session } = useAuthStore();
|
||||
|
||||
export const useUploadFile = (): UseMutationResult<
|
||||
UploadResponse,
|
||||
TResponseError,
|
||||
File,
|
||||
unknown
|
||||
> => {
|
||||
return useMutation({
|
||||
mutationKey: ['upload-file'],
|
||||
mutationFn: (file) => uploadService.uploadFile(file),
|
||||
mutationFn: async (file: File) => {
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('You must be logged in to upload files');
|
||||
}
|
||||
|
||||
// Generate a unique file name
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const fileName = `${session.user.id}-${Date.now()}.${fileExt}`;
|
||||
const filePath = `teams/${fileName}`;
|
||||
|
||||
// Supabase client now has auth context from setSession()
|
||||
const { error } = await supabase.storage
|
||||
.from('hackathon-uploads')
|
||||
.upload(filePath, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to upload file:', error);
|
||||
throw new Error(error.message || 'Failed to upload file');
|
||||
}
|
||||
|
||||
// Get public URL
|
||||
const { data: publicUrlData } = supabase.storage
|
||||
.from('hackathon-uploads')
|
||||
.getPublicUrl(filePath);
|
||||
|
||||
return { data: { url: publicUrlData.publicUrl } };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUploadAvatar = (): UseMutationResult<
|
||||
UploadResponse,
|
||||
TResponseError,
|
||||
File,
|
||||
unknown
|
||||
> => {
|
||||
export const useUploadAvatar = () => {
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['upload-avatar'],
|
||||
mutationFn: (file) => uploadService.uploadAvatar(file),
|
||||
mutationFn: async (file: File) => {
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('You must be logged in to upload avatar');
|
||||
}
|
||||
|
||||
// Generate a unique file name
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const fileName = `${session.user.id}-${Date.now()}.${fileExt}`;
|
||||
const filePath = `avatars/${fileName}`;
|
||||
|
||||
// Supabase client now has auth context from setSession()
|
||||
const { error } = await supabase.storage
|
||||
.from('hackathon-uploads')
|
||||
.upload(filePath, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to upload avatar:', error);
|
||||
throw new Error(error.message || 'Failed to upload avatar');
|
||||
}
|
||||
|
||||
// Get public URL
|
||||
const { data: publicUrlData } = supabase.storage
|
||||
.from('hackathon-uploads')
|
||||
.getPublicUrl(filePath);
|
||||
|
||||
return { data: { url: publicUrlData.publicUrl } };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUploadCV = (): UseMutationResult<
|
||||
UploadResponse,
|
||||
TResponseError,
|
||||
File,
|
||||
unknown
|
||||
> => {
|
||||
export const useUploadCV = () => {
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['upload-cv'],
|
||||
mutationFn: (file) => uploadService.uploadCV(file),
|
||||
mutationFn: async (file: File) => {
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('You must be logged in to upload CV');
|
||||
}
|
||||
|
||||
// Generate a unique file name
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const fileName = `${session.user.id}-${Date.now()}.${fileExt}`;
|
||||
const filePath = `cvs/${fileName}`;
|
||||
|
||||
// Supabase client now has auth context from setSession()
|
||||
const { error } = await supabase.storage
|
||||
.from('hackathon-uploads')
|
||||
.upload(filePath, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to upload CV:', error);
|
||||
throw new Error(error.message || 'Failed to upload CV');
|
||||
}
|
||||
|
||||
// Get public URL
|
||||
const { data: publicUrlData } = supabase.storage
|
||||
.from('hackathon-uploads')
|
||||
.getPublicUrl(filePath);
|
||||
|
||||
return { data: { url: publicUrlData.publicUrl } };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,44 +1,94 @@
|
||||
import { useQuery, useMutation, UseQueryResult, UseMutationResult, UseQueryOptions } from '@tanstack/react-query';
|
||||
import { userService, UserDetailResponseDto, UserUpdateRequestDto } from '../../api/users';
|
||||
import { TResponseError } from '../../types/common';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { userService } from '../../api/users';
|
||||
import { supabase, getAuthenticatedClient } from '../../supabase';
|
||||
import { useAuthStore } from '@imphnen-frontend-service/utils';
|
||||
|
||||
export const useUserMe = (options?: UseQueryOptions<UserDetailResponseDto, TResponseError>): UseQueryResult<UserDetailResponseDto, TResponseError> => {
|
||||
// Supabase-based user hooks
|
||||
|
||||
export const useUserMe = () => {
|
||||
return useQuery({
|
||||
queryKey: ['user-me'],
|
||||
queryFn: () => userService.getUserMe(),
|
||||
...options,
|
||||
queryFn: async () => {
|
||||
const user = await userService.getUserMe();
|
||||
return { data: user };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUserById = (id: string, options?: UseQueryOptions<UserDetailResponseDto, TResponseError>): UseQueryResult<UserDetailResponseDto, TResponseError> => {
|
||||
export const useUserById = (id: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['user-by-id', id],
|
||||
queryFn: () => userService.getUserById(id),
|
||||
queryFn: async () => {
|
||||
const user = await userService.getUserById(id);
|
||||
return { data: user };
|
||||
},
|
||||
enabled: !!id,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateUserMe = (): UseMutationResult<
|
||||
UserDetailResponseDto,
|
||||
TResponseError,
|
||||
UserUpdateRequestDto,
|
||||
unknown
|
||||
> => {
|
||||
export const useUpdateUserMe = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { session, setSession } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['update-user-me'],
|
||||
mutationFn: (data) => userService.updateUserMe(data),
|
||||
mutationFn: async (data: any) => {
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('You must be logged in to update profile');
|
||||
}
|
||||
|
||||
// Supabase client now has auth context from setSession()
|
||||
const { data: updatedUser, error } = await supabase
|
||||
.from('users')
|
||||
.update({
|
||||
fullname: data.fullname,
|
||||
bio: data.bio,
|
||||
location: data.location,
|
||||
avatar: data.avatar,
|
||||
skills: data.skills,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', session.user.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to update user profile:', error);
|
||||
throw new Error(error.message || 'Failed to update profile');
|
||||
}
|
||||
return { data: updatedUser };
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
// Update Zustand session store with new user data
|
||||
if (session && result.data) {
|
||||
setSession({
|
||||
token: session.token,
|
||||
user: {
|
||||
...session.user,
|
||||
fullname: result.data.fullname || session.user.fullname,
|
||||
bio: result.data.bio || '',
|
||||
location: result.data.location || '',
|
||||
avatar: result.data.avatar || session.user.avatar,
|
||||
skills: result.data.skills || [],
|
||||
},
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['user-me'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateUserById = (): UseMutationResult<
|
||||
UserDetailResponseDto,
|
||||
TResponseError,
|
||||
{ id: string; data: UserUpdateRequestDto },
|
||||
unknown
|
||||
> => {
|
||||
export const useUpdateUserById = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['update-user-by-id'],
|
||||
mutationFn: ({ id, data }) => userService.updateUserById(id, data),
|
||||
mutationFn: async ({ id, data }: { id: string; data: any }) => {
|
||||
const updated = await userService.updateUserById(id, data);
|
||||
return { data: updated };
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user-by-id', variables.id] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './api';
|
||||
export * from './hooks';
|
||||
export * from './types';
|
||||
export * from './schemas';
|
||||
export * from './supabase';
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './auth';
|
||||
export * from './gacha';
|
||||
export * from './teams';
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { z } from 'zod';
|
||||
import { ETeamVisibility } from '../../types/teams';
|
||||
|
||||
export const teamCreateSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(3, 'Nama tim minimal 3 karakter')
|
||||
.max(50, 'Nama tim maksimal 50 karakter'),
|
||||
logo: z.string().url('Logo harus berupa URL yang valid').nullable(),
|
||||
banner: z.string().url('Banner harus berupa URL yang valid').nullable(),
|
||||
description: z
|
||||
.string()
|
||||
.min(10, 'Deskripsi minimal 10 karakter')
|
||||
.max(500, 'Deskripsi maksimal 500 karakter'),
|
||||
city: z.string().min(1, 'Kota harus diisi'),
|
||||
visibility: z.nativeEnum(ETeamVisibility, {
|
||||
errorMap: () => ({ message: 'Visibilitas tidak valid' }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const teamUpdateSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(3, 'Nama tim minimal 3 karakter')
|
||||
.max(50, 'Nama tim maksimal 50 karakter')
|
||||
.optional(),
|
||||
logo: z.string().url('Logo harus berupa URL yang valid').nullable().optional(),
|
||||
banner: z.string().url('Banner harus berupa URL yang valid').nullable().optional(),
|
||||
description: z
|
||||
.string()
|
||||
.min(10, 'Deskripsi minimal 10 karakter')
|
||||
.max(500, 'Deskripsi maksimal 500 karakter')
|
||||
.optional(),
|
||||
city: z.string().min(1, 'Kota harus diisi').optional(),
|
||||
visibility: z
|
||||
.nativeEnum(ETeamVisibility, {
|
||||
errorMap: () => ({ message: 'Visibilitas tidak valid' }),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const inviteMemberSchema = z.object({
|
||||
email: z.string().email('Email tidak valid'),
|
||||
});
|
||||
|
||||
export const joinTeamSchema = z.object({
|
||||
message: z
|
||||
.string()
|
||||
.min(10, 'Pesan minimal 10 karakter')
|
||||
.max(200, 'Pesan maksimal 200 karakter'),
|
||||
});
|
||||
|
||||
export const projectSubmissionSchema = z.object({
|
||||
project_name: z
|
||||
.string()
|
||||
.min(3, 'Nama project minimal 3 karakter')
|
||||
.max(100, 'Nama project maksimal 100 karakter'),
|
||||
description: z
|
||||
.string()
|
||||
.min(20, 'Deskripsi minimal 20 karakter')
|
||||
.max(1000, 'Deskripsi maksimal 1000 karakter'),
|
||||
repository_url: z.string().url('URL repository tidak valid'),
|
||||
demo_url: z.string().url('URL demo tidak valid').optional().or(z.literal('')),
|
||||
presentation_url: z.string().url('URL presentasi tidak valid').optional().or(z.literal('')),
|
||||
screenshots: z.array(z.string().url('URL screenshot tidak valid')).optional(),
|
||||
});
|
||||
|
||||
export const userOnboardingSchema = z.object({
|
||||
fullname: z.string().min(3, 'Nama lengkap minimal 3 karakter').optional(),
|
||||
avatar: z.string().url('Avatar harus berupa URL yang valid').nullable().optional(),
|
||||
location: z.string().min(1, 'Domisili harus diisi'),
|
||||
bio: z.string().max(500, 'Bio maksimal 500 karakter').optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type TTeamCreateForm = z.infer<typeof teamCreateSchema>;
|
||||
export type TTeamUpdateForm = z.infer<typeof teamUpdateSchema>;
|
||||
export type TInviteMemberForm = z.infer<typeof inviteMemberSchema>;
|
||||
export type TJoinTeamForm = z.infer<typeof joinTeamSchema>;
|
||||
export type TProjectSubmissionForm = z.infer<typeof projectSubmissionSchema>;
|
||||
export type TUserOnboardingForm = z.infer<typeof userOnboardingSchema>;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error('Missing Supabase environment variables');
|
||||
}
|
||||
|
||||
// Create Supabase client with proper session management enabled
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
autoRefreshToken: true, // ✅ Auto-refresh expired tokens
|
||||
persistSession: true, // ✅ Persist session in storage
|
||||
detectSessionInUrl: true, // ✅ Auto-detect OAuth callback
|
||||
storage: typeof window !== 'undefined' ? window.localStorage : undefined,
|
||||
},
|
||||
global: {
|
||||
headers: {
|
||||
'X-Client-Info': 'supabase-js-web',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to create authenticated client (kept for backward compatibility)
|
||||
// NOTE: With proper session management, this should no longer be needed
|
||||
// Once session is set via supabase.auth.setSession(), the base client will have auth context
|
||||
export const getAuthenticatedClient = (accessToken: string) => {
|
||||
return createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false,
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
global: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './client';
|
||||
@@ -4,3 +4,4 @@ export * from './users';
|
||||
export * from './roles';
|
||||
export * from './permissions';
|
||||
export * from './mentors';
|
||||
export * from './teams';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
npm warn exec The following package was not found and will be installed: supabase@2.58.5
|
||||
npm warn deprecated node-domexception@1.0.0: Use your platform's native DOMException instead
|
||||
supabase start is not running.
|
||||
Try rerunning the command with --debug to troubleshoot the error.
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { TUserItem } from '../users';
|
||||
|
||||
export enum ETeamVisibility {
|
||||
PUBLIC = 'public',
|
||||
PRIVATE = 'private',
|
||||
}
|
||||
|
||||
export enum ETeamMemberRole {
|
||||
LEADER = 'leader',
|
||||
MEMBER = 'member',
|
||||
}
|
||||
|
||||
export enum ETeamMemberStatus {
|
||||
ACTIVE = 'active',
|
||||
PENDING = 'pending',
|
||||
REQUESTED = 'requested',
|
||||
}
|
||||
|
||||
export enum EInvitationStatus {
|
||||
PENDING = 'pending',
|
||||
ACCEPTED = 'accepted',
|
||||
REJECTED = 'rejected',
|
||||
}
|
||||
|
||||
export enum ESubmissionStatus {
|
||||
DRAFT = 'draft',
|
||||
SUBMITTED = 'submitted',
|
||||
}
|
||||
|
||||
export type TTeamItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
banner: string | null;
|
||||
description: string;
|
||||
city: string;
|
||||
visibility: ETeamVisibility;
|
||||
leader_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type TTeamDetailItem = TTeamItem & {
|
||||
leader: TUserItem;
|
||||
members: TTeamMemberItem[];
|
||||
member_count: number;
|
||||
has_submission: boolean;
|
||||
};
|
||||
|
||||
export type TTeamMemberItem = {
|
||||
id: string;
|
||||
team_id: string;
|
||||
user_id: string;
|
||||
role: ETeamMemberRole;
|
||||
status: ETeamMemberStatus;
|
||||
joined_at: string;
|
||||
user: TUserItem;
|
||||
};
|
||||
|
||||
export type TTeamInvitationItem = {
|
||||
id: string;
|
||||
team_id: string;
|
||||
inviter_id: string;
|
||||
invitee_email: string;
|
||||
invitee_id: string | null;
|
||||
status: EInvitationStatus;
|
||||
created_at: string;
|
||||
team: TTeamItem;
|
||||
inviter: TUserItem;
|
||||
};
|
||||
|
||||
export type TTeamJoinRequestItem = {
|
||||
id: string;
|
||||
team_id: string;
|
||||
user_id: string;
|
||||
status: EInvitationStatus;
|
||||
message: string;
|
||||
created_at: string;
|
||||
user: TUserItem;
|
||||
team: TTeamItem;
|
||||
};
|
||||
|
||||
export type TProjectSubmissionItem = {
|
||||
id: string;
|
||||
team_id: string;
|
||||
project_name: string;
|
||||
description: string;
|
||||
repository_url: string;
|
||||
demo_url: string | null;
|
||||
presentation_url: string | null;
|
||||
screenshots: string[];
|
||||
status: ESubmissionStatus;
|
||||
submitted_at: string | null;
|
||||
submitted_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
// Request/Response DTOs
|
||||
export type TCreateTeamRequest = {
|
||||
name: string;
|
||||
logo: string | null;
|
||||
banner: string | null;
|
||||
description: string;
|
||||
city: string;
|
||||
visibility: ETeamVisibility;
|
||||
};
|
||||
|
||||
export type TUpdateTeamRequest = Partial<TCreateTeamRequest>;
|
||||
|
||||
export type TInviteMemberRequest = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type TJoinTeamRequest = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type TManageMemberRequest = {
|
||||
action: 'approve' | 'reject' | 'remove';
|
||||
};
|
||||
|
||||
export type TSubmitProjectRequest = {
|
||||
project_name: string;
|
||||
description: string;
|
||||
repository_url: string;
|
||||
demo_url?: string;
|
||||
presentation_url?: string;
|
||||
screenshots?: string[];
|
||||
};
|
||||
|
||||
export type TTeamListResponse = {
|
||||
data: TTeamItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export type TTeamDetailResponse = {
|
||||
data: TTeamDetailItem;
|
||||
};
|
||||
|
||||
export type TTeamMembersResponse = {
|
||||
data: TTeamMemberItem[];
|
||||
};
|
||||
|
||||
export type TTeamInvitationsResponse = {
|
||||
data: TTeamInvitationItem[];
|
||||
};
|
||||
|
||||
export type TTeamJoinRequestsResponse = {
|
||||
data: TTeamJoinRequestItem[];
|
||||
};
|
||||
|
||||
export type TProjectSubmissionResponse = {
|
||||
data: TProjectSubmissionItem;
|
||||
};
|
||||
@@ -10,6 +10,9 @@ export type TUserItem = {
|
||||
is_active: boolean;
|
||||
phone_number: string;
|
||||
role: TRoleDetailItem;
|
||||
location?: string;
|
||||
bio?: string;
|
||||
skills?: string[];
|
||||
};
|
||||
|
||||
// Re-export types from API for convenience
|
||||
|
||||
@@ -9,9 +9,16 @@ import { EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons'; // Import
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { Button } from '../button';
|
||||
|
||||
type TInputType = 'text' | 'email' | 'number' | 'password' | 'file' | 'date' | 'time';
|
||||
type TInputType =
|
||||
| 'text'
|
||||
| 'email'
|
||||
| 'number'
|
||||
| 'password'
|
||||
| 'file'
|
||||
| 'date'
|
||||
| 'time';
|
||||
type TInputSize = 'sm' | 'md' | 'lg';
|
||||
type Width = 'standard' | 'custom'
|
||||
type Width = 'standard' | 'custom';
|
||||
|
||||
type TInputProps = Omit<
|
||||
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
||||
@@ -36,7 +43,7 @@ export const Input: FC<TInputProps> = ({
|
||||
type = 'text',
|
||||
size = 'md',
|
||||
placeholder = 'Placeholder',
|
||||
widthform='standard',
|
||||
widthform = 'standard',
|
||||
disabled,
|
||||
className,
|
||||
...rest
|
||||
@@ -49,7 +56,9 @@ export const Input: FC<TInputProps> = ({
|
||||
};
|
||||
|
||||
const mergedClassName = cn(
|
||||
`px-[12px] py-[8px] text-neutral-800 bg-white placeholder:text-neutral-300 border border-neutral-200 hover:border-blue-300 focus:outline-1 focus:outline-blue-500 rounded-md font-bai-jamjuree ${widthform === "standard" ? "min-w-70" : ""}`,
|
||||
`px-[12px] py-[8px] text-neutral-800 bg-white placeholder:text-neutral-300 border border-neutral-200 hover:border-blue-300 focus:outline-1 focus:outline-blue-500 rounded-md font-bai-jamjuree w-full ${
|
||||
widthform === 'standard' ? 'min-w-70' : ''
|
||||
}`,
|
||||
sizeClasses[size].textSize,
|
||||
disabled && disabledClass,
|
||||
className
|
||||
@@ -65,14 +74,14 @@ export const Input: FC<TInputProps> = ({
|
||||
{...rest}
|
||||
/>
|
||||
{type === 'password' && (
|
||||
<div className="absolute end-0 px-[12px] h-full flex items-center">
|
||||
<div className="absolute end-0 px-3 h-full flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size={size}
|
||||
onClick={togglePasswordVisibility}
|
||||
className={cn(
|
||||
'relative aspect-square -me-[8px] p-[6px]',
|
||||
'relative aspect-square -me-2 p-1.5',
|
||||
sizeClasses[size].iconSize,
|
||||
disabled && 'cursor-not-allowed'
|
||||
)}
|
||||
|
||||
@@ -1,29 +1,10 @@
|
||||
import { TVerifyOtpRequest, usePostVerifyEmail } from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
// OTP verification is not used with GitHub OAuth authentication
|
||||
// This hook is kept for backward compatibility but is not used
|
||||
export const useOtp = () => {
|
||||
const navigate = useNavigate();
|
||||
const { mutate, isPending } = usePostVerifyEmail();
|
||||
const [ searchParams ] = useSearchParams();
|
||||
|
||||
const otp = (payload: TVerifyOtpRequest) => {
|
||||
mutate({ ...payload, email: searchParams.get("email") || "" }, {
|
||||
onSuccess: (data) => {
|
||||
toast.success('Berhasil Registrasi');
|
||||
navigate("/auth/register/success");
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err?.response?.data?.message ??
|
||||
'Terjadi Kesalahan yang tidak diketahui'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
otp,
|
||||
isLoading: isPending,
|
||||
otp: () => {
|
||||
console.warn('OTP verification is not used with GitHub OAuth authentication');
|
||||
},
|
||||
isLoading: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,28 +1,10 @@
|
||||
import { TRegisterRequest, usePostRegister } from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
// Registration is handled through GitHub OAuth
|
||||
// This hook is kept for backward compatibility but is not used
|
||||
export const useRegister = () => {
|
||||
const navigate = useNavigate();
|
||||
const { mutate, isPending } = usePostRegister();
|
||||
|
||||
const register = (payload: TRegisterRequest) => {
|
||||
mutate(payload, {
|
||||
onSuccess: (data) => {
|
||||
toast.success('Berhasil Registrasi');
|
||||
navigate(`/auth/register/otp?email=${payload.email}`);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err?.response?.data?.message ??
|
||||
'Terjadi Kesalahan yang tidak diketahui'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
register,
|
||||
isLoading: isPending,
|
||||
register: () => {
|
||||
console.warn('Registration is handled through GitHub OAuth');
|
||||
},
|
||||
isLoading: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,25 +1,10 @@
|
||||
import { TSendOTPRequest, usePostSendOTP } from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// OTP is not used with GitHub OAuth authentication
|
||||
// This hook is kept for backward compatibility but is not used
|
||||
export const useSendOTP = () => {
|
||||
const { mutate, isPending } = usePostSendOTP();
|
||||
|
||||
const resendOTP = (payload: TSendOTPRequest) => {
|
||||
mutate(payload, {
|
||||
onSuccess: (data) => {
|
||||
toast.success('Berhasil Mengirim Ulang OTP');
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err?.response?.data?.message ??
|
||||
'Terjadi Kesalahan yang tidak diketahui'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
resendOTP,
|
||||
isLoading: isPending,
|
||||
resendOTP: () => {
|
||||
console.warn('OTP is not used with GitHub OAuth authentication');
|
||||
},
|
||||
isLoading: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,41 +1,21 @@
|
||||
import { TLoginRequest, usePostLogin } from '@imphnen-frontend-service/service';
|
||||
import { supabase } from '@imphnen-frontend-service/service';
|
||||
import { useAuthStore } from './';
|
||||
import { toast } from 'sonner';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
export const useSession = () => {
|
||||
const navigate = useNavigate();
|
||||
const { mutate, isPending } = usePostLogin();
|
||||
const { setLoading, setSession, clearSession, session, status } =
|
||||
useAuthStore();
|
||||
const { clearSession, session, status } = useAuthStore();
|
||||
const isAuthenticated = status === 'authenticated';
|
||||
|
||||
const signIn = (payload: TLoginRequest) => {
|
||||
setLoading(true);
|
||||
mutate(payload, {
|
||||
onSuccess: (data) => {
|
||||
toast.success('Login sukses');
|
||||
setSession(data.data);
|
||||
navigate(0);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err?.response?.data?.message ??
|
||||
'Terjadi Kesalahan yang tidak diketahui'
|
||||
);
|
||||
clearSession();
|
||||
},
|
||||
});
|
||||
};
|
||||
const signOut = () => {
|
||||
const signOut = async () => {
|
||||
await supabase.auth.signOut();
|
||||
clearSession();
|
||||
navigate(0);
|
||||
navigate('/auth/login');
|
||||
};
|
||||
|
||||
return {
|
||||
session,
|
||||
signIn,
|
||||
signOut,
|
||||
isLoading: isPending,
|
||||
isAuthenticated,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -23,6 +23,9 @@ type TUserItem = {
|
||||
is_active: boolean;
|
||||
phone_number: string;
|
||||
role: TRoleItem;
|
||||
bio?: string;
|
||||
location?: string;
|
||||
skills?: string[];
|
||||
};
|
||||
|
||||
export const SessionUser = {
|
||||
|
||||
Reference in New Issue
Block a user