Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa4686254b | ||
|
|
2d80ed05db | ||
|
|
d923237969 | ||
|
|
b2a28c6fd3 | ||
|
|
d927b95457 | ||
|
|
2dc9dd985a | ||
|
|
417ff3390b | ||
|
|
0c646ceb85 | ||
|
|
6bbc87479a | ||
|
|
80a6aa9fb5 | ||
|
|
1343404e01 | ||
|
|
5faba43728 | ||
|
|
a20f5eff85 | ||
|
|
23b6677323 | ||
|
|
45c75b3283 | ||
|
|
18e835450a | ||
|
|
f5ad55235c | ||
|
|
764eb34e47 | ||
|
|
fe4e9291fa | ||
|
|
ec9a81d7b5 | ||
|
|
e652c6ed0e | ||
|
|
d431564aac | ||
|
|
350d08a2b4 | ||
|
|
eb5e043357 | ||
|
|
d67e3e7244 | ||
|
|
a7196dd453 | ||
|
|
c83e54fae8 | ||
|
|
35a5761a7c | ||
|
|
b472fbdbbf | ||
|
|
4fcc96ac15 | ||
|
|
2dfcd3adad | ||
|
|
d79788258b | ||
|
|
427d2b69c7 | ||
|
|
7e5e4cc29b | ||
|
|
a1e89b882a | ||
|
|
a74f0181f1 | ||
|
|
a9a1e5450b | ||
|
|
5e65942060 | ||
|
|
d044d0ef50 | ||
|
|
d40a913ccc | ||
|
|
66178ccc18 | ||
|
|
8280ba2a64 | ||
|
|
8259020450 | ||
|
|
692a7a7258 | ||
|
|
1ee04ada14 | ||
|
|
193cb61b45 | ||
|
|
4777a34f23 | ||
|
|
4d3677ebd2 | ||
|
|
f0eb11010d | ||
|
|
e1f13b3872 | ||
|
|
fa30849d03 | ||
|
|
f8d592d811 | ||
|
|
c385aa1996 | ||
|
|
9a97eb3e8e | ||
|
|
70ee5300f7 | ||
|
|
35c9539c08 |
@@ -10,7 +10,13 @@
|
||||
"Bash(ren page.tsx page-original.tsx)",
|
||||
"Bash(ren:*)",
|
||||
"Bash(npx supabase:*)",
|
||||
"Bash(libs/service/src/types/supabase.ts)"
|
||||
"Bash(libs/service/src/types/supabase.ts)",
|
||||
"Bash(npx nx build:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(ls:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"nx-mcp": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["nx", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!-- nx configuration start-->
|
||||
<!-- Leave the start & end comments to automatically receive updates. -->
|
||||
|
||||
# General Guidelines for working with Nx
|
||||
|
||||
- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
|
||||
- You have access to the Nx MCP server and its tools, use them to help the user
|
||||
- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable.
|
||||
- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies
|
||||
- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration
|
||||
- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors
|
||||
|
||||
<!-- nx configuration end-->
|
||||
@@ -0,0 +1,32 @@
|
||||
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { FC, ReactElement } from 'react';
|
||||
|
||||
export const HackathonDashboardPage: FC = (): ReactElement => {
|
||||
return (
|
||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
|
||||
|
||||
<section className="grid grid-cols-5 gap-5">
|
||||
{/* Participant */}
|
||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||
1261
|
||||
</h3>
|
||||
<p className="text-neutral-400 text-p3">Total Participants</p>
|
||||
</div>
|
||||
{/* Team */}
|
||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">206</h3>
|
||||
<p className="text-neutral-400 text-p3">Total Teams</p>
|
||||
</div>
|
||||
{/* Project Submitted */}
|
||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">0</h3>
|
||||
<p className="text-neutral-400 text-p3">Total Project Submitted</p>
|
||||
</div>
|
||||
</section>
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default HackathonDashboardPage;
|
||||
@@ -0,0 +1,128 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
|
||||
export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
});
|
||||
|
||||
const mockData: any[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
project_name: `Project ${i + 1}`,
|
||||
repository_url: `https://github.com/user/repo${i + 1}`,
|
||||
demo_url: `https://demo.example.com/project${i + 1}`,
|
||||
presentation_url: `https://slides.example.com/project${i + 1}`,
|
||||
}));
|
||||
|
||||
type UserStatus = 'active' | 'inactive';
|
||||
|
||||
interface SubmissionType {
|
||||
id: number;
|
||||
project_name: string;
|
||||
repository_url: string;
|
||||
demo_url: string;
|
||||
presentation_url: string;
|
||||
}
|
||||
|
||||
const columns: ColumnDef<SubmissionType>[] = [
|
||||
{
|
||||
header: 'Project Name',
|
||||
accessorKey: 'project_name',
|
||||
},
|
||||
{
|
||||
header: 'Repository URL',
|
||||
accessorKey: 'repository_url',
|
||||
},
|
||||
{
|
||||
header: 'Demo URL',
|
||||
accessorKey: 'demo_url',
|
||||
},
|
||||
{
|
||||
header: 'Presentation URL',
|
||||
accessorKey: 'presentation_url',
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
meta: { cellClassName: cn('w-72') },
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 w-max"
|
||||
onClick={() => {
|
||||
// View detail logic
|
||||
}}
|
||||
>
|
||||
<EditOutlined className="text-base" /> View & Manage
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: mockData,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||
manualPagination: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||
Project Submission
|
||||
</h1>
|
||||
{/* Filters and actions */}
|
||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<input
|
||||
type="text"
|
||||
className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-64"
|
||||
placeholder="Search name or email"
|
||||
/>
|
||||
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||
<option value="all">All City</option>
|
||||
<option value="jakarta">Jakarta</option>
|
||||
<option value="bandung">Bandung</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<DataTable data={mockData} columns={columns} table={table} />
|
||||
</section>
|
||||
|
||||
{/* Modals extracted into shared backoffice components */}
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default HackathonUsersPage;
|
||||
@@ -0,0 +1,210 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { useTeams } from '@imphnen-frontend-service/service';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
|
||||
export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||
const { data: teamsData } = useTeams();
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
});
|
||||
|
||||
const mockData: TeamType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
id: `team-${i + 1}`,
|
||||
name: `Team ${i + 1} - ${i % 3 === 0 ? 'Innovators' : 'Hackers'}`,
|
||||
city: i % 2 === 0 ? 'Jakarta' : 'Bandung',
|
||||
visibility: i % 4 === 0 ? 'private' : 'public',
|
||||
member_count: Math.floor(Math.random() * 4) + 1,
|
||||
has_submission: i % 3 !== 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
leader: {
|
||||
user: {
|
||||
fullname: `Leader User ${i}`,
|
||||
email: `leader${i}@example.com`,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
interface TeamType {
|
||||
id: string;
|
||||
name: string;
|
||||
city: string;
|
||||
visibility: 'public' | 'private';
|
||||
member_count: number;
|
||||
has_submission: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
leader?: {
|
||||
user: {
|
||||
fullname: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const columns: ColumnDef<TeamType>[] = [
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: 'ID',
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Team Name',
|
||||
},
|
||||
{
|
||||
accessorKey: 'city',
|
||||
header: 'City',
|
||||
},
|
||||
{
|
||||
accessorKey: 'visibility',
|
||||
header: 'Visibility',
|
||||
cell: ({ row }) => {
|
||||
const isPublic = row.original.visibility === 'public';
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'py-2 px-4 text-sm rounded-2xl text-center',
|
||||
isPublic
|
||||
? 'bg-info-200 text-info-700'
|
||||
: 'bg-gray-200 text-gray-700'
|
||||
)}
|
||||
>
|
||||
{isPublic ? 'Public' : 'Private'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'member_count',
|
||||
header: 'Members',
|
||||
},
|
||||
{
|
||||
id: 'leader',
|
||||
header: 'Leader',
|
||||
cell: ({ row }) => {
|
||||
const leader = row.original.leader?.user;
|
||||
return leader ? (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{leader.fullname}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{leader.email}</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400 italic">-</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'has_submission',
|
||||
header: 'Submitted',
|
||||
cell: ({ row }) => {
|
||||
const hasSubmission = row.original.has_submission;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'py-2 px-4 text-sm rounded-2xl text-center',
|
||||
hasSubmission
|
||||
? 'bg-success-200 text-success-700'
|
||||
: 'bg-danger-200 text-danger-700'
|
||||
)}
|
||||
>
|
||||
{hasSubmission ? 'Yes' : 'No'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: 'Last Updated',
|
||||
cell: ({ row }) => {
|
||||
return new Date(row.original.updated_at).toLocaleDateString();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Action',
|
||||
meta: { cellClassName: cn('w-48') },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 w-max"
|
||||
onClick={() => {
|
||||
// View detail logic
|
||||
}}
|
||||
>
|
||||
<EditOutlined className="text-base" /> View & Manage
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: mockData,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||
manualPagination: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||
Team Management
|
||||
</h1>
|
||||
{/* Filters and actions */}
|
||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<input
|
||||
type="text"
|
||||
className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-64"
|
||||
placeholder="Search name or email"
|
||||
/>
|
||||
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||
<option value="all">All City</option>
|
||||
<option value="jakarta">Jakarta</option>
|
||||
<option value="bandung">Bandung</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* Table */}
|
||||
<DataTable data={mockData} columns={columns} table={table} />
|
||||
</section>
|
||||
{/* Modals extracted into shared backoffice components */}
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default HackathonTeamsPage;
|
||||
+651
@@ -0,0 +1,651 @@
|
||||
import { FC, useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
UserOutlined,
|
||||
EnvironmentOutlined,
|
||||
CalendarOutlined,
|
||||
SaveOutlined,
|
||||
CloseOutlined,
|
||||
ExclamationOutlined,
|
||||
CameraOutlined,
|
||||
DeleteOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
interface UserType {
|
||||
id: string;
|
||||
avatar?: string;
|
||||
fullname: string;
|
||||
bio?: string;
|
||||
location: string;
|
||||
is_active: boolean;
|
||||
skills: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: UserType | null;
|
||||
}
|
||||
|
||||
const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
const [formData, setFormData] = useState<UserType | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showAvatarMenu, setShowAvatarMenu] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Initialize form data when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (user) {
|
||||
// Edit existing user
|
||||
setFormData({ ...user });
|
||||
} else {
|
||||
// Create new user
|
||||
setFormData({
|
||||
id: '', // Will be generated by backend
|
||||
fullname: '',
|
||||
bio: '',
|
||||
location: '',
|
||||
is_active: true,
|
||||
skills: [],
|
||||
avatar: undefined,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isOpen, user]);
|
||||
|
||||
// Check if form has changes
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!formData) return false;
|
||||
if (!user) return true; // New user always has changes
|
||||
return (
|
||||
formData.fullname !== user.fullname ||
|
||||
formData.location !== user.location ||
|
||||
formData.is_active !== user.is_active ||
|
||||
formData.avatar !== user.avatar ||
|
||||
JSON.stringify(formData.skills) !== JSON.stringify(user.skills) ||
|
||||
formData.bio !== user.bio
|
||||
);
|
||||
}, [formData, user]);
|
||||
|
||||
// Check if required fields are filled
|
||||
const isFormValid = useMemo(() => {
|
||||
if (!formData) return false;
|
||||
return formData.fullname.trim() !== '' && formData.location.trim() !== '';
|
||||
}, [formData]);
|
||||
|
||||
const canSave = hasChanges && isFormValid;
|
||||
|
||||
if (!isOpen || !formData) return null;
|
||||
|
||||
const handleInputChange = (
|
||||
field: keyof UserType,
|
||||
value: string | boolean | string[] | undefined
|
||||
) => {
|
||||
setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
|
||||
};
|
||||
|
||||
const handleSkillsChange = (skills: string[]) => {
|
||||
setFormData((prev) => (prev ? { ...prev, skills } : null));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!formData) return;
|
||||
|
||||
if (user) {
|
||||
// Update existing user
|
||||
console.log('Update user data:', formData);
|
||||
} else {
|
||||
// Create new user
|
||||
console.log('Create new user:', formData);
|
||||
}
|
||||
// Here you would typically make an API call to save the data
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (user) {
|
||||
setFormData({ ...user }); // Reset to original for edit mode
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
if (!user) return; // Can't delete new user
|
||||
console.log('Delete user:', user.id);
|
||||
setShowDeleteConfirm(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('Image size must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview URL
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const avatarUrl = e.target?.result as string;
|
||||
handleInputChange('avatar', avatarUrl);
|
||||
setShowAvatarMenu(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAvatar = () => {
|
||||
handleInputChange('avatar', undefined);
|
||||
setShowAvatarMenu(false);
|
||||
};
|
||||
|
||||
const triggerFileUpload = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const availableSkills = [
|
||||
'Frontend Developer',
|
||||
'Backend Developer',
|
||||
'Full Stack Developer',
|
||||
'DevOps Engineer',
|
||||
'UI/UX Designer',
|
||||
'Product Manager',
|
||||
'Data Scientist',
|
||||
'Mobile Developer',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50"
|
||||
onClick={(e) => {
|
||||
setShowAvatarMenu(false);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Hidden File Input */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleAvatarUpload}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="border-b border-neutral-200 px-8 py-6 flex justify-between items-start">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Interactive User Avatar */}
|
||||
<div className="relative group ">
|
||||
<div className="w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden border-2 border-transparent group-hover:border-primary-300 transition-colors">
|
||||
{formData.avatar ? (
|
||||
<img
|
||||
src={formData.avatar}
|
||||
alt={formData.fullname}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<UserOutlined className="text-neutral-500 text-2xl" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Avatar Hover Overlay */}
|
||||
<button
|
||||
onClick={() => setShowAvatarMenu(!showAvatarMenu)}
|
||||
className="absolute inset-0 bg-neutral-400 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||
>
|
||||
<CameraOutlined className="text-white text-lg" />
|
||||
</button>
|
||||
|
||||
{/* Avatar Menu Dropdown */}
|
||||
{showAvatarMenu && (
|
||||
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10">
|
||||
<button
|
||||
onClick={triggerFileUpload}
|
||||
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<UploadOutlined className="text-sm" />
|
||||
{formData.avatar ? 'Change Photo' : 'Upload Photo'}
|
||||
</button>
|
||||
{formData.avatar && (
|
||||
<button
|
||||
onClick={handleRemoveAvatar}
|
||||
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<DeleteOutlined className="text-sm" />
|
||||
Remove Photo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h2 className="text-2xl font-bold text-neutral-900">
|
||||
{user ? 'Edit User Profile' : 'Create New User'}
|
||||
</h2>
|
||||
{user && (
|
||||
<span className="px-3 py-1 bg-info-100 text-info-700 text-xs font-medium rounded-2xl">
|
||||
Hover avatar to change
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-neutral-500">
|
||||
{user
|
||||
? `Make changes to ${
|
||||
formData.fullname || 'this user'
|
||||
}'s profile information`
|
||||
: 'Fill in the information below to create a new user account'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
onClick={() => {
|
||||
setShowAvatarMenu(false);
|
||||
handleCancel();
|
||||
}}
|
||||
>
|
||||
<CloseOutlined className="text-neutral-400 text-lg cursor-pointer" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-8" onClick={() => setShowAvatarMenu(false)}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Left Column - Basic Info */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||
Basic Information
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* Full Name - Required */}
|
||||
<div className="flex items-center gap-3">
|
||||
<UserOutlined className="text-neutral-400" />
|
||||
<div className="flex-1">
|
||||
<label className="text-sm text-neutral-500 block mb-1">
|
||||
Full Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.fullname}
|
||||
onChange={(e) =>
|
||||
handleInputChange('fullname', e.target.value)
|
||||
}
|
||||
className={cn(
|
||||
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none',
|
||||
formData.fullname.trim() === ''
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-neutral-300'
|
||||
)}
|
||||
placeholder="Enter full name"
|
||||
/>
|
||||
{formData.fullname.trim() === '' && (
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
Full name is required
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location - Required */}
|
||||
<div className="flex items-center gap-3">
|
||||
<EnvironmentOutlined className="text-neutral-400" />
|
||||
<div className="flex-1">
|
||||
<label className="text-sm text-neutral-500 block mb-1">
|
||||
Location <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.location}
|
||||
onChange={(e) =>
|
||||
handleInputChange('location', e.target.value)
|
||||
}
|
||||
className={cn(
|
||||
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white',
|
||||
formData.location.trim() === ''
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-neutral-300'
|
||||
)}
|
||||
>
|
||||
<option value="">Select location</option>
|
||||
<option value="Jakarta">Jakarta</option>
|
||||
<option value="Bandung">Bandung</option>
|
||||
<option value="Surabaya">Surabaya</option>
|
||||
<option value="Medan">Medan</option>
|
||||
<option value="Yogyakarta">Yogyakarta</option>
|
||||
</select>
|
||||
{formData.location.trim() === '' && (
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
Location is required
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Joined Date - Read Only - Only show for existing users */}
|
||||
{user && (
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarOutlined className="text-neutral-400" />
|
||||
<div>
|
||||
<p className="text-sm text-neutral-500">
|
||||
Joined Date
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{new Date(formData.created_at).toLocaleDateString(
|
||||
'en-US',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bio Section - Optional */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-3">
|
||||
Bio{' '}
|
||||
<span className="text-neutral-400 text-sm font-normal">
|
||||
(Optional)
|
||||
</span>
|
||||
</h3>
|
||||
<textarea
|
||||
value={formData.bio || ''}
|
||||
onChange={(e) =>
|
||||
handleInputChange('bio', e.target.value || undefined)
|
||||
}
|
||||
placeholder="Tell us about yourself..."
|
||||
rows={4}
|
||||
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Skills & Status */}
|
||||
<div className="space-y-6">
|
||||
{/* Account Status - Enhanced Tab Design */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||
Account Status
|
||||
</h3>
|
||||
<div className="flex bg-neutral-100 p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => handleInputChange('is_active', true)}
|
||||
className={cn(
|
||||
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
|
||||
formData.is_active
|
||||
? 'bg-white text-success-700 shadow-sm ring-1 ring-success-200'
|
||||
: 'text-neutral-600 hover:text-neutral-800'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
formData.is_active
|
||||
? 'bg-success-500'
|
||||
: 'bg-neutral-400'
|
||||
)}
|
||||
/>
|
||||
Active
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleInputChange('is_active', false)}
|
||||
className={cn(
|
||||
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
|
||||
!formData.is_active
|
||||
? 'bg-white text-neutral-700 shadow-sm ring-1 ring-neutral-200'
|
||||
: 'text-neutral-600 hover:text-neutral-800'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
!formData.is_active
|
||||
? 'bg-neutral-500'
|
||||
: 'bg-neutral-400'
|
||||
)}
|
||||
/>
|
||||
Inactive
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-2">
|
||||
{formData.is_active
|
||||
? 'User can access their account and participate in activities'
|
||||
: 'User account is suspended and cannot access services'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Skills Section - Optional */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||
Skills & Expertise{' '}
|
||||
<span className="text-neutral-400 text-sm font-normal">
|
||||
(Optional)
|
||||
</span>
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2 min-h-10 p-3 border border-neutral-300 rounded-lg bg-neutral-50">
|
||||
{formData.skills.length > 0 ? (
|
||||
formData.skills.map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-2xl text-sm font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
{skill}
|
||||
<button
|
||||
onClick={() =>
|
||||
handleSkillsChange(
|
||||
formData.skills.filter((_, i) => i !== index)
|
||||
)
|
||||
}
|
||||
className="text-blue-600 hover:text-blue-800 ml-1 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-neutral-400 text-sm">
|
||||
No skills added yet
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (
|
||||
e.target.value &&
|
||||
!formData.skills.includes(e.target.value)
|
||||
) {
|
||||
handleSkillsChange([
|
||||
...formData.skills,
|
||||
e.target.value,
|
||||
]);
|
||||
}
|
||||
}}
|
||||
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white"
|
||||
>
|
||||
<option value="">Add a skill...</option>
|
||||
{availableSkills
|
||||
.filter((skill) => !formData.skills.includes(skill))
|
||||
.map((skill) => (
|
||||
<option key={skill} value={skill}>
|
||||
{skill}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Details - Read Only - Only show for existing users */}
|
||||
{user && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||
Account Details
|
||||
</h3>
|
||||
<div className="space-y-3 bg-neutral-50 p-4 rounded-lg">
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-neutral-600 text-sm">
|
||||
User ID
|
||||
</span>
|
||||
<span className="font-mono text-sm text-neutral-800">
|
||||
{formData.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-neutral-600 text-sm">
|
||||
Last Updated
|
||||
</span>
|
||||
<span className="text-sm text-neutral-800">
|
||||
{new Date(formData.updated_at).toLocaleDateString(
|
||||
'en-US',
|
||||
{
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="border-t border-neutral-200 px-8 py-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-neutral-500">
|
||||
{canSave
|
||||
? 'Ready to save changes'
|
||||
: hasChanges
|
||||
? 'Please fill required fields'
|
||||
: 'No changes made'}
|
||||
</div>
|
||||
{/* Delete Account Button - Only show for existing users */}
|
||||
{user && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="text-red-600 hover:text-red-700 text-sm font-medium transition-colors cursor-pointer"
|
||||
>
|
||||
Delete Account
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
className="px-6"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-6',
|
||||
!canSave && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<SaveOutlined className="text-sm" />
|
||||
{user ? 'Save Changes' : 'Create User'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 z-60">
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
/>
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<ExclamationOutlined className="text-red-600 text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900">
|
||||
Delete Account
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-500">
|
||||
This action cannot be undone
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-neutral-700 mb-6">
|
||||
Are you sure you want to permanently delete{' '}
|
||||
<strong>{formData.fullname}</strong>'s account? This will
|
||||
remove all their data and cannot be reversed.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="px-4"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleDeleteAccount}
|
||||
className="px-4 bg-red-600 hover:bg-red-700 border-red-600"
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalUserDetail;
|
||||
@@ -0,0 +1,476 @@
|
||||
import { FC, ReactElement, useState, useMemo, useCallback } from 'react';
|
||||
import ModalUserDetail from './_components/modal-user-detail';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
EditOutlined,
|
||||
UserOutlined,
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
// Removed unused SearchOutlined icon after schema revision
|
||||
|
||||
// Define interface outside component
|
||||
interface UserType {
|
||||
id: string; // UUID
|
||||
avatar?: string;
|
||||
fullname: string;
|
||||
bio?: string;
|
||||
location: string;
|
||||
is_active: boolean; // admin can deactivate
|
||||
skills: string[]; // Frontend Developer, Backend Developer, etc.
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Move mock data outside component to prevent recreation
|
||||
const skillsOptions = [
|
||||
'Frontend Developer',
|
||||
'Backend Developer',
|
||||
'Full Stack Developer',
|
||||
'DevOps Engineer',
|
||||
'UI/UX Designer',
|
||||
'Product Manager',
|
||||
'Data Scientist',
|
||||
'Mobile Developer',
|
||||
];
|
||||
|
||||
const locations = ['Jakarta', 'Bandung', 'Surabaya', 'Medan', 'Yogyakarta'];
|
||||
const bios = [
|
||||
'Passionate developer with 5+ years experience',
|
||||
'Tech enthusiast and problem solver',
|
||||
'Building scalable solutions for modern problems',
|
||||
'Creative designer with technical background',
|
||||
'Data-driven decision maker',
|
||||
];
|
||||
|
||||
const mockData: UserType[] = Array.from({ length: 50 }, (_, i) => {
|
||||
const randomSkillsCount = Math.floor(Math.random() * 3) + 1; // 1-3 skills
|
||||
const randomSkills = skillsOptions
|
||||
.sort(() => 0.5 - Math.random())
|
||||
.slice(0, randomSkillsCount);
|
||||
|
||||
return {
|
||||
id: `24db9e4d-ca4c-46aa-ac36-8ef04bbe01${String(i).padStart(2, '0')}`,
|
||||
avatar:
|
||||
i % 4 === 0
|
||||
? `https://ui-avatars.com/api/?name=${encodeURIComponent(
|
||||
i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana'
|
||||
)}&background=random`
|
||||
: undefined,
|
||||
fullname:
|
||||
i % 3 === 0
|
||||
? 'Ahmad Wijuana'
|
||||
: i % 3 === 1
|
||||
? 'Sofia Wijuana'
|
||||
: 'Budi Santoso',
|
||||
bio: i % 4 === 0 ? bios[i % bios.length] : undefined,
|
||||
location: locations[i % locations.length],
|
||||
is_active: i % 7 !== 0, // More realistic distribution
|
||||
skills: randomSkills,
|
||||
created_at: new Date(
|
||||
Date.now() - i * 86400000 * (Math.random() * 30 + 1)
|
||||
).toISOString(), // Random within last 30-60 days
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [showNewUserModal, setShowNewUserModal] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
|
||||
const [globalFilter, setGlobalFilter] = useState('');
|
||||
|
||||
// Advanced filtering states
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [locationFilter, setLocationFilter] = useState('all');
|
||||
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
|
||||
|
||||
// Constants
|
||||
const pageSize = 10;
|
||||
|
||||
// Memoize the callback to prevent recreation
|
||||
const handleShowDetailModal = useCallback((user: UserType) => {
|
||||
setSelectedUser(user);
|
||||
setShowDetailModal(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseDetailModal = useCallback(() => {
|
||||
setShowDetailModal(false);
|
||||
setSelectedUser(null);
|
||||
}, []);
|
||||
|
||||
const handleShowNewUserModal = useCallback(() => {
|
||||
setShowNewUserModal(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseNewUserModal = useCallback(() => {
|
||||
setShowNewUserModal(false);
|
||||
}, []);
|
||||
|
||||
// Filter data based on current filter states
|
||||
const filteredData = useMemo(() => {
|
||||
return mockData.filter((user) => {
|
||||
// Status filter
|
||||
if (statusFilter !== 'all') {
|
||||
const isActive = statusFilter === 'active';
|
||||
if (user.is_active !== isActive) return false;
|
||||
}
|
||||
|
||||
// Location filter
|
||||
if (locationFilter !== 'all' && user.location !== locationFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skills filter
|
||||
if (skillsFilter.length > 0) {
|
||||
const hasMatchingSkill = skillsFilter.some((skill) =>
|
||||
user.skills.includes(skill)
|
||||
);
|
||||
if (!hasMatchingSkill) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [statusFilter, locationFilter, skillsFilter]);
|
||||
|
||||
// Memoize columns to prevent recreation on every render
|
||||
const columns: ColumnDef<UserType>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'fullname',
|
||||
header: 'User',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Avatar */}
|
||||
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
|
||||
{row.original.avatar ? (
|
||||
<img
|
||||
src={row.original.avatar}
|
||||
alt={row.original.fullname}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<UserOutlined className="text-neutral-500 text-lg" />
|
||||
)}
|
||||
</div>
|
||||
{/* Name only */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-neutral-900 truncate">
|
||||
{row.original.fullname}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'skills',
|
||||
header: 'Skills',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||
{row.original.skills.slice(0, 2).map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
|
||||
>
|
||||
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||
</span>
|
||||
))}
|
||||
{row.original.skills.length > 2 && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-200 text-success-700">
|
||||
+{row.original.skills.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'location',
|
||||
header: 'Location',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-neutral-700">{row.original.location}</span>
|
||||
),
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'is_active',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
row.original.is_active ? 'text-success-700' : 'text-neutral-500'
|
||||
)}
|
||||
>
|
||||
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
enableSorting: true,
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const aActive = rowA.original.is_active;
|
||||
const bActive = rowB.original.is_active;
|
||||
if (aActive && !bActive) return -1;
|
||||
if (!aActive && bActive) return 1;
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Joined',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-neutral-900 text-sm">
|
||||
{new Date(row.original.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
enableSorting: true,
|
||||
sortingFn: 'datetime',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
meta: { cellClassName: cn('w-48') },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 text-sm px-4 py-2"
|
||||
onClick={() => handleShowDetailModal(row.original)}
|
||||
>
|
||||
<EditOutlined className="text-sm" />
|
||||
Manage
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-sm px-4 py-2"
|
||||
onClick={() => {
|
||||
// Toggle user status - implement later
|
||||
console.log(`Toggle status for ${row.original.fullname}`);
|
||||
}}
|
||||
>
|
||||
{row.original.is_active ? 'Deactivate' : 'Activate'}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
],
|
||||
[handleShowDetailModal]
|
||||
);
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||
User Management
|
||||
</h1>
|
||||
{/* Filters and actions */}
|
||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||
{/* Left side - Search & filters */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
{/* Search bar */}
|
||||
<div className="relative">
|
||||
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||
<input
|
||||
type="text"
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="Search users by name or location..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="relative">
|
||||
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-36 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Location Filter */}
|
||||
<div className="relative">
|
||||
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value={locationFilter}
|
||||
onChange={(e) => setLocationFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">All Locations</option>
|
||||
{locations.map((location) => (
|
||||
<option key={location} value={location}>
|
||||
{location}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Skills Filter with Icon */}
|
||||
<div className="relative">
|
||||
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (
|
||||
e.target.value &&
|
||||
!skillsFilter.includes(e.target.value)
|
||||
) {
|
||||
setSkillsFilter((prev) => [...prev, e.target.value]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">Add Skill Filter</option>
|
||||
{skillsOptions.map((skill) => (
|
||||
<option
|
||||
key={skill}
|
||||
value={skill}
|
||||
disabled={skillsFilter.includes(skill)}
|
||||
>
|
||||
{skill}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Add User Button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex items-center gap-2 px-4 py-2"
|
||||
onClick={handleShowNewUserModal}
|
||||
>
|
||||
<PlusOutlined className="text-sm" />
|
||||
Add User
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active filters display */}
|
||||
{(skillsFilter.length > 0 ||
|
||||
statusFilter !== 'all' ||
|
||||
locationFilter !== 'all') && (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||
|
||||
{/* Status filter badge */}
|
||||
{statusFilter !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
||||
Status: {statusFilter}
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className="text-info-600 hover:text-info-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Location filter badge */}
|
||||
{locationFilter !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||
Location: {locationFilter}
|
||||
<button
|
||||
onClick={() => setLocationFilter('all')}
|
||||
className="text-green-600 hover:text-green-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Skills filter badges */}
|
||||
{skillsFilter.map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm"
|
||||
>
|
||||
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||
<button
|
||||
onClick={() =>
|
||||
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
|
||||
}
|
||||
className="text-purple-600 hover:text-purple-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Clear all filters */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStatusFilter('all');
|
||||
setLocationFilter('all');
|
||||
setSkillsFilter([]);
|
||||
setGlobalFilter('');
|
||||
}}
|
||||
className="text-sm text-neutral-600"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination-aware results display */}
|
||||
{filteredData.length > 0 && (
|
||||
<div className="text-sm text-neutral-600">
|
||||
Showing {Math.min(pageSize, filteredData.length)} of{' '}
|
||||
{filteredData.length} users
|
||||
{filteredData.length > pageSize}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<DataTable data={filteredData} columns={columns} pageSize={10} />
|
||||
</section>
|
||||
|
||||
{/* Modals component */}
|
||||
<ModalUserDetail
|
||||
isOpen={showDetailModal}
|
||||
onClose={handleCloseDetailModal}
|
||||
user={selectedUser}
|
||||
/>
|
||||
|
||||
{/* New User Modal */}
|
||||
<ModalUserDetail
|
||||
isOpen={showNewUserModal}
|
||||
onClose={handleCloseNewUserModal}
|
||||
user={null} // null indicates creating new user
|
||||
/>
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default HackathonUsersPage;
|
||||
@@ -1,13 +1,51 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
export const AppLayout: FC = (): ReactElement => {
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="bg-primary-50 min-h-screen flex justify-center">
|
||||
<div className="bg-primary-50 min-h-screen w-full flex">
|
||||
<BackofficeSidebar />
|
||||
<BackofficeSidebar
|
||||
isOpen={mobileSidebarOpen}
|
||||
onClose={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Sticky top header */}
|
||||
<header
|
||||
className={
|
||||
'lg:hidden sticky top-0 bg-white border-b border-primary-200 px-4 py-3 flex items-center gap-3 ' +
|
||||
(mobileSidebarOpen ? 'z-0' : 'z-30')
|
||||
}
|
||||
>
|
||||
{/* Mobile menu button (shown on small screens) */}
|
||||
<button
|
||||
type="button"
|
||||
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700"
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-p3 font-semibold text-primary-700">
|
||||
IMPHNEN Backoffice
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
|
||||
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
|
||||
import { cn, For } from "@imphnen-frontend-service/utils";
|
||||
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
|
||||
import { ReactElement, useState } from "react";
|
||||
import { ModalDetailUser } from "./_components/modal/detail";
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import { cn, For } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { ReactElement, useState } from 'react';
|
||||
import { ModalDetailUser } from './_components/modal/detail';
|
||||
|
||||
type UserStatus = 'active' | 'inactive';
|
||||
|
||||
interface UserType {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
rating: number
|
||||
status: UserStatus
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
rating: number;
|
||||
status: UserStatus;
|
||||
}
|
||||
|
||||
const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
@@ -22,15 +32,15 @@ const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
email: 'fullname23@gmail.com',
|
||||
rating: 4.5,
|
||||
status: i % 2 === 0 ? 'active' : 'inactive',
|
||||
}))
|
||||
}));
|
||||
|
||||
export default function Components(): ReactElement {
|
||||
const TABS = ['mentor', 'mentee'] as const
|
||||
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor')
|
||||
const [showDetail, setShowDetail] = useState(false)
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
|
||||
const TABS = ['mentor', 'mentee'] as const;
|
||||
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor');
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
@@ -39,7 +49,7 @@ export default function Components(): ReactElement {
|
||||
const columns: ColumnDef<UserType>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
meta: { cellClassName: cn("w-20") },
|
||||
meta: { cellClassName: cn('w-20') },
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -97,7 +107,7 @@ export default function Components(): ReactElement {
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
meta: { cellClassName: cn("w-72") },
|
||||
meta: { cellClassName: cn('w-72') },
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -113,7 +123,7 @@ export default function Components(): ReactElement {
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: mockData,
|
||||
@@ -134,14 +144,19 @@ export default function Components(): ReactElement {
|
||||
return (
|
||||
<BackofficeWrapper title="Dimentorin.dev">
|
||||
<div className="mb-8 flex justify-between items-center">
|
||||
<h1 className="text-p1 font-semibold text-neutral-700">User Management</h1>
|
||||
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">
|
||||
User Management
|
||||
</h1>
|
||||
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
|
||||
<For data={TABS}>
|
||||
{(tab) => (
|
||||
<Button
|
||||
key={tab}
|
||||
variant="text"
|
||||
className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
|
||||
className={cn(
|
||||
'px-3 py-2 capitalize',
|
||||
activeTab === tab && 'bg-white'
|
||||
)}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
{tab}
|
||||
@@ -163,12 +178,16 @@ export default function Components(): ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
<Select>
|
||||
<option selected disabled>Rating</option>
|
||||
<option selected disabled>
|
||||
Rating
|
||||
</option>
|
||||
<option value="4.5">4.5</option>
|
||||
<option value="5">5</option>
|
||||
</Select>
|
||||
<Select>
|
||||
<option selected disabled>Status</option>
|
||||
<option selected disabled>
|
||||
Status
|
||||
</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</Select>
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
html {
|
||||
font-family: 'Bai Jamjuree', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!session) return redirect('/auth/login');
|
||||
// if (!session) return redirect('/auth/login');
|
||||
|
||||
const matchedRoute = mappingRoutePermissions.find(
|
||||
(route) => route.path === pathname
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { FC, ReactElement, useEffect, useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useAuthStore, supabase } from '@imphnen-frontend-service/service';
|
||||
import { useGitHubCallback } from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const CallbackPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const { setSession } = useAuthStore();
|
||||
const { mutateAsync: exchangeGitHubCode } = useGitHubCallback();
|
||||
const [isProcessing, setIsProcessing] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const hasRunRef = useRef(false);
|
||||
@@ -13,134 +13,68 @@ const CallbackPage: FC = (): ReactElement => {
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
if (hasRunRef.current) {
|
||||
// console.log('[Callback] Already processed, skipping...');
|
||||
return;
|
||||
}
|
||||
hasRunRef.current = true;
|
||||
|
||||
try {
|
||||
// console.log('[Callback] Processing OAuth callback...');
|
||||
// console.log('[Callback] Current URL:', globalThis.location.href);
|
||||
// Check URL hash for Supabase email confirmation callback
|
||||
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
|
||||
const urlParams = new URLSearchParams(globalThis.location.search);
|
||||
|
||||
// Supabase client is configured with detectSessionInUrl: true
|
||||
// This means Supabase automatically detects and processes OAuth tokens from the URL hash
|
||||
// We just need to wait a moment for it to complete, then check for the session
|
||||
const type = hashParams.get('type') || urlParams.get('type');
|
||||
const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
|
||||
|
||||
// console.log('[Callback] Waiting for Supabase to process OAuth callback...');
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
// Debug: log what we received
|
||||
console.log('[Callback] Params:', { type, accessToken: !!accessToken, hash: globalThis.location.hash, search: globalThis.location.search });
|
||||
|
||||
// Get the session that Supabase automatically created from the URL hash
|
||||
const {
|
||||
data: { session: sessionData },
|
||||
error: sessionError,
|
||||
} = await supabase.auth.getSession();
|
||||
// Handle Supabase email callbacks (has access_token in hash or query)
|
||||
// This includes: signup confirmation, email confirmation, password recovery
|
||||
if (accessToken) {
|
||||
setIsProcessing(false);
|
||||
|
||||
if (sessionError) {
|
||||
// console.error('[Callback] Session error:', sessionError);
|
||||
throw new Error(sessionError.message || 'Failed to get session');
|
||||
// Password recovery - type is 'recovery' or we have access_token from reset email
|
||||
if (type === 'recovery' || type === 'magiclink') {
|
||||
toast.success('Email verified! Please set your new password.');
|
||||
navigate('/auth/reset-password?access_token=' + accessToken);
|
||||
return;
|
||||
}
|
||||
|
||||
// Signup/Email confirmation
|
||||
if (type === 'signup' || type === 'email_confirmation') {
|
||||
toast.success('Email verified successfully! Please log in to continue.');
|
||||
navigate('/auth/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have access_token but unknown type, assume it's password recovery
|
||||
// (Supabase sometimes sends without explicit type)
|
||||
toast.success('Email verified! Please set your new password.');
|
||||
navigate('/auth/reset-password?access_token=' + accessToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionData || !sessionData.user) {
|
||||
throw new Error(
|
||||
'No session found after OAuth callback. Please try logging in again.'
|
||||
);
|
||||
// Get the code from URL query params (GitHub OAuth)
|
||||
const code = urlParams.get('code');
|
||||
|
||||
if (!code) {
|
||||
throw new Error('No authorization code received');
|
||||
}
|
||||
|
||||
// console.log('[Callback] Supabase session established:', {
|
||||
// userId: sessionData.user.id,
|
||||
// email: sessionData.user.email,
|
||||
// });
|
||||
// Exchange the code for tokens using backend API (GitHub OAuth)
|
||||
const result = await exchangeGitHubCode({ code });
|
||||
|
||||
// Create/update user in the users table (for foreign key constraints)
|
||||
// console.log('[Callback] Creating/updating user record...');
|
||||
const { data: userData, error: upsertError } = await supabase
|
||||
.from('users')
|
||||
.upsert(
|
||||
{
|
||||
id: sessionData.user.id,
|
||||
email: sessionData.user.email || '',
|
||||
fullname:
|
||||
sessionData.user.user_metadata?.full_name ||
|
||||
sessionData.user.user_metadata?.name ||
|
||||
sessionData.user.email?.split('@')[0] ||
|
||||
'',
|
||||
avatar: sessionData.user.user_metadata?.avatar_url || '',
|
||||
is_active: true,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
onConflict: 'id',
|
||||
}
|
||||
)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (upsertError) {
|
||||
// console.warn('[Callback] Failed to create user record:', upsertError);
|
||||
// Don't throw - continue with login even if user record creation fails
|
||||
} else {
|
||||
// console.log('[Callback] User record created/updated successfully');
|
||||
}
|
||||
|
||||
// Store user-friendly data in Zustand for UI purposes
|
||||
// Supabase now manages the actual auth session
|
||||
// Use data from database if available, otherwise use OAuth metadata
|
||||
const userRecord = userData || {
|
||||
id: sessionData.user.id,
|
||||
email: sessionData.user.email || '',
|
||||
fullname:
|
||||
sessionData.user.user_metadata?.full_name ||
|
||||
sessionData.user.user_metadata?.name ||
|
||||
sessionData.user.email?.split('@')[0] ||
|
||||
'',
|
||||
avatar: sessionData.user.user_metadata?.avatar_url || '',
|
||||
phone_number: '',
|
||||
birthdate: '',
|
||||
gender: '',
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
setSession({
|
||||
token: {
|
||||
access_token: sessionData.access_token,
|
||||
refresh_token: sessionData.refresh_token || '',
|
||||
},
|
||||
user: {
|
||||
id: userRecord.id,
|
||||
email: userRecord.email,
|
||||
fullname: userRecord.fullname,
|
||||
phone_number: userRecord.phone_number || '',
|
||||
avatar: userRecord.avatar || '',
|
||||
birthdate: userRecord.birthdate || '',
|
||||
gender: userRecord.gender || '',
|
||||
is_active: userRecord.is_active,
|
||||
location: userRecord.location,
|
||||
bio: userRecord.bio,
|
||||
skills: userRecord.skills,
|
||||
role: {
|
||||
id: '',
|
||||
name: 'user',
|
||||
permissions: [],
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// console.log('[Callback] Session stored successfully');
|
||||
toast.success('Login successful!');
|
||||
setIsProcessing(false);
|
||||
|
||||
// Check if user has completed onboarding (has location)
|
||||
// Use globalThis.location.replace for hard redirect to prevent history issues
|
||||
if (userRecord.location) {
|
||||
// console.log('[Callback] User has completed onboarding, redirecting to dashboard...');
|
||||
if (result.user.location) {
|
||||
globalThis.location.replace('/dashboard');
|
||||
} else {
|
||||
// console.log('[Callback] User needs onboarding, redirecting...');
|
||||
globalThis.location.replace('/onboarding/user');
|
||||
}
|
||||
} catch (err) {
|
||||
// console.error('[Callback] Error:', err);
|
||||
console.error('[Callback] Error:', err);
|
||||
setError((err as Error).message);
|
||||
setIsProcessing(false);
|
||||
toast.error('An error occurred during login');
|
||||
@@ -153,21 +87,68 @@ const CallbackPage: FC = (): ReactElement => {
|
||||
|
||||
handleCallback();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Run only once on mount
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
// Check if error is related to private email
|
||||
const isPrivateEmailError =
|
||||
error.toLowerCase().includes('failed to create user') ||
|
||||
error.toLowerCase().includes('email') ||
|
||||
error.toLowerCase().includes('user record');
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 px-4">
|
||||
<div className="bg-white w-full max-w-2xl p-8 rounded-2xl shadow-lg border border-red-200">
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 px-4">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-2xl p-8 rounded-2xl shadow-lg border border-red-200 dark:border-red-800">
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-red-500 text-5xl mb-4">⚠️</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
GitHub Login Failed
|
||||
</h2>
|
||||
<p className="text-red-600 mb-4 whitespace-pre-line">{error}</p>
|
||||
<p className="text-red-600 dark:text-red-400 mb-4 whitespace-pre-line">{error}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm mt-6 text-center">
|
||||
{isPrivateEmailError && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-6">
|
||||
<h3 className="font-semibold text-amber-800 dark:text-amber-300 mb-2">
|
||||
Is your GitHub email set to private?
|
||||
</h3>
|
||||
<p className="text-amber-700 dark:text-amber-400 text-sm mb-3">
|
||||
GitHub login requires a public email address. Please follow these steps:
|
||||
</p>
|
||||
<ol className="text-amber-700 dark:text-amber-400 text-sm list-decimal list-inside space-y-1 mb-3">
|
||||
<li>
|
||||
Go to{' '}
|
||||
<a
|
||||
href="https://github.com/settings/emails"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-amber-900 dark:hover:text-amber-200"
|
||||
>
|
||||
GitHub Email Settings
|
||||
</a>
|
||||
</li>
|
||||
<li>Uncheck "Keep my email addresses private"</li>
|
||||
<li>
|
||||
Or go to{' '}
|
||||
<a
|
||||
href="https://github.com/settings/profile"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-amber-900 dark:hover:text-amber-200"
|
||||
>
|
||||
Profile Settings
|
||||
</a>{' '}
|
||||
and set a public email
|
||||
</li>
|
||||
<li>Try signing in with GitHub again</li>
|
||||
</ol>
|
||||
<p className="text-amber-600 dark:text-amber-500 text-xs">
|
||||
Alternatively, you can sign up using email and password instead.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm mt-6 text-center">
|
||||
Redirecting to login page in 3 seconds...
|
||||
</p>
|
||||
</div>
|
||||
@@ -176,13 +157,13 @@ const CallbackPage: FC = (): ReactElement => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50">
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Completing login...
|
||||
</h2>
|
||||
<p className="text-gray-600">Please wait</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">Please wait</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { supabase } from '@imphnen-frontend-service/service';
|
||||
import { useForgotPassword } from '@imphnen-frontend-service/service';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@iconify/react';
|
||||
@@ -7,9 +7,9 @@ import ThemeToggle from '../../../components/theme-toggle';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [emailSent, setEmailSent] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const forgotPasswordMutation = useForgotPassword();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -20,44 +20,33 @@ export default function ForgotPasswordPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${window.location.origin}/auth/reset-password`,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
await forgotPasswordMutation.mutateAsync({ email });
|
||||
|
||||
setEmailSent(true);
|
||||
toast.success('Password reset email sent! Check your inbox.');
|
||||
} catch (err) {
|
||||
// console.error('Failed to send reset email:', err);
|
||||
toast.error((err as Error).message || 'Failed to send reset email');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (emailSent) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
|
||||
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 text-center">
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
|
||||
<div className="mb-6">
|
||||
<div className="mx-auto w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
|
||||
<div className="mx-auto w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<span className="text-3xl">✓</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Check Your Email
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
We've sent a password reset link to <strong>{email}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Click the link in the email to reset your password. The link will
|
||||
expire in 1 hour.
|
||||
</p>
|
||||
@@ -70,7 +59,7 @@ export default function ForgotPasswordPage() {
|
||||
|
||||
<button
|
||||
onClick={() => setEmailSent(false)}
|
||||
className="w-full py-3 text-gray-600 hover:text-gray-900 transition-colors"
|
||||
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
Send another email
|
||||
</button>
|
||||
@@ -116,18 +105,18 @@ export default function ForgotPasswordPage() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
disabled={isLoading}
|
||||
className="bg-white dark:bg-gray-800 w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
disabled={forgotPasswordMutation.isPending}
|
||||
className="bg-white dark:bg-gray-800 text-gray-900 dark:text-white w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
disabled={forgotPasswordMutation.isPending}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Send Reset Link'}
|
||||
{forgotPasswordMutation.isPending ? 'Sending...' : 'Send Reset Link'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
useGitHubAuth,
|
||||
useEmailAuth,
|
||||
supabase,
|
||||
useAuthStore,
|
||||
useLogin,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { GithubOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, Link } from 'react-router';
|
||||
@@ -12,17 +10,34 @@ import { Icon } from '@iconify/react';
|
||||
import { ThemeToggle } from '../../../components/theme-toggle';
|
||||
|
||||
export default function LoginPage() {
|
||||
// console.log('[LoginPage] Rendering...');
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { setSession } = useAuthStore();
|
||||
const { signInWithGitHub } = useGitHubAuth();
|
||||
const { signInWithEmail } = useEmailAuth();
|
||||
const loginMutation = useLogin();
|
||||
const [isGithubLoading, setIsGithubLoading] = useState(false);
|
||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// Check for password reset tokens in URL and redirect to reset-password page
|
||||
useEffect(() => {
|
||||
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
|
||||
const urlParams = new URLSearchParams(globalThis.location.search);
|
||||
|
||||
const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
|
||||
const type = hashParams.get('type') || urlParams.get('type');
|
||||
|
||||
// If we have an access_token, this is likely a password reset redirect that landed on the wrong page
|
||||
if (accessToken) {
|
||||
console.log('[Login] Detected access_token, redirecting to reset-password page');
|
||||
|
||||
// Check if it's a password recovery
|
||||
if (type === 'recovery' || type === 'magiclink' || !type) {
|
||||
toast.info('Redirecting to password reset...');
|
||||
navigate('/auth/reset-password?access_token=' + accessToken);
|
||||
}
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
const handleEmailLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -34,52 +49,12 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
setIsEmailLoading(true);
|
||||
// console.log('[Login] Attempting email login...');
|
||||
|
||||
const result = await signInWithEmail(email, password);
|
||||
// console.log('[Login] Email login successful:', result);
|
||||
|
||||
// Get user data from database
|
||||
const { data: userData } = await supabase
|
||||
.from('users')
|
||||
.select('*')
|
||||
.eq('id', result.user.id)
|
||||
.single();
|
||||
|
||||
// Store session in Zustand
|
||||
setSession({
|
||||
token: {
|
||||
access_token: result.session.access_token,
|
||||
refresh_token: result.session.refresh_token || '',
|
||||
},
|
||||
user: {
|
||||
id: result.user.id,
|
||||
email: result.user.email || '',
|
||||
fullname:
|
||||
userData?.fullname || result.user.user_metadata?.full_name || '',
|
||||
phone_number: userData?.phone_number || '',
|
||||
avatar: userData?.avatar || '',
|
||||
birthdate: userData?.birthdate || '',
|
||||
gender: userData?.gender || '',
|
||||
is_active: userData?.is_active || true,
|
||||
location: userData?.location,
|
||||
bio: userData?.bio,
|
||||
skills: userData?.skills,
|
||||
role: {
|
||||
id: '',
|
||||
name: 'user',
|
||||
permissions: [],
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await loginMutation.mutateAsync({ email, password });
|
||||
|
||||
toast.success('Login successful!');
|
||||
|
||||
// Redirect based on onboarding status
|
||||
if (userData?.location) {
|
||||
if (result.user.location) {
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
navigate('/onboarding/user');
|
||||
@@ -87,29 +62,25 @@ export default function LoginPage() {
|
||||
} catch (err) {
|
||||
console.error('[Login] Email login failed:', err);
|
||||
setError((err as Error).message || 'Login failed');
|
||||
setIsEmailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGithubLogin = async () => {
|
||||
try {
|
||||
setIsGithubLoading(true);
|
||||
// console.log('[Login] Initiating GitHub OAuth...');
|
||||
|
||||
const result = await signInWithGitHub();
|
||||
// console.log('[Login] OAuth result:', result);
|
||||
|
||||
// Check if we got a redirect URL
|
||||
if (result?.url) {
|
||||
// console.log('[Login] Redirecting to GitHub OAuth:', result.url);
|
||||
// Manually redirect immediately
|
||||
globalThis.location.href = result.url;
|
||||
} else {
|
||||
// console.error('[Login] No OAuth URL returned');
|
||||
setIsGithubLoading(false);
|
||||
setError('Failed to get GitHub OAuth URL');
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error('[Login] GitHub login failed');
|
||||
} catch (err) {
|
||||
console.error('[Login] GitHub login failed:', err);
|
||||
setError((err as Error).message || 'GitHub login failed');
|
||||
setIsGithubLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -157,7 +128,7 @@ export default function LoginPage() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
disabled={isEmailLoading}
|
||||
disabled={loginMutation.isPending}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
@@ -178,24 +149,33 @@ export default function LoginPage() {
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={loginMutation.isPending}
|
||||
className="w-full px-4 py-2.5 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<Icon icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isEmailLoading}
|
||||
disabled={loginMutation.isPending}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
>
|
||||
{isEmailLoading ? 'Signing in...' : 'Sign in with Email'}
|
||||
{loginMutation.isPending ? 'Signing in...' : 'Sign in with Email'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -219,15 +199,28 @@ export default function LoginPage() {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p className="mt-3 text-xs text-center text-gray-500 dark:text-gray-500 font-sans">
|
||||
Make sure your GitHub email is{' '}
|
||||
<a
|
||||
href="https://github.com/settings/emails"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 dark:text-primary-400 hover:underline"
|
||||
>
|
||||
set to public
|
||||
</a>{' '}
|
||||
for GitHub sign in to work.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Don't have an account?{' '}
|
||||
<a
|
||||
href="/auth/signup"
|
||||
<Link
|
||||
to="/auth/signup"
|
||||
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
|
||||
>
|
||||
Sign up
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@imphnen-frontend-service/service';
|
||||
import { useResetPassword, useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const navigate = useNavigate();
|
||||
const { clearSession } = useAuthStore();
|
||||
const resetPasswordMutation = useResetPassword();
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isValidToken, setIsValidToken] = useState(false);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
// useEffect(() => {
|
||||
// // Check if we have a valid session (from the reset link)
|
||||
// supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
// if (session) {
|
||||
// setIsValidToken(true);
|
||||
// } else {
|
||||
// toast.error('Invalid or expired reset link');
|
||||
// setTimeout(() => navigate('/auth/forgot-password'), 2000);
|
||||
// }
|
||||
// });
|
||||
// }, [navigate]);
|
||||
useEffect(() => {
|
||||
// Get the access_token from URL hash (Supabase sends it as hash fragment)
|
||||
// or from query params (when redirected from callback page)
|
||||
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
|
||||
const queryParams = new URLSearchParams(globalThis.location.search);
|
||||
const token = hashParams.get('access_token') || queryParams.get('access_token');
|
||||
|
||||
if (token) {
|
||||
setAccessToken(token);
|
||||
} else {
|
||||
toast.error('Invalid or expired reset link');
|
||||
setTimeout(() => navigate('/auth/forgot-password'), 2000);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -35,42 +42,39 @@ export default function ResetPasswordPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
toast.error('Invalid reset token');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const { error } = await supabase.auth.updateUser({
|
||||
password: password,
|
||||
await resetPasswordMutation.mutateAsync({
|
||||
access_token: accessToken,
|
||||
new_password: password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
toast.success('Password updated successfully!');
|
||||
|
||||
// Sign out and redirect to login
|
||||
await supabase.auth.signOut();
|
||||
// Clear session and redirect to login
|
||||
clearSession();
|
||||
navigate('/auth/login');
|
||||
} catch (err) {
|
||||
// console.error('Failed to reset password:', err);
|
||||
toast.error((err as Error).message || 'Failed to reset password');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// if (!isValidToken) {
|
||||
// return (
|
||||
// <div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
// <div className="text-center">
|
||||
// <div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
|
||||
// <p className="text-gray-600 dark:text-gray-400">
|
||||
// Verifying reset link...
|
||||
// </p>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
if (!accessToken) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Verifying reset link...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
@@ -92,17 +96,26 @@ export default function ResetPasswordPage() {
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={resetPasswordMutation.isPending}
|
||||
className="w-full px-4 py-2.5 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<Icon icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -112,25 +125,34 @@ export default function ResetPasswordPage() {
|
||||
>
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={resetPasswordMutation.isPending}
|
||||
className="w-full px-4 py-2.5 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<Icon icon={showConfirmPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
disabled={resetPasswordMutation.isPending}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoading ? 'Updating...' : 'Update Password'}
|
||||
{resetPasswordMutation.isPending ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,142 +1,200 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
useGitHubAuth,
|
||||
useEmailAuth,
|
||||
supabase,
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { useGitHubAuth, useSignup } from '@imphnen-frontend-service/service';
|
||||
import { GithubOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate, Link } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { ThemeToggle } from '../../../components/theme-toggle';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
const signupSchema = z
|
||||
.object({
|
||||
fullname: z
|
||||
.string()
|
||||
.min(1, 'Full name is required')
|
||||
.min(2, 'Full name must be at least 2 characters'),
|
||||
email: z
|
||||
.string()
|
||||
.min(1, 'Email is required')
|
||||
.email('Please enter a valid email address'),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Password is required')
|
||||
.min(6, 'Password must be at least 6 characters'),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type SignupFormData = z.infer<typeof signupSchema>;
|
||||
|
||||
// Registration deadline: 2025-11-30 23:29:00 WIB (UTC+7)
|
||||
const REGISTRATION_DEADLINE = new Date('2025-11-30T16:29:00Z');
|
||||
|
||||
export default function SignupPage() {
|
||||
const navigate = useNavigate();
|
||||
const { setSession } = useAuthStore();
|
||||
const { signInWithGitHub } = useGitHubAuth();
|
||||
const { signUpWithEmail } = useEmailAuth();
|
||||
const [isGithubLoading, setIsGithubLoading] = useState(false);
|
||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||
const [fullname, setFullname] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleEmailSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Check if registration is closed
|
||||
const isRegistrationClosed = new Date() >= REGISTRATION_DEADLINE;
|
||||
const { signInWithGitHub } = useGitHubAuth();
|
||||
const signupMutation = useSignup();
|
||||
const [isGithubLoading, setIsGithubLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [registrationSuccess, setRegistrationSuccess] = useState(false);
|
||||
const [registeredEmail, setRegisteredEmail] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isValid },
|
||||
} = useForm<SignupFormData>({
|
||||
resolver: zodResolver(signupSchema),
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const onSubmit = async (data: SignupFormData) => {
|
||||
setError(null);
|
||||
|
||||
if (!fullname || !email || !password || !confirmPassword) {
|
||||
setError('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsEmailLoading(true);
|
||||
// console.log('[Signup] Attempting email signup...');
|
||||
|
||||
const result = await signUpWithEmail(email, password, fullname);
|
||||
// console.log('[Signup] Email signup successful:', result);
|
||||
|
||||
if (!result.user) {
|
||||
throw new Error('Signup failed - no user returned');
|
||||
}
|
||||
|
||||
// Create user record in database
|
||||
const { error: upsertError } = await supabase.from('users').upsert(
|
||||
{
|
||||
id: result.user.id,
|
||||
email: result.user.email || '',
|
||||
fullname: fullname,
|
||||
is_active: true,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
onConflict: 'id',
|
||||
}
|
||||
);
|
||||
|
||||
if (upsertError) {
|
||||
console.warn('[Signup] Failed to create user record');
|
||||
}
|
||||
|
||||
// If session is available (email confirmation disabled), store it
|
||||
if (result.session) {
|
||||
setSession({
|
||||
token: {
|
||||
access_token: result.session.access_token,
|
||||
refresh_token: result.session.refresh_token || '',
|
||||
},
|
||||
user: {
|
||||
id: result.user.id,
|
||||
email: result.user.email || '',
|
||||
fullname: fullname,
|
||||
phone_number: '',
|
||||
avatar: '',
|
||||
birthdate: '',
|
||||
gender: '',
|
||||
is_active: true,
|
||||
role: {
|
||||
id: '',
|
||||
name: 'user',
|
||||
permissions: [],
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
toast.success('Account created successfully!');
|
||||
navigate('/onboarding/user');
|
||||
} else {
|
||||
// Email confirmation is enabled
|
||||
toast.success(
|
||||
'Account created! Please check your email to verify your account.'
|
||||
);
|
||||
setTimeout(() => {
|
||||
navigate('/auth/login');
|
||||
}, 2000);
|
||||
}
|
||||
const result = await signupMutation.mutateAsync({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
fullname: data.fullname,
|
||||
});
|
||||
toast.success(result.message);
|
||||
setRegisteredEmail(data.email);
|
||||
setRegistrationSuccess(true);
|
||||
} catch (err) {
|
||||
// console.error('[Signup] Email signup failed:', err);
|
||||
console.error('[Signup] Email signup failed:', err);
|
||||
setError((err as Error).message || 'Signup failed');
|
||||
setIsEmailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Show closed registration screen
|
||||
if (isRegistrationClosed) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
|
||||
<div className="mb-6">
|
||||
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<Icon
|
||||
icon="mdi:clock-alert"
|
||||
className="text-3xl text-red-600 dark:text-red-400"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Registration Closed
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
The registration period for this hackathon has ended.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Thank you for your interest! Registration closed on November 30, 2025 at 23:29 WIB.
|
||||
</p>
|
||||
|
||||
<Link to="/auth/login">
|
||||
<button className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer">
|
||||
Go to Login
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Back to Homepage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show success screen after registration
|
||||
if (registrationSuccess) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
|
||||
<div className="mb-6">
|
||||
<div className="mx-auto w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<Icon
|
||||
icon="mdi:email-check"
|
||||
className="text-3xl text-green-600 dark:text-green-400"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Check Your Email
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
We've sent an activation link to{' '}
|
||||
<strong>{registeredEmail}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Click the link in the email to activate your account. The link
|
||||
will expire in 24 hours.
|
||||
</p>
|
||||
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
Don't forget to check your spam folder if you don't see the
|
||||
email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link to="/auth/login">
|
||||
<button className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer">
|
||||
Go to Login
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => setRegistrationSuccess(false)}
|
||||
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Register with different email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleGithubLogin = async () => {
|
||||
try {
|
||||
setIsGithubLoading(true);
|
||||
// console.log('[Signup] Initiating GitHub OAuth...');
|
||||
|
||||
const result = await signInWithGitHub();
|
||||
// console.log('[Signup] OAuth result:', result);
|
||||
|
||||
if (result?.url) {
|
||||
// console.log('[Signup] Redirecting to GitHub OAuth:', result.url);
|
||||
globalThis.location.href = result.url;
|
||||
} else {
|
||||
// console.error('[Signup] No OAuth URL returned');
|
||||
setIsGithubLoading(false);
|
||||
setError('Failed to get GitHub OAuth URL');
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error('[Signup] GitHub login failed:', error);
|
||||
} catch (err) {
|
||||
console.error('[Signup] GitHub login failed:', err);
|
||||
setError((err as Error).message || 'GitHub login failed');
|
||||
setIsGithubLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputBaseClass =
|
||||
'w-full px-4 py-2.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed';
|
||||
const inputErrorClass = 'border-red-500 dark:border-red-500';
|
||||
const inputNormalClass = 'border-gray-300 dark:border-gray-600';
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
@@ -166,7 +224,7 @@ export default function SignupPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleEmailSignup} className="space-y-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="fullname"
|
||||
@@ -177,13 +235,18 @@ export default function SignupPage() {
|
||||
<input
|
||||
id="fullname"
|
||||
type="text"
|
||||
value={fullname}
|
||||
onChange={(e) => setFullname(e.target.value)}
|
||||
{...register('fullname')}
|
||||
placeholder="John Doe"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
disabled={signupMutation.isPending}
|
||||
className={`${inputBaseClass} ${
|
||||
errors.fullname ? inputErrorClass : inputNormalClass
|
||||
}`}
|
||||
/>
|
||||
{errors.fullname && (
|
||||
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
|
||||
{errors.fullname.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -196,13 +259,18 @@ export default function SignupPage() {
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
{...register('email')}
|
||||
placeholder="your@email.com"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
disabled={signupMutation.isPending}
|
||||
className={`${inputBaseClass} ${
|
||||
errors.email ? inputErrorClass : inputNormalClass
|
||||
}`}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -212,16 +280,30 @@ export default function SignupPage() {
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
{...register('password')}
|
||||
placeholder="••••••••"
|
||||
disabled={signupMutation.isPending}
|
||||
className={`${inputBaseClass} pr-12 ${
|
||||
errors.password ? inputErrorClass : inputNormalClass
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<Icon icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -231,24 +313,40 @@ export default function SignupPage() {
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
{...register('confirmPassword')}
|
||||
placeholder="••••••••"
|
||||
disabled={signupMutation.isPending}
|
||||
className={`${inputBaseClass} pr-12 ${
|
||||
errors.confirmPassword ? inputErrorClass : inputNormalClass
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<Icon icon={showConfirmPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
|
||||
{errors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isEmailLoading}
|
||||
disabled={!isValid || signupMutation.isPending}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
>
|
||||
{isEmailLoading ? 'Creating account...' : 'Create Account'}
|
||||
{signupMutation.isPending
|
||||
? 'Creating account...'
|
||||
: 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -272,15 +370,28 @@ export default function SignupPage() {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p className="mt-3 text-xs text-center text-gray-500 dark:text-gray-500 font-sans">
|
||||
Make sure your GitHub email is{' '}
|
||||
<a
|
||||
href="https://github.com/settings/emails"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 dark:text-primary-400 hover:underline"
|
||||
>
|
||||
set to public
|
||||
</a>{' '}
|
||||
for GitHub sign up to work.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Already have an account?{' '}
|
||||
<a
|
||||
href="/auth/login"
|
||||
<Link
|
||||
to="/auth/login"
|
||||
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { FC, ReactElement, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import {
|
||||
useMyTeams,
|
||||
@@ -9,16 +9,55 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Icon } from '@iconify/react';
|
||||
import ProfilePage from '../profile/page';
|
||||
|
||||
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
||||
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
||||
|
||||
type Invitation = {
|
||||
id: string;
|
||||
team: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
logo?: string;
|
||||
banner?: string;
|
||||
description?: string;
|
||||
city?: string;
|
||||
visibility?: string;
|
||||
leader_id?: string;
|
||||
};
|
||||
inviter: {
|
||||
id?: string;
|
||||
fullname?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const DashboardPage: FC = (): ReactElement => {
|
||||
const { session } = useAuthStore();
|
||||
const [showProfileModal, setShowProfileModal] = useState(false);
|
||||
|
||||
// Check if team features are closed
|
||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||
// Lock background scroll when profile modal is open
|
||||
useEffect(() => {
|
||||
if (showProfileModal) {
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = originalOverflow || '';
|
||||
};
|
||||
}
|
||||
}, [showProfileModal]);
|
||||
const { data: teamsData } = useMyTeams();
|
||||
const { data: invitationsData } = useMyInvitations();
|
||||
const { mutateAsync: respondToInvitation } = useRespondToInvitation();
|
||||
|
||||
const user = session?.user;
|
||||
const myTeams = teamsData?.data || [];
|
||||
const invitations = invitationsData?.data || [];
|
||||
const invitations: Invitation[] = (invitationsData?.data ||
|
||||
[]) as Invitation[];
|
||||
|
||||
const handleAcceptInvitation = async (invitationId: string) => {
|
||||
try {
|
||||
@@ -41,225 +80,255 @@ const DashboardPage: FC = (): ReactElement => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 md:max-w-7xl w-full mx-auto overflow-hidden">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Welcome, {user?.fullname || user?.email?.split('@')[0] || 'User'}!
|
||||
</h1>
|
||||
{user?.location && (
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1 font-sans flex items-center space-x-1">
|
||||
<Icon icon="heroicons:map-pin-16-solid" width="24" height="24" />
|
||||
<span>{user.location}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{invitations.length > 0 && (
|
||||
<div className="mb-8 bg-primary-50 dark:bg-blue-900/20 border border-primary-200 dark:border-blue-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Team Invitations ({invitations.length})
|
||||
</h2>
|
||||
<div className="space-y-3 font-sans">
|
||||
{invitations.map((invitation) => (
|
||||
<div
|
||||
key={invitation.id}
|
||||
className="bg-white dark:bg-gray-900 p-4 rounded-lg shadow-sm flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{invitation.team.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Invited by {invitation.inviter.fullname}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAcceptInvitation(invitation.id)}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleRejectInvitation(invitation.id)}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{myTeams.length > 0 ? (
|
||||
<div className="mb-6 md:mb-8">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
My Team
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:gap-6 w-full md:max-w-lg">
|
||||
{myTeams.map((team) => (
|
||||
<Link
|
||||
key={team.id}
|
||||
to={'/teams/' + team.id}
|
||||
className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow"
|
||||
>
|
||||
{team.banner && (
|
||||
<div className="w-full aspect-3/1">
|
||||
<img
|
||||
src={team.banner}
|
||||
alt={team.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col md:flex-row items-center space-y-3 space-x-3 -mt-14 md:mt-0 mb-2 md:mb-3 min-w-12 min-h-12 ">
|
||||
{team.logo && (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-20 h-20 rounded-full object-cover shrink-0 border-black dark:border-gray-700"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-xl md:text-lg font-bold text-gray-900 dark:text-white leading-tight">
|
||||
{team.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 flex items-center">
|
||||
<Icon
|
||||
icon="heroicons:map-pin-16-solid"
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
<span className="ml-1">{team.city}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400 line-clamp-3 text-ellipsis font-sans">
|
||||
{team.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-6 md:mb-8 bg-white dark:bg-gray-900 rounded-lg shadow-md p-6 md:p-8">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl md:text-4xl mb-2 md:mb-3">👋</div>
|
||||
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-1 md:mb-2">
|
||||
You are not in a team yet
|
||||
</h2>
|
||||
<p className="text-sm md:text-base text-gray-600 dark:text-gray-400 font-sans">
|
||||
Use the sidebar to browse teams or create your own
|
||||
<>
|
||||
<div className="p-6 md:p-8 md:max-w-7xl w-full mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Welcome, {user?.fullname || user?.email?.split('@')[0] || 'User'}!
|
||||
</h1>
|
||||
{user?.location && (
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1 font-sans flex items-center space-x-1">
|
||||
<Icon icon="heroicons:map-pin-16-solid" width="24" height="24" />
|
||||
<span>{user.location}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 bg-white dark:bg-gray-900 rounded-xl shadow-lg">
|
||||
<div className="bg-linear-to-r from-primary-600 to-primary-500 h-24 rounded-t-xl"></div>
|
||||
<div className="px-4 md:px-8 pb-4 md:pb-8 max-w-7xl">
|
||||
<div className="flex flex-col md:flex-row md:items-start -mt-12 mb-4 md:mb-6">
|
||||
<div className="flex flex-col md:flex-row items-start">
|
||||
{user?.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.fullname || 'User'}
|
||||
className="w-20 h-20 md:w-24 md:h-24 rounded-full border-4 border-white dark:border-gray-700 shadow-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 md:w-24 md:h-24 rounded-full border-4 border-white dark:border-gray-700 shadow-lg bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-gray-400 dark:text-gray-500 text-3xl md:text-4xl font-sans">
|
||||
U
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="md:ml-6 mt-4 md:mt-14">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{user?.fullname ||
|
||||
user?.email?.split('@')[0] ||
|
||||
'Unnamed User'}
|
||||
</h2>
|
||||
|
||||
{user?.location ? (
|
||||
<p className="text-gray-600 dark:text-gray-400 flex items-center mt-1 text-sm md:text-base font-sans">
|
||||
<Icon
|
||||
icon="heroicons:map-pin-16-solid"
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
<span className="ml-1">{user.location}</span>
|
||||
</p>
|
||||
) : (
|
||||
<Link
|
||||
to="/onboarding/user"
|
||||
className="text-primary-500 dark:text-blue-400 hover:text-primary-600 dark:hover:text-blue-300 text-sm md:text-sm mt-1 flex items-center"
|
||||
>
|
||||
<span className="font-sans">Complete your profile</span>
|
||||
<Icon
|
||||
icon="ic:baseline-chevron-right"
|
||||
width="24"
|
||||
height="24"
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{user?.location && (
|
||||
<Link
|
||||
to="/profile"
|
||||
className="mt-3 md:mt-14 md:ml-auto inline-flex items-center justify-center px-3 py-1.5 md:px-4 md:py-2 bg-primary-500 text-white rounded-lg text md:text-sm font-medium shadow-sm hover:bg-primary-600 transition-colors w-full md:w-auto"
|
||||
>
|
||||
Edit Profile
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4 md:space-y-6 w-full max-w-full">
|
||||
{user?.bio && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm w-full max-w-full overflow-x-hidden">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
|
||||
About
|
||||
</h3>
|
||||
<p className="text-gray-800 dark:text-gray-200 leading-relaxed line-clamp-3 wrap-break-word break-all overflow-hidden w-full max-w-full">
|
||||
{user.bio}
|
||||
{invitations.length > 0 && (
|
||||
<div className="mb-8 bg-primary-50 dark:bg-blue-900/20 border border-primary-200 dark:border-blue-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Team Invitations ({invitations.length})
|
||||
</h2>
|
||||
{isTeamFeaturesClosed && (
|
||||
<div className="mb-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300 flex items-center gap-2">
|
||||
<Icon icon="mdi:clock-alert" className="text-lg shrink-0" />
|
||||
Team features are closed. You can no longer accept invitations.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm">
|
||||
<h3 className="font-semibold text-gray-700 dark:text-gray-300 tracking-wide mb-3">
|
||||
Contact
|
||||
</h3>
|
||||
<div className="flex items-center text-gray-700 dark:text-gray-300">
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{user?.email}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3 font-sans">
|
||||
{invitations.map((invitation) => (
|
||||
<div
|
||||
key={invitation.id}
|
||||
className="bg-white dark:bg-gray-900 p-4 rounded-lg shadow-sm flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{invitation.team?.name ?? 'Unnamed Team'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Invited by{' '}
|
||||
{invitation.inviter?.fullname ?? 'Unknown User'}
|
||||
</p>
|
||||
</div>
|
||||
{!isTeamFeaturesClosed && (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAcceptInvitation(invitation.id)}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleRejectInvitation(invitation.id)}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{user?.skills && user.skills.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm">
|
||||
<h3 className="font-semibold text-gray-700 dark:text-gray-300 tracking-wide mb-3">
|
||||
Skills
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{user.skills.map((skill: string) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="inline-flex items-center px-4 py-2 border border-primary-600 dark:border-gray-500 text-primary-600 dark:text-white rounded-4xl text-xs font-medium dark:bg-gray-600"
|
||||
>
|
||||
{skill}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{myTeams.length > 0 ? (
|
||||
(() => {
|
||||
const team = myTeams[0] as any;
|
||||
return (
|
||||
<div className="mb-6 md:mb-8">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
My Team
|
||||
</h2>
|
||||
<Link
|
||||
to={'/teams/' + team.id}
|
||||
className="block bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow border dark:border-gray-700"
|
||||
>
|
||||
<img
|
||||
src={team.banner || '/images/banner-imphnen.webp'}
|
||||
alt={team.name}
|
||||
className="w-full aspect-3/1 object-cover"
|
||||
/>
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="flex items-center space-x-4 mb-4">
|
||||
{team.logo ? (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-16 h-16 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0">
|
||||
<Icon icon="mdi:account-group" className="text-gray-500 dark:text-gray-400 text-2xl" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white leading-tight line-clamp-1">
|
||||
{team.name}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-3 font-sans mt-2">
|
||||
{team.has_submission && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0">
|
||||
<Icon icon="mdi:check-circle" className="text-sm" />
|
||||
Submitted
|
||||
</span>
|
||||
)}
|
||||
{team.city && (
|
||||
<span className="flex items-center gap-1 truncate">
|
||||
<Icon icon="mdi:map-marker" className="shrink-0" />
|
||||
<span className="truncate">{team.city}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
<Icon icon="mdi:account-group" />
|
||||
{team.member_count || team.members?.length || 0} member{(team.member_count || team.members?.length || 0) !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{team.description && (
|
||||
<p className="text-gray-600 dark:text-gray-400 line-clamp-3 font-sans">
|
||||
{team.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<div className="mb-6 md:mb-8 bg-white dark:bg-gray-900 rounded-lg shadow-md p-6 md:p-8">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl md:text-4xl mb-2 md:mb-3">👋</div>
|
||||
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-1 md:mb-2">
|
||||
You are not in a team yet
|
||||
</h2>
|
||||
<p className="text-sm md:text-base text-gray-600 dark:text-gray-400 font-sans">
|
||||
Use the sidebar to browse teams or create your own
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 bg-white dark:bg-gray-900 rounded-xl shadow-lg">
|
||||
<div className="bg-linear-to-r from-primary-600 to-primary-500 h-24 rounded-t-xl"></div>
|
||||
<div className="px-4 md:px-8 pb-4 md:pb-8 max-w-7xl">
|
||||
<div className="flex flex-col md:flex-row md:items-start -mt-12 mb-4 md:mb-6">
|
||||
<div className="flex flex-col md:flex-row items-start">
|
||||
{user?.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.fullname || 'User'}
|
||||
className="w-20 h-20 md:w-24 md:h-24 rounded-full border-4 border-white dark:border-gray-700 shadow-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 md:w-24 md:h-24 rounded-full border-4 border-white dark:border-gray-700 shadow-lg bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-gray-400 dark:text-gray-500 text-3xl md:text-4xl font-sans">
|
||||
U
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="md:ml-6 mt-4 md:mt-14">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{user?.fullname ||
|
||||
user?.email?.split('@')[0] ||
|
||||
'Unnamed User'}
|
||||
</h2>
|
||||
|
||||
{user?.location ? (
|
||||
<p className="text-gray-600 dark:text-gray-400 flex items-center mt-1 text-sm md:text-base font-sans">
|
||||
<Icon
|
||||
icon="heroicons:map-pin-16-solid"
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
<span className="ml-1">{user.location}</span>
|
||||
</p>
|
||||
) : (
|
||||
<Link
|
||||
to="/onboarding/user"
|
||||
className="text-primary-500 dark:text-blue-400 hover:text-primary-600 dark:hover:text-blue-300 text-sm md:text-sm mt-1 flex items-center"
|
||||
>
|
||||
<span className="font-sans">Complete your profile</span>
|
||||
<Icon
|
||||
icon="ic:baseline-chevron-right"
|
||||
width="24"
|
||||
height="24"
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{user?.location && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowProfileModal(true)}
|
||||
className="mt-3 md:mt-14 md:ml-auto inline-flex items-center justify-center px-3 py-1.5 md:px-4 md:py-2 bg-primary-500 text-white rounded-lg text md:text-sm font-medium shadow-sm hover:bg-primary-600 transition-colors w-full md:w-auto cursor-pointer"
|
||||
>
|
||||
Edit Profile
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4 md:space-y-6 w-full max-w-full">
|
||||
{user?.bio && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm w-full max-w-full overflow-x-hidden">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
|
||||
About
|
||||
</h3>
|
||||
<p className="text-gray-800 dark:text-gray-200 leading-relaxed line-clamp-3 wrap-break-word break-all overflow-hidden w-full max-w-full">
|
||||
{user.bio}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm">
|
||||
<h3 className="font-semibold text-gray-700 dark:text-gray-300 tracking-wide mb-3">
|
||||
Contact
|
||||
</h3>
|
||||
<div className="flex items-center text-gray-700 dark:text-gray-300">
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{user?.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{user?.skills && user.skills.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm">
|
||||
<h3 className="font-semibold text-gray-700 dark:text-gray-300 tracking-wide mb-3">
|
||||
Skills
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{user.skills.map((skill: string) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="inline-flex items-center px-4 py-2 border border-primary-600 dark:border-gray-500 text-primary-600 dark:text-white rounded-4xl text-xs font-medium dark:bg-gray-600"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProfilePage
|
||||
open={showProfileModal}
|
||||
onClose={() => setShowProfileModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Outlet, ScrollRestoration, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Outlet,
|
||||
ScrollRestoration,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } from '@imphnen-frontend-service/service';
|
||||
import { useAuthStore, useUserMe } from '@imphnen-frontend-service/service';
|
||||
|
||||
// Define onboarding routes
|
||||
const ONBOARDING_ROUTES = new Set(['/onboarding/user']);
|
||||
@@ -8,6 +13,8 @@ const ONBOARDING_ROUTES = new Set(['/onboarding/user']);
|
||||
export default function RootLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
const { data: userData, isLoading: isUserLoading } = useUserMe();
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -26,24 +33,15 @@ export default function RootLayout() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check Supabase session
|
||||
const { data: { session }, error } = await supabase.auth.getSession();
|
||||
|
||||
if (error) {
|
||||
console.error('[Layout] Session error:', error);
|
||||
}
|
||||
|
||||
// Public auth pages (login, signup, forgot-password, reset-password) - allow unauthenticated access
|
||||
const isPublicAuthPage = pathname === '/auth/login' || pathname === '/auth/signup' || pathname === '/auth/forgot-password' || pathname === '/auth/reset-password';
|
||||
|
||||
if (isPublicAuthPage) {
|
||||
// Public auth pages - allow unauthenticated access
|
||||
if (pathname.startsWith('/auth')) {
|
||||
// If already authenticated and not on password reset pages, redirect to dashboard
|
||||
if (session && pathname !== '/auth/reset-password') {
|
||||
navigate('/dashboard', { replace: true });
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
// Allow unauthenticated access
|
||||
// Allow unauthenticated access to auth pages
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
@@ -61,30 +59,19 @@ export default function RootLayout() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for user data to load before checking onboarding
|
||||
if (isUserLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has completed onboarding (skip for onboarding routes)
|
||||
if (!ONBOARDING_ROUTES.has(pathname)) {
|
||||
try {
|
||||
const { data: userData, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('location')
|
||||
.eq('id', session.user.id)
|
||||
.single();
|
||||
const hasLocation = !!userData?.data?.location || !!session?.user?.location;
|
||||
|
||||
if (userError) {
|
||||
console.error('[Layout] Failed to fetch user data:', userError);
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasLocation = !!userData?.location;
|
||||
|
||||
if (!hasLocation) {
|
||||
navigate('/onboarding/user', { replace: true });
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently handle error
|
||||
if (!hasLocation) {
|
||||
navigate('/onboarding/user', { replace: true });
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +79,7 @@ export default function RootLayout() {
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [location.pathname, navigate]);
|
||||
}, [location.pathname, navigate, session, userData, isUserLoading]);
|
||||
|
||||
// Show loading state while checking auth
|
||||
if (isChecking) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
export default function MaintenancePage() {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 px-4">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-bold text-yellow-600 mb-4">🚧</h1>
|
||||
<h2 className="text-3xl font-semibold mb-2">We'll be back soon!</h2>
|
||||
<p className="text-gray-600">
|
||||
Our site is currently undergoing scheduled maintenance.
|
||||
<br />
|
||||
Thank you for your patience.
|
||||
</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="mt-4 inline-block text-primary-600 hover:underline"
|
||||
>
|
||||
Back to Homepage
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,20 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file size (max 2MB)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error('The file is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('The file must be an image');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
setAvatarFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
@@ -150,7 +164,7 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2 text-center">
|
||||
Optional, but highly recommended
|
||||
Optional, but highly recommended. Max 2MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate, Link } from 'react-router';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Icon } from '@iconify/react';
|
||||
@@ -187,12 +187,12 @@ export default function HomePage() {
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<a
|
||||
href="#masuk"
|
||||
className="ms-3 text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white"
|
||||
<button
|
||||
onClick={() => navigate('/auth/login')}
|
||||
className="ms-3 text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white cursor-pointer"
|
||||
>
|
||||
Masuk
|
||||
</a>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/auth/login')}
|
||||
className="px-4 py-2 bg-primary-500 text-white text-base rounded-lg hover:bg-primary-600 transition-colors text-center cursor-pointer"
|
||||
@@ -796,12 +796,12 @@ export default function HomePage() {
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/auth/signup"
|
||||
<Link
|
||||
to="/auth/signup"
|
||||
className="hover:text-white transition-colors"
|
||||
>
|
||||
Daftar
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { FC, ReactElement, useState, useEffect } from 'react';
|
||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useNavigate, Link } from 'react-router';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import {
|
||||
userEditProfileSchema,
|
||||
@@ -26,8 +25,15 @@ const ROLE_OPTIONS = [
|
||||
'Mobile Developer',
|
||||
];
|
||||
|
||||
const ProfilePage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
type ProfileModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const ProfilePage: FC<ProfileModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
}): ReactElement | null => {
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string>('');
|
||||
|
||||
@@ -78,15 +84,17 @@ const ProfilePage: FC = (): ReactElement => {
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('The file is too large. Maximum 5MB');
|
||||
// Validate file size (max 2MB)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error('The file is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('The file must be an image');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -123,8 +131,8 @@ const ProfilePage: FC = (): ReactElement => {
|
||||
// Wait a bit for the onSuccess handler to update localStorage
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Navigate back to dashboard
|
||||
navigate('/dashboard');
|
||||
// Close modal
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Profile update failed:', error);
|
||||
toast.error(
|
||||
@@ -137,65 +145,23 @@ const ProfilePage: FC = (): ReactElement => {
|
||||
|
||||
const isLoading = isUpdating || isUploading;
|
||||
|
||||
return (
|
||||
<div className=" bg-black/30 dark:bg-black/50 backdrop-blur-sm flex flex-col justify-center items-center px-4 py-8 z-50 overflow-y-auto">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-xl border dark:boder-gray-800">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Edit Profile
|
||||
</h1>
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="text-gray-500 dark:text-neutral-400 hover:text-gray-700 dark:hover:text-neutral-200"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-gray-600 font-sans dark:text-neutral-400">
|
||||
Update your photo and name
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
{/* Avatar Upload */}
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="relative group">
|
||||
{avatarPreview ? (
|
||||
<img
|
||||
src={avatarPreview}
|
||||
alt="Avatar preview"
|
||||
className="w-32 h-32 rounded-full object-cover border-4 border-gray-200 dark:border-neutral-700 group-hover:border-blue-400 dark:group-hover:border-blue-500 transition-colors"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-32 h-32 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center group-hover:bg-gray-300 dark:group-hover:bg-gray-600 transition-colors">
|
||||
<Icon
|
||||
icon="ic:baseline-person"
|
||||
width="48"
|
||||
height="48"
|
||||
className="text-gray-400 dark:text-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Camera overlay */}
|
||||
<label
|
||||
htmlFor="avatar"
|
||||
className="absolute bottom-0 right-0 bg-primary-500 text-white p-2 rounded-full cursor-pointer hover:bg-primary-600 transition-colors shadow-lg"
|
||||
return open ? (
|
||||
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-md z-50 overflow-y-auto">
|
||||
<div className="min-h-full flex items-center justify-center">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-md mx-4 my-6 sm:my-8 p-8 rounded-xl border dark:boder-gray-800">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Edit Profile
|
||||
</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-500 dark:text-neutral-400 hover:text-gray-700 dark:hover:text-neutral-200 cursor-pointer"
|
||||
aria-label="Close profile modal"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -204,176 +170,222 @@ const ProfilePage: FC = (): ReactElement => {
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
id="avatar"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleAvatarChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-neutral-400 text-center font-sans">
|
||||
Click the camera icon to change your photo
|
||||
<br />
|
||||
Format: JPG, PNG. Max 5MB
|
||||
<p className="text-gray-600 font-sans dark:text-neutral-400">
|
||||
Update your photo and name
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Full Name */}
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Full Name"
|
||||
placeholder="Enter your full name"
|
||||
name="fullname"
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{/* City */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-label1 font-medium text-neutral-800 dark:text-neutral-300">
|
||||
City
|
||||
</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="location"
|
||||
render={({ field, fieldState }) => (
|
||||
<CitySelect
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
error={fieldState.error?.message}
|
||||
placeholder="Search your city..."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role/Skills */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">
|
||||
Role / Skills
|
||||
</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="skills"
|
||||
render={({ field }) => (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<label
|
||||
key={role}
|
||||
className="flex items-center space-x-2 cursor-pointer font-sans"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.value?.includes(role)}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.checked
|
||||
? [...(field.value || []), role]
|
||||
: (field.value || []).filter((v) => v !== role);
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
className="rounded border-gray-300 dark:border-neutral-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-800"
|
||||
/>
|
||||
<span className="text-sm dark:text-neutral-300">
|
||||
{role}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bio */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">
|
||||
Bio{' '}
|
||||
<span className="text-gray-400 dark:text-neutral-500">
|
||||
(Optional)
|
||||
</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="bio"
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="Tell us about yourself..."
|
||||
rows={4}
|
||||
className="w-full font-sans"
|
||||
size="lg"
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
{/* Avatar Upload */}
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="relative group">
|
||||
{avatarPreview ? (
|
||||
<img
|
||||
src={avatarPreview}
|
||||
alt="Avatar preview"
|
||||
className="w-24 h-24 rounded-full object-cover border-4 border-gray-200 dark:border-neutral-700 group-hover:border-blue-400 dark:group-hover:border-blue-500 transition-colors"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<p className="text-sm text-red-500 mt-1">
|
||||
{fieldState.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => navigate('/dashboard')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center">
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center group-hover:bg-gray-300 dark:group-hover:bg-gray-600 transition-colors">
|
||||
<Icon
|
||||
icon="ic:baseline-person"
|
||||
width="48"
|
||||
height="48"
|
||||
className="text-gray-400 dark:text-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Camera overlay */}
|
||||
<label
|
||||
htmlFor="avatar"
|
||||
className="absolute bottom-0 right-0 bg-primary-500 text-white p-2 rounded-full cursor-pointer hover:bg-primary-600 transition-colors shadow-lg"
|
||||
>
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
Saving...
|
||||
<input
|
||||
id="avatar"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleAvatarChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-neutral-400 text-center font-sans">
|
||||
Click the camera icon to change your photo
|
||||
<br />
|
||||
Format: JPG, PNG. Max 2MB
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Full Name */}
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Full Name"
|
||||
placeholder="Enter your full name"
|
||||
name="fullname"
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{/* City */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-label1 font-medium text-neutral-800 dark:text-neutral-300">
|
||||
City
|
||||
</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="location"
|
||||
render={({ field, fieldState }) => (
|
||||
<CitySelect
|
||||
value={field.value ?? ''}
|
||||
onChange={field.onChange}
|
||||
error={fieldState.error?.message}
|
||||
placeholder="Search your city..."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role/Skills */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">
|
||||
Role / Skills
|
||||
</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="skills"
|
||||
render={({ field }) => (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<label
|
||||
key={role}
|
||||
className="flex items-center space-x-2 cursor-pointer font-sans"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.value?.includes(role)}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.checked
|
||||
? [...(field.value || []), role]
|
||||
: (field.value || []).filter((v) => v !== role);
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
className="rounded border-gray-300 dark:border-neutral-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-800"
|
||||
/>
|
||||
<span className="text-sm dark:text-neutral-300">
|
||||
{role}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bio */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">
|
||||
Bio{' '}
|
||||
<span className="text-gray-400 dark:text-neutral-500">
|
||||
(Optional)
|
||||
</span>
|
||||
) : (
|
||||
'Save'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="bio"
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="Tell us about yourself..."
|
||||
rows={4}
|
||||
className="w-full font-sans"
|
||||
size="lg"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<p className="text-sm text-red-500 mt-1">
|
||||
{fieldState.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Saving...
|
||||
</span>
|
||||
) : (
|
||||
'Save'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
|
||||
@@ -16,6 +16,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
import { CitySelect } from '../../../../components/city-select';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
const EditTeamPage: FC = (): ReactElement => {
|
||||
const { teamId } = useParams<{ teamId: string }>();
|
||||
@@ -85,6 +88,11 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error('Logo image is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
setLogoFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
@@ -97,6 +105,11 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error('Banner image is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
setBannerFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
@@ -183,7 +196,7 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
Click to upload banner
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
1200x400 recommended
|
||||
1200x400 recommended. Max 2MB
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
@@ -215,32 +228,35 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col md:flex-row items-start justify-start gap-1">
|
||||
<label htmlFor="logo" className="cursor-pointer">
|
||||
<span className="px-4 py-2 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700 inline-block">
|
||||
{logoPreview ? 'Change Logo' : 'Upload Logo'}
|
||||
</span>
|
||||
<input
|
||||
id="logo"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleLogoChange}
|
||||
/>
|
||||
</label>
|
||||
{logoPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLogoFile(null);
|
||||
setLogoPreview('');
|
||||
form.setValue('logo', null);
|
||||
}}
|
||||
className="px-4 py-2 text-sm bg-danger-600 text-white rounded-lg hover:bg-danger-700 cursor-pointer"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<div className="flex flex-col md:flex-row items-start justify-start gap-1">
|
||||
<label htmlFor="logo" className="cursor-pointer">
|
||||
<span className="px-4 py-2 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700 inline-block">
|
||||
{logoPreview ? 'Change Logo' : 'Upload Logo'}
|
||||
</span>
|
||||
<input
|
||||
id="logo"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleLogoChange}
|
||||
/>
|
||||
</label>
|
||||
{logoPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLogoFile(null);
|
||||
setLogoPreview('');
|
||||
form.setValue('logo', null);
|
||||
}}
|
||||
className="px-4 py-2 text-sm bg-danger-600 text-white rounded-lg hover:bg-danger-700 cursor-pointer"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">Max 2MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,7 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
: [];
|
||||
const currentUserId = session?.user?.id;
|
||||
const isLeader = currentUserId === team?.leader_id;
|
||||
const hasSubmission = team?.has_submission;
|
||||
|
||||
const form = useForm<TInviteMemberForm>({
|
||||
resolver: zodResolver(inviteMemberSchema),
|
||||
@@ -133,7 +134,11 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button onClick={() => setShowInviteModal(true)}>
|
||||
<Button
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
disabled={hasSubmission}
|
||||
title={hasSubmission ? 'Cannot invite members after project submission' : undefined}
|
||||
>
|
||||
Invite Member
|
||||
</Button>
|
||||
<Button
|
||||
@@ -148,6 +153,23 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
{/* Submission Lock Warning */}
|
||||
{hasSubmission && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-2xl">🔒</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-amber-900 dark:text-amber-100">
|
||||
Team Locked
|
||||
</h3>
|
||||
<p className="text-amber-800 dark:text-amber-200 text-sm font-sans mt-1">
|
||||
Your team has submitted a project. You cannot add or remove members after submission to maintain competition integrity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Join Requests */}
|
||||
{joinRequests.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-6">
|
||||
@@ -196,7 +218,8 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleApproveRequest(request.id)}
|
||||
disabled={isResponding}
|
||||
disabled={isResponding || hasSubmission}
|
||||
title={hasSubmission ? 'Cannot accept members after project submission' : undefined}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
@@ -272,7 +295,7 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{member.role !== 'leader' && (
|
||||
{member.role !== 'leader' && !hasSubmission && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@@ -289,12 +312,14 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<strong>Note:</strong> Members cannot leave the team without your
|
||||
approval. Only you can remove members from the team.
|
||||
</p>
|
||||
</div>
|
||||
{!hasSubmission && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<strong>Note:</strong> Members cannot leave the team without your
|
||||
approval. Only you can remove members from the team.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invite Member Modal */}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
useInviteMember,
|
||||
useTeamJoinRequests,
|
||||
useRespondToJoinRequest,
|
||||
useLeaveTeam,
|
||||
useDeleteTeam,
|
||||
ETeamMemberRole,
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
@@ -15,6 +17,9 @@ import { Icon } from '@iconify/react';
|
||||
|
||||
const MAX_TEAM_MEMBERS = 5;
|
||||
|
||||
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
||||
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
||||
|
||||
// Image component with loading state
|
||||
const ImageWithLoader: FC<{
|
||||
src: string;
|
||||
@@ -50,8 +55,14 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
const { teamId } = useParams<{ teamId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
// Check if team features are closed
|
||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||
const [showJoinRequestsModal, setShowJoinRequestsModal] = useState(false);
|
||||
const [showLeaveModal, setShowLeaveModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [imagesLoaded, setImagesLoaded] = useState(false);
|
||||
const [imageLoadCount, setImageLoadCount] = useState(0);
|
||||
@@ -85,25 +96,29 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(
|
||||
teamId || ''
|
||||
);
|
||||
|
||||
const team = teamData?.data;
|
||||
const members = membersData?.data || [];
|
||||
const currentUserId = session?.user?.id;
|
||||
const isLeader = currentUserId === team?.leader_id;
|
||||
|
||||
// Only fetch join requests if user is the team leader
|
||||
const { data: joinRequestsData } = useTeamJoinRequests(
|
||||
teamId || '',
|
||||
!!teamId
|
||||
!!teamId && isLeader
|
||||
);
|
||||
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(
|
||||
teamId || ''
|
||||
);
|
||||
const { mutateAsync: respondToJoinRequest, isPending: isResponding } =
|
||||
useRespondToJoinRequest(teamId || '');
|
||||
const { mutateAsync: leaveTeam, isPending: isLeaving } = useLeaveTeam();
|
||||
const { mutateAsync: deleteTeam, isPending: isDeleting } = useDeleteTeam();
|
||||
|
||||
const team = teamData?.data;
|
||||
const members = membersData?.data || [];
|
||||
const joinRequests = joinRequestsData?.data || [];
|
||||
const pendingJoinRequests = joinRequests.filter(
|
||||
(req: any) => req.status === 'pending'
|
||||
);
|
||||
const currentUserId = session?.user?.id;
|
||||
|
||||
const isLeader = currentUserId === team?.leader_id;
|
||||
const isMember = members.some(
|
||||
(member: any) => member.user_id === currentUserId
|
||||
);
|
||||
@@ -139,6 +154,32 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeaveTeam = async () => {
|
||||
if (!teamId) return;
|
||||
|
||||
try {
|
||||
await leaveTeam(teamId);
|
||||
toast.success('You have left the team');
|
||||
navigate('/dashboard');
|
||||
} catch (error) {
|
||||
console.error('Failed to leave team:', error);
|
||||
toast.error('Failed to leave team');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTeam = async () => {
|
||||
if (!teamId) return;
|
||||
|
||||
try {
|
||||
await deleteTeam(teamId);
|
||||
toast.success('Team deleted successfully');
|
||||
navigate('/dashboard');
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete team:', error);
|
||||
toast.error(error?.message || 'Failed to delete team');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingTeam || isLoadingMembers) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
@@ -194,7 +235,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
<div className="fixed inset-0 bg-white/60 flex items-center justify-center z-50">
|
||||
<div className="fixed inset-0 bg-white/60 dark:bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-white">
|
||||
@@ -334,42 +375,49 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
Team Management
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
disabled={!canInvite}
|
||||
>
|
||||
<Icon icon="mdi:plus" className="inline-block mr-2" />
|
||||
Invite Member{' '}
|
||||
{!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full relative"
|
||||
variant="secondary"
|
||||
onClick={() => setShowJoinRequestsModal(true)}
|
||||
>
|
||||
<Icon icon="mdi:email" className="inline-block mr-2" /> Join
|
||||
Requests
|
||||
{pendingJoinRequests.length > 0 && (
|
||||
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center">
|
||||
{pendingJoinRequests.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Link to={`/teams/${teamId}/edit`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
<Icon icon="mdi:pencil" className="inline-block mr-2" />
|
||||
Edit Team Info
|
||||
</Button>
|
||||
</Link>
|
||||
{/* Hide invite/join management after submission or deadline */}
|
||||
{!team.has_submission && !isTeamFeaturesClosed && (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
disabled={!canInvite}
|
||||
>
|
||||
<Icon icon="mdi:plus" className="inline-block mr-2" />
|
||||
Invite Member{' '}
|
||||
{!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full relative"
|
||||
variant="secondary"
|
||||
onClick={() => setShowJoinRequestsModal(true)}
|
||||
>
|
||||
<Icon icon="mdi:email" className="inline-block mr-2" /> Join
|
||||
Requests
|
||||
{pendingJoinRequests.length > 0 && (
|
||||
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center">
|
||||
{pendingJoinRequests.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!team.has_submission && !isTeamFeaturesClosed && (
|
||||
<Link to={`/teams/${teamId}/edit`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
<Icon icon="mdi:pencil" className="inline-block mr-2" />
|
||||
Edit Team Info
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link to={`/teams/${teamId}/members`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
<Icon
|
||||
icon="mdi:account-group"
|
||||
className="inline-block mr-2"
|
||||
/>
|
||||
Manage Members
|
||||
View Members
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={`/teams/${teamId}/chat`}>
|
||||
@@ -378,40 +426,92 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
Team Chat
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={`/teams/${teamId}/submit`}>
|
||||
<Button className="w-full">
|
||||
<Icon icon="mdi:rocket" className="inline-block mr-2" />
|
||||
Submit Project
|
||||
</Button>
|
||||
</Link>
|
||||
{team.has_submission ? (
|
||||
<Link to={`/teams/${teamId}/submission`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
<Icon icon="mdi:file-document" className="inline-block mr-2" />
|
||||
View Submission
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link to={`/teams/${teamId}/submit`}>
|
||||
<Button className="w-full" disabled={members.length < 2}>
|
||||
<Icon icon="mdi:rocket" className="inline-block mr-2" />
|
||||
Submit Project
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{!canInvite && members.length >= MAX_TEAM_MEMBERS && (
|
||||
{!team.has_submission && !canInvite && members.length >= MAX_TEAM_MEMBERS && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-3 text-center">
|
||||
Maximum team size reached ({MAX_TEAM_MEMBERS} members)
|
||||
</p>
|
||||
)}
|
||||
{/* Danger Zone - Only show when leader is alone, no submission, and features not closed */}
|
||||
{members.length === 1 && !team.has_submission && !isTeamFeaturesClosed && (
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-red-600 dark:text-red-400 mb-3">
|
||||
Danger Zone
|
||||
</h3>
|
||||
<Button
|
||||
className="w-full bg-red-600 hover:bg-red-700 text-white"
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
>
|
||||
<Icon icon="mdi:delete" className="inline-block mr-2" />
|
||||
Delete Team
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions for Members (non-leaders) */}
|
||||
{!isLeader && isMember && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 md:p-6">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Quick Actions
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Link to={`/teams/${teamId}/chat`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
💬 Team Chat
|
||||
<Icon icon="mdi:chat" className="inline-block mr-2" />
|
||||
Team Chat
|
||||
</Button>
|
||||
</Link>
|
||||
{team.has_submission && (
|
||||
<Link to={`/teams/${teamId}/submission`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
📄 View Submission
|
||||
<Icon icon="mdi:file-document" className="inline-block mr-2" />
|
||||
View Submission
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{!team.has_submission && !isTeamFeaturesClosed && (
|
||||
<Button
|
||||
className="w-full bg-red-600 hover:bg-red-700 text-white"
|
||||
onClick={() => setShowLeaveModal(true)}
|
||||
>
|
||||
<Icon icon="mdi:exit-run" className="inline-block mr-2" />
|
||||
Leave Team
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning for single member teams */}
|
||||
{members.length === 1 && !team.has_submission && isMember && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 md:p-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-3xl">⚠️</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-amber-900 dark:text-amber-400">
|
||||
Team Needs More Members
|
||||
</h3>
|
||||
<p className="text-amber-700 dark:text-amber-300 text-sm">
|
||||
Your team needs at least 2 members to submit a project. Invite someone or wait for join requests!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -696,6 +796,110 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Leave Team Confirmation Modal */}
|
||||
{showLeaveModal && (
|
||||
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<Icon
|
||||
icon="mdi:exit-run"
|
||||
className="text-3xl text-red-600 dark:text-red-400"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Leave Team?
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Are you sure you want to leave <strong>{team?.name}</strong>?
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-300 font-sans">
|
||||
<strong>Warning:</strong> If you leave, you will need to request to join again or be re-invited by the team leader.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => setShowLeaveModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleLeaveTeam}
|
||||
className="flex-1 bg-red-600 hover:bg-red-700"
|
||||
disabled={isLeaving}
|
||||
>
|
||||
{isLeaving ? 'Leaving...' : 'Leave Team'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Team Confirmation Modal */}
|
||||
{showDeleteModal && (
|
||||
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<Icon
|
||||
icon="mdi:delete-alert"
|
||||
className="text-3xl text-red-600 dark:text-red-400"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Delete Team?
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
This action is <strong className="text-red-600 dark:text-red-400">permanent</strong> and cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-red-800 dark:text-red-300 font-sans">
|
||||
<strong>Warning:</strong> All team data, chat messages, and submissions will be permanently deleted.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Type <span className="font-bold text-red-600 dark:text-red-400">{team?.name}</span> to confirm:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||
placeholder="Type team name here"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeleteConfirmText('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDeleteTeam}
|
||||
className="flex-1 bg-red-600 hover:bg-red-700"
|
||||
disabled={isDeleting || deleteConfirmText !== team?.name}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete Team'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -60,20 +60,46 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Status Banner */}
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-4xl">✅</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-green-900 dark:text-green-100 text-lg">Project Submitted Successfully</h3>
|
||||
<p className="text-green-700 dark:text-green-300 text-sm">
|
||||
Submitted on {submittedDate}
|
||||
</p>
|
||||
<p className="text-green-600 dark:text-green-400 text-xs mt-1">
|
||||
This submission is now read-only and cannot be edited
|
||||
</p>
|
||||
{submission.status === 'submitted' ? (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-4xl">✅</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-green-900 dark:text-green-100 text-lg">Project Submitted Successfully</h3>
|
||||
<p className="text-green-700 dark:text-green-300 text-sm">
|
||||
Submitted on {submittedDate}
|
||||
</p>
|
||||
<p className="text-green-600 dark:text-green-400 text-xs mt-1">
|
||||
This submission is now read-only and cannot be edited
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : submission.status === 'pending_verification' ? (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-4xl">⏳</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-yellow-900 dark:text-yellow-100 text-lg">Submission Pending Verification</h3>
|
||||
<p className="text-yellow-700 dark:text-yellow-300 text-sm">
|
||||
Your submission is being processed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-4xl">📝</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-orange-900 dark:text-orange-100 text-lg">Draft Submission</h3>
|
||||
<p className="text-orange-700 dark:text-orange-300 text-sm">
|
||||
This submission is still in draft and has not been finalized
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-950/50 overflow-hidden">
|
||||
{/* Project Header */}
|
||||
@@ -124,21 +150,6 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Presentation URL */}
|
||||
{submission.presentation_url && (
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Presentation</h3>
|
||||
<a
|
||||
href={submission.presentation_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-2 text-blue-600 dark:text-primary-400 hover:text-blue-800 dark:hover:text-primary-300"
|
||||
>
|
||||
<span>📊</span>
|
||||
<span className="break-all">{submission.presentation_url}</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Screenshots */}
|
||||
@@ -173,8 +184,18 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-neutral-400">Status:</span>
|
||||
<span className="font-medium text-green-600 dark:text-green-400">
|
||||
{submission.status === 'submitted' ? '✓ Submitted' : 'Draft'}
|
||||
<span className={`font-medium ${
|
||||
submission.status === 'submitted'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: submission.status === 'pending_verification'
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: 'text-orange-600 dark:text-orange-400'
|
||||
}`}>
|
||||
{submission.status === 'submitted'
|
||||
? '✓ Submitted'
|
||||
: submission.status === 'pending_verification'
|
||||
? '⏳ Pending Verification'
|
||||
: '📝 Draft'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
|
||||
@@ -13,12 +13,17 @@ import {
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const MIN_TEAM_MEMBERS = 2; // Minimum members required to submit (including leader)
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
const SubmitProjectPage: FC = (): ReactElement => {
|
||||
const { teamId } = useParams<{ teamId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [screenshots, setScreenshots] = useState<string[]>([]);
|
||||
|
||||
const { data: teamData } = useTeamById(teamId || '');
|
||||
@@ -31,6 +36,8 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
const currentUserId = session?.user?.id;
|
||||
const isLeader = currentUserId === team?.leader_id;
|
||||
const hasSubmission = !!submissionData?.data;
|
||||
const memberCount = team?.members?.length || 0;
|
||||
const hasEnoughMembers = memberCount >= MIN_TEAM_MEMBERS;
|
||||
|
||||
const form = useForm<TProjectSubmissionForm>({
|
||||
resolver: zodResolver(projectSubmissionSchema),
|
||||
@@ -84,6 +91,14 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
|
||||
// Validate file sizes
|
||||
const oversizedFiles = Array.from(files).filter(file => file.size > MAX_FILE_SIZE);
|
||||
if (oversizedFiles.length > 0) {
|
||||
toast.error(`${oversizedFiles.length} file(s) are too large. Maximum size is 2MB per file.`);
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const uploadPromises = Array.from(files).map((file) => uploadFile(file));
|
||||
const results = await Promise.all(uploadPromises);
|
||||
@@ -91,6 +106,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
setScreenshots([...screenshots, ...urls]);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload screenshots:', error);
|
||||
toast.error('Failed to upload screenshots. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -122,6 +138,25 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Minimum Members Warning */}
|
||||
{!hasEnoughMembers && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-3xl">👥</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-amber-900 dark:text-amber-100 text-lg">
|
||||
Team Members Required
|
||||
</h3>
|
||||
<p className="text-amber-800 dark:text-amber-200 mt-2 text-sm font-sans">
|
||||
Your team needs at least <strong>{MIN_TEAM_MEMBERS} members</strong> to submit a project.
|
||||
Currently you have <strong>{memberCount} member{memberCount !== 1 ? 's' : ''}</strong>.
|
||||
Please invite more members to your team before submitting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning Banner */}
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border-2 border-red-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
@@ -208,16 +243,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{/* Presentation URL */}
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Presentation URL (Optional)"
|
||||
placeholder="https://slides.com/your-presentation or Google Drive link"
|
||||
name="presentation_url"
|
||||
type="url"
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{/* Screenshots */}
|
||||
<div>
|
||||
<label className="block text-label1 font-medium text-neutral-800 dark:text-gray-300">
|
||||
@@ -247,7 +272,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
Click to upload screenshots
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1 font-sans">
|
||||
PNG, JPG up to 5MB each
|
||||
PNG, JPG. Max 2MB each
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
@@ -274,11 +299,16 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={!form.formState.isValid || isUploading}
|
||||
disabled={!form.formState.isValid || isUploading || !hasEnoughMembers}
|
||||
>
|
||||
Review & Submit
|
||||
</Button>
|
||||
</div>
|
||||
{!hasEnoughMembers && (
|
||||
<p className="text-center text-sm text-amber-600 dark:text-amber-400 mt-2">
|
||||
You need at least {MIN_TEAM_MEMBERS} team members to submit
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,7 +328,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
||||
By clicking "Submit Project", you confirm that:
|
||||
By submitting, you confirm that:
|
||||
</p>
|
||||
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-2">
|
||||
<li>✓ All information is correct and complete</li>
|
||||
@@ -307,19 +337,35 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
<li>✓ Your team agrees with this submission</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Type <span className="font-bold text-red-600 dark:text-red-400">SUBMIT</span> to confirm:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder="Type SUBMIT here"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => setShowConfirmModal(false)}
|
||||
onClick={() => {
|
||||
setShowConfirmModal(false);
|
||||
setConfirmText('');
|
||||
}}
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
className="flex-1 bg-red-600 hover:bg-red-700"
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || confirmText !== 'SUBMIT'}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit Project'}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FC, ReactElement, useState, useEffect } from 'react';
|
||||
import { FC, ReactElement, useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router';
|
||||
import {
|
||||
useTeams,
|
||||
useJoinTeam,
|
||||
@@ -14,26 +14,193 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { CitySelect } from '../../../components/city-select';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
const DEFAULT_PER_PAGE = 12;
|
||||
const PER_PAGE_OPTIONS = [6, 12, 24, 48];
|
||||
|
||||
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
||||
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
||||
|
||||
// Member filter options
|
||||
const MEMBER_FILTER_OPTIONS = [
|
||||
{ label: 'All Teams', value: '', minMembers: undefined, maxMembers: undefined },
|
||||
{ label: 'Looking for Members (1-4)', value: 'looking', minMembers: 1, maxMembers: 4 },
|
||||
{ label: '1 Member', value: '1', minMembers: 1, maxMembers: 1 },
|
||||
{ label: '2 Members', value: '2', minMembers: 2, maxMembers: 2 },
|
||||
{ label: '3 Members', value: '3', minMembers: 3, maxMembers: 3 },
|
||||
{ label: '4 Members', value: '4', minMembers: 4, maxMembers: 4 },
|
||||
{ label: '5 Members (Full)', value: '5', minMembers: 5, maxMembers: 5 },
|
||||
];
|
||||
|
||||
// Submission status filter options
|
||||
const SUBMISSION_FILTER_OPTIONS = [
|
||||
{ label: 'All Teams', value: '' },
|
||||
{ label: 'Submitted', value: 'true' },
|
||||
{ label: 'Not Submitted', value: 'false' },
|
||||
];
|
||||
|
||||
// Skeleton card component for loading state
|
||||
const TeamCardSkeleton: FC = () => (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden flex flex-col border dark:border-gray-800 animate-pulse">
|
||||
<div className="w-full aspect-3/1 bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="p-6 flex flex-col flex-1">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="flex-1">
|
||||
<div className="h-5 w-32 bg-gray-200 dark:bg-gray-700 rounded mb-2" />
|
||||
<div className="h-4 w-48 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 mb-4 flex-1">
|
||||
<div className="h-4 w-full bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-4 w-5/6 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-4 w-4/6 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
<div className="space-y-3 mt-auto">
|
||||
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [selectedCity, setSelectedCity] = useState('');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Check if team features are closed
|
||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||
|
||||
// Initialize state from URL params
|
||||
const initialPage = parseInt(searchParams.get('page') || '1', 10);
|
||||
const initialPerPage = parseInt(
|
||||
searchParams.get('per_page') || String(DEFAULT_PER_PAGE),
|
||||
10
|
||||
);
|
||||
const initialSearch = searchParams.get('search') || '';
|
||||
const initialCity = searchParams.get('city') || '';
|
||||
const initialMembers = searchParams.get('members') || '';
|
||||
const initialSubmission = searchParams.get('submission') || '';
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(initialSearch);
|
||||
const [debouncedSearch, setDebouncedSearch] = useState(initialSearch);
|
||||
const [selectedCity, setSelectedCity] = useState(initialCity);
|
||||
const [selectedMembers, setSelectedMembers] = useState(initialMembers);
|
||||
const [selectedSubmission, setSelectedSubmission] = useState(initialSubmission);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
const [showJoinModal, setShowJoinModal] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(
|
||||
PER_PAGE_OPTIONS.includes(initialPerPage) ? initialPage : 1
|
||||
);
|
||||
const [perPage, setPerPage] = useState(
|
||||
PER_PAGE_OPTIONS.includes(initialPerPage) ? initialPerPage : DEFAULT_PER_PAGE
|
||||
);
|
||||
|
||||
// Debounce search term
|
||||
// Update URL when pagination state changes
|
||||
const updateUrlParams = useCallback(
|
||||
(params: {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
search?: string;
|
||||
city?: string;
|
||||
members?: string;
|
||||
submission?: string;
|
||||
}) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
|
||||
if (params.page !== undefined) {
|
||||
if (params.page === 1) {
|
||||
newParams.delete('page');
|
||||
} else {
|
||||
newParams.set('page', String(params.page));
|
||||
}
|
||||
}
|
||||
|
||||
if (params.per_page !== undefined) {
|
||||
if (params.per_page === DEFAULT_PER_PAGE) {
|
||||
newParams.delete('per_page');
|
||||
} else {
|
||||
newParams.set('per_page', String(params.per_page));
|
||||
}
|
||||
}
|
||||
|
||||
if (params.search !== undefined) {
|
||||
if (params.search === '') {
|
||||
newParams.delete('search');
|
||||
} else {
|
||||
newParams.set('search', params.search);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.city !== undefined) {
|
||||
if (params.city === '') {
|
||||
newParams.delete('city');
|
||||
} else {
|
||||
newParams.set('city', params.city);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.members !== undefined) {
|
||||
if (params.members === '') {
|
||||
newParams.delete('members');
|
||||
} else {
|
||||
newParams.set('members', params.members);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.submission !== undefined) {
|
||||
if (params.submission === '') {
|
||||
newParams.delete('submission');
|
||||
} else {
|
||||
newParams.set('submission', params.submission);
|
||||
}
|
||||
}
|
||||
|
||||
setSearchParams(newParams, { replace: true });
|
||||
},
|
||||
[searchParams, setSearchParams]
|
||||
);
|
||||
|
||||
// Debounce search term and reset page
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(searchTerm);
|
||||
if (searchTerm !== debouncedSearch) {
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ search: searchTerm, page: 1 });
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm]);
|
||||
|
||||
const { data: teamsData, isLoading } = useTeams({
|
||||
// Update URL when city filter changes
|
||||
useEffect(() => {
|
||||
if (selectedCity !== initialCity) {
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ city: selectedCity, page: 1 });
|
||||
}
|
||||
}, [selectedCity]);
|
||||
|
||||
// Get member filter values
|
||||
const memberFilter = MEMBER_FILTER_OPTIONS.find(
|
||||
(opt) => opt.value === selectedMembers
|
||||
);
|
||||
|
||||
// Convert submission filter value to boolean
|
||||
const hasSubmissionFilter = selectedSubmission === 'true' ? true : selectedSubmission === 'false' ? false : undefined;
|
||||
|
||||
const {
|
||||
data: teamsData,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useTeams({
|
||||
page: currentPage,
|
||||
limit: perPage,
|
||||
search: debouncedSearch,
|
||||
city: selectedCity || undefined,
|
||||
visibility: ETeamVisibility.PUBLIC,
|
||||
minMembers: memberFilter?.minMembers,
|
||||
maxMembers: memberFilter?.maxMembers,
|
||||
hasSubmission: hasSubmissionFilter,
|
||||
});
|
||||
|
||||
const { data: myTeamsData } = useMyTeams();
|
||||
@@ -44,7 +211,9 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
mode: 'all',
|
||||
});
|
||||
|
||||
const teams = teamsData?.data || [];
|
||||
const teams = teamsData?.teams || [];
|
||||
const totalPages = teamsData?.totalPages || 1;
|
||||
const total = teamsData?.total || 0;
|
||||
const myTeams = myTeamsData?.data || [];
|
||||
|
||||
// Helper function to check if user is a member of a team
|
||||
@@ -70,6 +239,43 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
}
|
||||
});
|
||||
|
||||
// Generate page numbers to display
|
||||
const getPageNumbers = () => {
|
||||
const pages: (number | string)[] = [];
|
||||
const maxVisible = 5;
|
||||
|
||||
if (totalPages <= maxVisible + 2) {
|
||||
// Show all pages if total is small
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
// Always show first page
|
||||
pages.push(1);
|
||||
|
||||
if (currentPage > 3) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Show pages around current
|
||||
const start = Math.max(2, currentPage - 1);
|
||||
const end = Math.min(totalPages - 1, currentPage + 1);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (currentPage < totalPages - 2) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Header */}
|
||||
@@ -94,7 +300,7 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
{/* Filters */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="bg-white dark:bg-gray-900 p-6 rounded-lg shadow-sm mb-6 border dark:border-gray-800">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Search Teams
|
||||
@@ -117,13 +323,63 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
placeholder="All Cities (search to filter...)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Filter by Members
|
||||
</label>
|
||||
<select
|
||||
value={selectedMembers}
|
||||
onChange={(e) => {
|
||||
setSelectedMembers(e.target.value);
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ members: e.target.value, page: 1 });
|
||||
}}
|
||||
className="w-full h-[42px] px-3 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
>
|
||||
{MEMBER_FILTER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Submission Status
|
||||
</label>
|
||||
<select
|
||||
value={selectedSubmission}
|
||||
onChange={(e) => {
|
||||
setSelectedSubmission(e.target.value);
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ submission: e.target.value, page: 1 });
|
||||
}}
|
||||
className="w-full h-[42px] px-3 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
>
|
||||
{SUBMISSION_FILTER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Teams Count */}
|
||||
{!isLoading && total > 0 && (
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing {(currentPage - 1) * perPage + 1}-
|
||||
{Math.min(currentPage * perPage, total)} of {total} teams
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Teams List */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading teams...</p>
|
||||
{isLoading || isFetching ? (
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: perPage }).map((_, index) => (
|
||||
<TeamCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
) : teams.length === 0 ? (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-sm p-12 text-center">
|
||||
@@ -135,85 +391,195 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{teams.map((team) => (
|
||||
<div
|
||||
key={team.id}
|
||||
className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow flex flex-col border dark:border-gray-800"
|
||||
>
|
||||
<img
|
||||
src={team.banner || '/images/banner-imphnen.webp'}
|
||||
alt={team.name}
|
||||
className="w-full aspect-3/1 object-cover"
|
||||
/>
|
||||
<div className="p-6 flex flex-col flex-1">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
{team.logo ? (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-gray-500 dark:text-gray-300 text-xl">
|
||||
<Icon icon="mdi:account-group" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white line-clamp-2 leading-tight">
|
||||
{team.name}
|
||||
</h3>
|
||||
<div className="text-sm font-sans text-gray-600 dark:text-gray-400 flex gap-2">
|
||||
<p className="truncate flex-1 min-w-0 flex items-center gap-x-1">
|
||||
<Icon icon="mdi:map-marker" />{' '}
|
||||
<span>{team.city}</span>
|
||||
</p>
|
||||
<p className="whitespace-nowrap shrink-0 flex items-center gap-x-1">
|
||||
<Icon icon="mdi:account-group" />{' '}
|
||||
{team.members?.length || 0} members
|
||||
</p>
|
||||
<>
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{teams.map((team: any) => (
|
||||
<div
|
||||
key={team.id}
|
||||
className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow flex flex-col border dark:border-gray-800"
|
||||
>
|
||||
<img
|
||||
src={team.banner || '/images/banner-imphnen.webp'}
|
||||
alt={team.name}
|
||||
className="w-full aspect-3/1 object-cover"
|
||||
/>
|
||||
<div className="p-6 flex flex-col flex-1">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
{team.logo ? (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-gray-500 dark:text-gray-300 text-xl">
|
||||
<Icon icon="mdi:account-group" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white line-clamp-1 leading-tight">
|
||||
{team.name}
|
||||
</h3>
|
||||
<div className="text-sm font-sans text-gray-600 dark:text-gray-400 flex items-center gap-2 mt-1">
|
||||
{team.has_submission && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0">
|
||||
<Icon icon="mdi:check-circle" className="text-sm" />
|
||||
Submitted
|
||||
</span>
|
||||
)}
|
||||
<p className="truncate flex-1 min-w-0 flex items-center gap-x-1">
|
||||
<Icon icon="mdi:map-marker" />{' '}
|
||||
<span>{team.city}</span>
|
||||
</p>
|
||||
<p className="whitespace-nowrap shrink-0 flex items-center gap-x-1">
|
||||
<Icon icon="mdi:account-group" />{' '}
|
||||
{team.member_count || 0} members
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm mb-4 line-clamp-3 font-sans">
|
||||
{team.description}
|
||||
</p>
|
||||
<div className="space-y-3 mt-auto">
|
||||
{isMyTeam(team.id) ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/teams/${team.id}`)}
|
||||
>
|
||||
Your Team
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{myTeams.length === 0 &&
|
||||
(team.members?.length || 0) < 5 && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => handleJoinRequest(team.id)}
|
||||
>
|
||||
Request to Join
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm mb-4 line-clamp-3 font-sans flex-1">
|
||||
{team.description}
|
||||
</p>
|
||||
<div className="space-y-3 mt-auto">
|
||||
{(team.member_count || 0) === 1 && !team.has_submission && (
|
||||
<div className="p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400 flex items-center gap-1">
|
||||
<Icon icon="mdi:alert" className="text-sm shrink-0" />
|
||||
<span>This team needs at least 2 members to submit</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isMyTeam(team.id) ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/teams/${team.id}`)}
|
||||
>
|
||||
View Team
|
||||
Your Team
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
{myTeams.length === 0 &&
|
||||
(team.member_count || 0) < 5 &&
|
||||
!team.has_submission &&
|
||||
!isTeamFeaturesClosed && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => handleJoinRequest(team.id)}
|
||||
>
|
||||
Request to Join
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/teams/${team.id}`)}
|
||||
>
|
||||
View Team
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{(totalPages > 1 || total > 6) && (
|
||||
<div className="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const newPage = Math.max(1, currentPage - 1);
|
||||
setCurrentPage(newPage);
|
||||
updateUrlParams({ page: newPage });
|
||||
}}
|
||||
disabled={currentPage === 1 || isFetching}
|
||||
className="px-3"
|
||||
>
|
||||
<Icon icon="mdi:chevron-left" className="text-xl" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{getPageNumbers().map((page, index) =>
|
||||
typeof page === 'string' ? (
|
||||
<span
|
||||
key={`ellipsis-${index}`}
|
||||
className="px-2 text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => {
|
||||
setCurrentPage(page);
|
||||
updateUrlParams({ page });
|
||||
}}
|
||||
disabled={isFetching}
|
||||
className={`min-w-10 h-10 px-3 rounded-md text-sm font-medium transition-colors ${
|
||||
currentPage === page
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const newPage = Math.min(totalPages, currentPage + 1);
|
||||
setCurrentPage(newPage);
|
||||
updateUrlParams({ page: newPage });
|
||||
}}
|
||||
disabled={currentPage === totalPages || isFetching}
|
||||
className="px-3"
|
||||
>
|
||||
<Icon icon="mdi:chevron-right" className="text-xl" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{totalPages > 1 && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Show:</span>
|
||||
<select
|
||||
value={perPage}
|
||||
onChange={(e) => {
|
||||
const newPerPage = Number(e.target.value);
|
||||
setPerPage(newPerPage);
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ per_page: newPerPage, page: 1 });
|
||||
}}
|
||||
className="h-10 px-3 pr-8 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
{PER_PAGE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,11 +5,22 @@ import { useNavigate } from 'react-router';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { teamCreateSchema, TTeamCreateForm, useCreateTeam, ETeamVisibility, useUploadFile } from '@imphnen-frontend-service/service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
import { CitySelect } from '../../../components/city-select';
|
||||
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
||||
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
||||
|
||||
const CreateTeamPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Check if team features are closed
|
||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string>('');
|
||||
const [bannerFile, setBannerFile] = useState<File | null>(null);
|
||||
@@ -31,6 +42,11 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error('Logo image is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
setLogoFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
@@ -43,6 +59,11 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error('Banner image is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
setBannerFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
@@ -73,12 +94,67 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
banner: bannerUrl,
|
||||
});
|
||||
|
||||
toast.success('Team created successfully!');
|
||||
navigate(`/teams/${result.data.id}`);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create team:', error);
|
||||
|
||||
// Handle specific error messages
|
||||
const message = error?.message || '';
|
||||
if (message.includes('413') || message.includes('length limit') || message.includes('too large')) {
|
||||
toast.error('Image file is too large. Please use smaller images (max 2MB each).');
|
||||
} else if (message.includes('already a member')) {
|
||||
toast.error(message);
|
||||
} else {
|
||||
toast.error(message || 'Failed to create team. Please try again.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Show closed screen if team features are closed
|
||||
if (isTeamFeaturesClosed) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
|
||||
<div className="mb-6">
|
||||
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<Icon
|
||||
icon="mdi:clock-alert"
|
||||
className="text-3xl text-red-600 dark:text-red-400"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Team Features Closed
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Team creation is no longer available.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
The deadline for team features was November 30, 2025 at 23:59 WIB.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer"
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/teams/browse')}
|
||||
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Browse Teams
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950">
|
||||
{/* Header */}
|
||||
@@ -119,7 +195,7 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
<label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 dark:border-neutral-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-neutral-800">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500 dark:text-neutral-400">Click to upload banner</p>
|
||||
<p className="text-xs text-gray-400 dark:text-neutral-500 mt-1">1200x400 recommended</p>
|
||||
<p className="text-xs text-gray-400 dark:text-neutral-500 mt-1">1200x400 recommended. Max 2MB</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
@@ -149,30 +225,33 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="logo" className="cursor-pointer">
|
||||
<span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
|
||||
{logoPreview ? 'Change Logo' : 'Upload Logo'}
|
||||
</span>
|
||||
<input
|
||||
id="logo"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleLogoChange}
|
||||
/>
|
||||
</label>
|
||||
{logoPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLogoFile(null);
|
||||
setLogoPreview('');
|
||||
}}
|
||||
className="ml-3 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="logo" className="cursor-pointer">
|
||||
<span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
|
||||
{logoPreview ? 'Change Logo' : 'Upload Logo'}
|
||||
</span>
|
||||
<input
|
||||
id="logo"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleLogoChange}
|
||||
/>
|
||||
</label>
|
||||
{logoPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLogoFile(null);
|
||||
setLogoPreview('');
|
||||
}}
|
||||
className="ml-3 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-neutral-500 mt-2">Max 2MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,21 +6,33 @@ const UserDetailLayout: FC = (): ReactElement => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-50 dark:bg-neutral-950">
|
||||
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-auto">
|
||||
{/* Mobile Header with Hamburger */}
|
||||
<div className="lg:hidden bg-white dark:bg-neutral-900 border-b dark:border-neutral-700 px-4 py-3 flex items-center">
|
||||
<div className="lg:hidden bg-white dark:bg-gray-900 border-b dark:border-neutral-700 px-4 py-3 flex items-center">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6 text-gray-600 dark:text-neutral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
<svg
|
||||
className="w-6 h-6 text-gray-600 dark:text-neutral-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="ml-3 text-lg font-bold text-gray-900 dark:text-white">Hackathon</h1>
|
||||
<h1 className="ml-3 text-lg font-bold text-gray-900 dark:text-white">
|
||||
Hackathon
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-auto">
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useUserDetailsById,
|
||||
useTeamsByUserId,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
const UserProfilePage: FC = (): ReactElement => {
|
||||
const { userId } = useParams<{ userId: string }>();
|
||||
@@ -17,8 +18,8 @@ const UserProfilePage: FC = (): ReactElement => {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950">
|
||||
<div className="text-gray-600 dark:text-neutral-400">
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
Loading user profile...
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,7 +28,7 @@ const UserProfilePage: FC = (): ReactElement => {
|
||||
|
||||
if (error || !user) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950">
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
User not found
|
||||
</h2>
|
||||
@@ -42,9 +43,9 @@ const UserProfilePage: FC = (): ReactElement => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700">
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3 md:space-x-4">
|
||||
@@ -52,12 +53,12 @@ const UserProfilePage: FC = (): ReactElement => {
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.fullname}
|
||||
className="w-16 h-16 md:w-20 md:h-20 rounded-full object-cover border-4 border-white dark:border-neutral-800 shadow-lg"
|
||||
className="w-16 h-16 md:w-20 md:h-20 rounded-full object-cover border-4 border-white dark:border-gray-800 shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full bg-gray-200 dark:bg-neutral-700 flex items-center justify-center border-4 border-white dark:border-neutral-800 shadow-lg">
|
||||
<span className="text-gray-500 dark:text-neutral-400 text-2xl md:text-3xl">
|
||||
👤
|
||||
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border-4 border-white dark:border-gray-800 shadow-lg">
|
||||
<span className="text-gray-500 dark:text-gray-400 text-2xl md:text-3xl">
|
||||
<Icon icon="mdi:account-circle" className="w-10 h-10" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -65,13 +66,17 @@ const UserProfilePage: FC = (): ReactElement => {
|
||||
<h1 className="text-xl md:text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{user.fullname}
|
||||
</h1>
|
||||
<p className="text-sm md:text-base text-gray-600 dark:text-neutral-400 mt-1">
|
||||
<p className="text-sm md:text-base text-gray-600 dark:text-gray-400 mt-1">
|
||||
{user.email}
|
||||
</p>
|
||||
{user.location && (
|
||||
<div className="flex items-center space-x-4 mt-2">
|
||||
<span className="text-sm text-gray-500 dark:text-neutral-500">
|
||||
📍 {user.location}
|
||||
<span className="text-sm text-gray-500 dark:text-gray-500">
|
||||
<Icon
|
||||
icon="mdi:map-marker"
|
||||
className="inline-block w-4 h-4 mr-1"
|
||||
/>
|
||||
{user.location}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -90,11 +95,11 @@ const UserProfilePage: FC = (): ReactElement => {
|
||||
<div className="lg:col-span-2 space-y-4 md:space-y-6">
|
||||
{/* About Section */}
|
||||
{user.bio && (
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-950/50 p-4 md:p-6 overflow-hidden">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6 overflow-hidden">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
About
|
||||
</h2>
|
||||
<p className="text-gray-700 dark:text-neutral-300 break-all overflow-wrap-anywhere">
|
||||
<p className="text-gray-700 dark:text-gray-300 break-all overflow-wrap-anywhere font-sans">
|
||||
{user.bio}
|
||||
</p>
|
||||
</div>
|
||||
@@ -102,7 +107,7 @@ const UserProfilePage: FC = (): ReactElement => {
|
||||
|
||||
{/* Skills Section */}
|
||||
{user.skills && user.skills.length > 0 && (
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-950/50 p-4 md:p-6">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Skills
|
||||
</h2>
|
||||
@@ -110,7 +115,7 @@ const UserProfilePage: FC = (): ReactElement => {
|
||||
{user.skills.map((skill: string) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 dark:bg-primary-600 text-white rounded-lg text-sm font-medium shadow-sm hover:bg-blue-700 dark:hover:bg-primary-700 transition-colors"
|
||||
className="inline-flex items-center px-4 py-2 border border-primary-600 dark:border-gray-500 text-primary-600 dark:text-white rounded-4xl text-xs font-medium dark:bg-gray-600"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
@@ -119,63 +124,77 @@ const UserProfilePage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Teams Section */}
|
||||
{/* Team Section */}
|
||||
{userTeams.length > 0 ? (
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-950/50 p-4 md:p-6">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Teams ({userTeams.length})
|
||||
Team
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
{userTeams.map((team: any) => (
|
||||
<Link
|
||||
key={team.id}
|
||||
to={'/teams/' + team.id}
|
||||
className="bg-white dark:bg-neutral-800 rounded-lg shadow-md dark:shadow-neutral-950/50 overflow-hidden hover:shadow-lg transition-shadow"
|
||||
className="block bg-white dark:bg-gray-800 rounded-lg shadow-md dark:shadow-gray-950/50 overflow-hidden hover:shadow-lg transition-shadow border dark:border-gray-700"
|
||||
>
|
||||
<img
|
||||
src={team.banner || '/images/banner-imphnen.webp'}
|
||||
alt={team.name}
|
||||
className="w-full h-24 object-cover"
|
||||
className="w-full aspect-3/1 object-cover"
|
||||
/>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
{team.logo && (
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
{team.logo ? (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<Icon icon="mdi:account-group" className="text-gray-500 dark:text-gray-400 text-xl" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-gray-900 dark:text-white line-clamp-2">
|
||||
<h3 className="font-bold text-gray-900 dark:text-white line-clamp-1 text-lg">
|
||||
{team.name}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 dark:text-neutral-400 flex gap-2">
|
||||
<p className="truncate flex-1 min-w-0">
|
||||
📍 {team.city}
|
||||
</p>
|
||||
<p className="whitespace-nowrap shrink-0">
|
||||
👥 {team.members?.length || 0} members
|
||||
</p>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-3 font-sans mt-1">
|
||||
{team.has_submission && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0">
|
||||
<Icon icon="mdi:check-circle" className="text-sm" />
|
||||
Submitted
|
||||
</span>
|
||||
)}
|
||||
{team.city && (
|
||||
<span className="flex items-center gap-1 truncate">
|
||||
<Icon icon="mdi:map-marker" className="shrink-0" />
|
||||
<span className="truncate">{team.city}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-neutral-400 text-sm line-clamp-2">
|
||||
{team.description}
|
||||
</p>
|
||||
{team.description && (
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm line-clamp-2 font-sans">
|
||||
{team.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-950/50 p-4 md:p-6">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Teams
|
||||
Team
|
||||
</h2>
|
||||
<div className="text-center py-8">
|
||||
<div className="text-4xl mb-3">👥</div>
|
||||
<p className="text-gray-600 dark:text-neutral-400">
|
||||
<div className="text-4xl mb-3">
|
||||
<Icon icon="mdi:account-group-outline" className="inline-block text-gray-400" />
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Not in any team yet
|
||||
</p>
|
||||
</div>
|
||||
@@ -186,13 +205,13 @@ const UserProfilePage: FC = (): ReactElement => {
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* Contact Info */}
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-950/50 p-4 md:p-6">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
||||
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Contact Information
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-neutral-400">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Email
|
||||
</p>
|
||||
<p className="text-gray-900 dark:text-white font-medium">
|
||||
@@ -201,7 +220,7 @@ const UserProfilePage: FC = (): ReactElement => {
|
||||
</div>
|
||||
{user.location && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-neutral-400">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Location
|
||||
</p>
|
||||
<p className="text-gray-900 dark:text-white font-medium">
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Link, useLocation } from 'react-router';
|
||||
import {
|
||||
useMyTeams,
|
||||
useAuthStore,
|
||||
supabase,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
@@ -40,19 +39,11 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await supabase.auth.signOut();
|
||||
clearSession();
|
||||
localStorage.clear();
|
||||
toast.success('Logged out successfully');
|
||||
navigate('/auth/login');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
clearSession();
|
||||
localStorage.clear();
|
||||
navigate('/auth/login');
|
||||
}
|
||||
const handleLogout = () => {
|
||||
clearSession();
|
||||
localStorage.clear();
|
||||
toast.success('Logged out successfully');
|
||||
navigate('/auth/login');
|
||||
};
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SessionUser } from '@imphnen-frontend-service/utils';
|
||||
import { supabase } from '@imphnen-frontend-service/service';
|
||||
import { hackathonApi, SessionToken } from '@imphnen-frontend-service/service';
|
||||
import { LoaderFunctionArgs, redirect } from 'react-router';
|
||||
|
||||
const mappingPublicRoutes = [
|
||||
@@ -37,14 +37,10 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
const url = new URL(request.url);
|
||||
const pathname = url.pathname;
|
||||
|
||||
// Get session from Supabase (authoritative source)
|
||||
const { data: { session: supabaseSession }, error: sessionError } = await supabase.auth.getSession();
|
||||
|
||||
// Handle session errors
|
||||
if (sessionError) {
|
||||
console.error('[Middleware] Session error:', sessionError);
|
||||
// Don't redirect on session errors, let the app handle it
|
||||
}
|
||||
// Get token from cookies and user from local storage
|
||||
const tokenData = SessionToken.get();
|
||||
const user = SessionUser.get();
|
||||
const isAuthenticated = !!tokenData?.token?.access_token;
|
||||
|
||||
// Allow to access the hackathon pages without authentication
|
||||
if (mappingPublicPrefixRoutes.some((prefix) => pathname.startsWith(prefix))) {
|
||||
@@ -63,20 +59,24 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
|
||||
// Auth routes (all /auth/* paths) - redirect to dashboard if already authenticated
|
||||
if (pathname.startsWith('/auth')) {
|
||||
if (supabaseSession) return redirect('/dashboard');
|
||||
if (isAuthenticated) return redirect('/dashboard');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Require authentication for all other routes - ONLY check Supabase session
|
||||
if (!supabaseSession) {
|
||||
// Require authentication for all other routes
|
||||
if (!isAuthenticated) {
|
||||
return redirect('/auth/login');
|
||||
}
|
||||
|
||||
// Check if user has completed onboarding by querying database (not localStorage!)
|
||||
// Check if user has completed onboarding
|
||||
// Skip onboarding check for onboarding routes themselves
|
||||
if (!mappingOnboardingRoutes.includes(pathname)) {
|
||||
try {
|
||||
const userId = supabaseSession.user.id;
|
||||
const userId = user?.id;
|
||||
if (!userId) {
|
||||
return redirect('/auth/login');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Check cache first
|
||||
@@ -86,20 +86,20 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
|
||||
hasLocation = cached.hasLocation;
|
||||
} else {
|
||||
const { data: userData, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('location')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
if (userError) {
|
||||
console.error('[Middleware] Failed to fetch user data:', userError);
|
||||
// If we can't fetch user data, allow access (don't break the app)
|
||||
return null;
|
||||
// First check user data (faster)
|
||||
if (user?.location) {
|
||||
hasLocation = true;
|
||||
} else {
|
||||
// Fetch from backend API
|
||||
try {
|
||||
const response = await hackathonApi.get('/users/me');
|
||||
hasLocation = !!response.data?.data?.location;
|
||||
} catch {
|
||||
// If API fails, check user data as fallback
|
||||
hasLocation = !!user?.location;
|
||||
}
|
||||
}
|
||||
|
||||
hasLocation = !!userData?.location;
|
||||
|
||||
// Update cache
|
||||
onboardingCache.set(userId, { hasLocation, timestamp: now });
|
||||
}
|
||||
@@ -114,10 +114,9 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check route permissions using fresh user data from Zustand (for UI metadata)
|
||||
const session = SessionUser.get();
|
||||
// Check route permissions using user data
|
||||
const userPermissions =
|
||||
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
||||
user?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
||||
|
||||
const matchedRoute = mappingRoutePermissions.find(
|
||||
(route) => route.path === pathname
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
# Hackathon Backoffice API Contract
|
||||
|
||||
## GET /api/v1/admin/dashboard
|
||||
|
||||
### Purpose
|
||||
|
||||
Single endpoint delivering aggregated metrics for the IMPHNEN x Kolosal.ai Hackathon dashboard.
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- Requires admin (backoffice) scope: e.g. `role=admin`
|
||||
- 401 if unauthenticated, 403 if authenticated but lacking required scope.
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
GET /api/v1/admin/dashboard
|
||||
```
|
||||
|
||||
### Response Schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"data": {
|
||||
"total_participants": 1261,
|
||||
"total_teams": 206,
|
||||
"total_submissions": 0 // Total project submitted
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Field Types
|
||||
|
||||
| Path | Type | Notes |
|
||||
| ------------------------- | ------- | --------------------- |
|
||||
| `data.total_participants` | integer | >= 0 |
|
||||
| `data.total_teams` | integer | >= 0 |
|
||||
| `data.total_submissions` | integer | <= `data.total_teams` |
|
||||
|
||||
### Errors
|
||||
|
||||
| Status | Code | Message | Notes |
|
||||
| ------ | ---------------- | ----------------------------- | --------------------- |
|
||||
| 401 | `unauthorized` | `authentication required` | Missing/invalid token |
|
||||
| 403 | `forbidden` | `insufficient permissions` | Lacks required scope |
|
||||
| 429 | `rate_limited` | `too many dashboard requests` | Rate limiting |
|
||||
| 500 | `internal_error` | `unexpected server error` | Unhandled exception |
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/admin/users
|
||||
|
||||
### Purpose
|
||||
|
||||
Retrieve paginated list of hackathon participants with filtering, searching, and sorting capabilities for backoffice user management.
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- Requires admin (backoffice) scope: e.g. `role=admin`
|
||||
- 401 if unauthenticated, 403 if authenticated but lacking required scope.
|
||||
|
||||
### Query Parameters
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
| ------------ | ------- | -------- | ------------ | ------------------------------------------------------------- |
|
||||
| `page` | integer | No | 1 | Page number (1-based) |
|
||||
| `limit` | integer | No | 10 | Items per page (1-100) |
|
||||
| `search` | string | No | - | Search by name or location (case-insensitive) |
|
||||
| `status` | string | No | `all` | Filter by status: `all`, `active`, `inactive` |
|
||||
| `location` | string | No | `all` | Filter by location or `all` |
|
||||
| `skills` | string | No | - | Comma-separated skill filters |
|
||||
| `sort_by` | string | No | `created_at` | Sort field: `fullname`, `location`, `is_active`, `created_at` |
|
||||
| `sort_order` | string | No | `desc` | Sort order: `asc`, `desc` |
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
GET /api/v1/admin/users
|
||||
GET /api/v1/admin/users?page=2&limit=10
|
||||
GET /api/v1/admin/users?search=john&status=active
|
||||
GET /api/v1/admin/users?location=Jakarta&skills=Frontend Developer,UI/UX Designer
|
||||
GET /api/v1/admin/users?sort_by=fullname&sort_order=asc
|
||||
```
|
||||
|
||||
### Response Schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"data": {
|
||||
"users": [
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"avatar": "https://example.com/avatars/user1.jpg", // Optional
|
||||
"fullname": "Budi Santoso",
|
||||
"bio": "Passionate developer with 5+ years experience", // Optional
|
||||
"location": "Jakarta",
|
||||
"is_active": true,
|
||||
"skills": ["Frontend Developer", "UI/UX Designer"], // Optional
|
||||
"created_at": "2024-11-15T08:30:00Z",
|
||||
"updated_at": "2024-11-30T14:22:00Z"
|
||||
}
|
||||
// ... more users
|
||||
],
|
||||
"pagination": {
|
||||
"current_page": 1,
|
||||
"total_pages": 15,
|
||||
"total_items": 287,
|
||||
"items_per_page": 20,
|
||||
"has_next": true,
|
||||
"has_prev": false
|
||||
},
|
||||
"filters": {
|
||||
"available_locations": ["Jakarta", "Bandung", "Surabaya", "Medan", "Yogyakarta"],
|
||||
"available_skills": ["Frontend Developer", "Backend Developer", "Full Stack Developer", "DevOps Engineer", "UI/UX Designer", "Product Manager", "Data Scientist", "Mobile Developer"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Field Types
|
||||
|
||||
| Path | Type | Notes |
|
||||
| ------------------------- | ------- | ------------------------------ |
|
||||
| `data.users[].id` | string | UUID format |
|
||||
| `data.users[].avatar` | string | URL, nullable |
|
||||
| `data.users[].fullname` | string | Required |
|
||||
| `data.users[].bio` | string | Optional, max 500 chars |
|
||||
| `data.users[].location` | string | Required, from predefined list |
|
||||
| `data.users[].is_active` | boolean | Account status |
|
||||
| `data.users[].skills` | array | Array of skill strings |
|
||||
| `data.users[].created_at` | string | ISO 8601 timestamp |
|
||||
| `data.users[].updated_at` | string | ISO 8601 timestamp |
|
||||
| `data.pagination.*` | integer | Pagination metadata |
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/admin/users/{user_id}
|
||||
|
||||
### Purpose
|
||||
|
||||
Retrieve detailed information for a specific user by ID.
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- Requires admin (backoffice) scope: e.g. `role=admin`
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------ | -------- | ----------- |
|
||||
| `user_id` | string | Yes | User UUID |
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
GET /api/v1/admin/users/550e8400-e29b-41d4-a716-446655440000
|
||||
```
|
||||
|
||||
### Response Schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"data": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"avatar": "https://example.com/avatars/user1.jpg",
|
||||
"fullname": "Budi Santoso",
|
||||
"bio": "Passionate developer with 5+ years experience",
|
||||
"location": "Jakarta",
|
||||
"is_active": true,
|
||||
"skills": ["Frontend Developer", "UI/UX Designer"],
|
||||
"created_at": "2024-11-15T08:30:00Z",
|
||||
"updated_at": "2024-11-30T14:22:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/admin/users
|
||||
|
||||
### Purpose
|
||||
|
||||
Create a new user account in the hackathon system.
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- Requires admin (backoffice) scope: e.g. `role=admin`
|
||||
|
||||
### Request Body Schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"fullname": "Jane Doe", // Required, 1-100 chars
|
||||
"bio": "Experienced developer", // Optional, max 500 chars
|
||||
"location": "Jakarta", // Required, from predefined list
|
||||
"is_active": true, // Required, boolean
|
||||
"skills": ["Backend Developer"], // Optional, array of valid skills
|
||||
"avatar": "https://example.com/images/..." // Optional, URL
|
||||
}
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
POST /api/v1/admin/users
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"fullname": "Jane Doe",
|
||||
"bio": "Experienced developer passionate about AI and machine learning",
|
||||
"location": "Jakarta",
|
||||
"is_active": true,
|
||||
"skills": ["Backend Developer", "Data Scientist"],
|
||||
"avatar": "https://example.com/images/..."
|
||||
}
|
||||
```
|
||||
|
||||
### Response Schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"data": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"avatar": "https://example.com/avatars/generated_url.jpg",
|
||||
"fullname": "Jane Doe",
|
||||
"bio": "Experienced developer passionate about AI and machine learning",
|
||||
"location": "Jakarta",
|
||||
"is_active": true,
|
||||
"skills": ["Backend Developer", "Data Scientist"],
|
||||
"created_at": "2024-12-01T10:30:00Z",
|
||||
"updated_at": "2024-12-01T10:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PUT /api/v1/admin/users/{user_id}
|
||||
|
||||
### Purpose
|
||||
|
||||
Update an existing user's profile information.
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- Requires admin (backoffice) scope: e.g. `role=admin`
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------ | -------- | ----------- |
|
||||
| `user_id` | string | Yes | User UUID |
|
||||
|
||||
### Request Body Schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"fullname": "Jane Smith", // Optional, 1-100 chars
|
||||
"bio": "Senior developer", // Optional, max 500 chars, null to clear
|
||||
"location": "Bandung", // Optional, from predefined list
|
||||
"is_active": false, // Optional, boolean
|
||||
"skills": ["Full Stack Developer"], // Optional, array of valid skills
|
||||
"avatar": "https://example.com/images/..." // Optional, URL, null to remove
|
||||
}
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
PUT /api/v1/admin/users/550e8400-e29b-41d4-a716-446655440000
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"fullname": "Jane Smith",
|
||||
"location": "Bandung",
|
||||
"is_active": false,
|
||||
"skills": ["Full Stack Developer", "Product Manager"]
|
||||
}
|
||||
```
|
||||
|
||||
### Response Schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"data": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"avatar": "https://example.com/avatars/user1.jpg",
|
||||
"fullname": "Jane Smith",
|
||||
"bio": "Experienced developer passionate about AI and machine learning",
|
||||
"location": "Bandung",
|
||||
"is_active": false,
|
||||
"skills": ["Full Stack Developer", "Product Manager"],
|
||||
"created_at": "2024-11-15T08:30:00Z",
|
||||
"updated_at": "2024-12-01T10:45:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DELETE /api/v1/admin/users/{user_id}
|
||||
|
||||
### Purpose
|
||||
|
||||
(Soft) Delete a user account from the hackathon system.
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- Requires admin (backoffice) scope: e.g. `role=admin`
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------ | -------- | ----------- |
|
||||
| `user_id` | string | Yes | User UUID |
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
DELETE /api/v1/admin/users/550e8400-e29b-41d4-a716-446655440000
|
||||
```
|
||||
|
||||
### Response Schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"data": {
|
||||
"message": "User successfully deleted",
|
||||
"deleted_user_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"deleted_at": "2024-12-01T10:50:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Error Responses
|
||||
|
||||
### User Management Endpoints
|
||||
|
||||
| Status | Code | Message | Notes |
|
||||
| ------ | --------------------- | -------------------------------- | ---------------------------- |
|
||||
| 400 | `validation_error` | `Invalid request data` | Field validation failures |
|
||||
| 401 | `unauthorized` | `Authentication required` | Missing/invalid token |
|
||||
| 403 | `forbidden` | `Insufficient permissions` | Lacks required scope |
|
||||
| 404 | `user_not_found` | `User not found` | Invalid user ID |
|
||||
| 409 | `user_already_exists` | `User with email already exists` | Duplicate user creation |
|
||||
| 413 | `payload_too_large` | `Avatar file too large` | Avatar exceeds size limit |
|
||||
| 422 | `invalid_skill` | `Invalid skill specified` | Skill not in allowed list |
|
||||
| 422 | `invalid_location` | `Invalid location specified` | Location not in allowed list |
|
||||
| 429 | `rate_limited` | `Too many requests` | Rate limiting |
|
||||
| 500 | `internal_error` | `Unexpected server error` | Unhandled exception |
|
||||
|
||||
### Validation Error Details
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"error": {
|
||||
"code": "validation_error",
|
||||
"message": "Invalid request data",
|
||||
"details": [
|
||||
{
|
||||
"field": "fullname",
|
||||
"code": "required",
|
||||
"message": "Full name is required"
|
||||
},
|
||||
{
|
||||
"field": "location",
|
||||
"code": "invalid_choice",
|
||||
"message": "Location must be one of: Jakarta, Bandung, Surabaya, Medan, Yogyakarta"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
- **Dashboard**: 30 requests / minute / admin user
|
||||
- **User Management**: 100 requests / minute / admin user
|
||||
- **File Upload**: 10 avatar uploads / minute / admin user
|
||||
- Return 429 with `Retry-After` header
|
||||
|
||||
## Avatar Handling
|
||||
|
||||
- **Supported formats**: JPEG, PNG, WebP
|
||||
- **Max file size**: 5MB
|
||||
- **Recommended dimensions**: 400x400px
|
||||
- **Storage**: Uploaded avatars are processed and stored with generated URLs
|
||||
- **URL response**: Always return publicly accessible HTTPS URLs
|
||||
|
||||
---
|
||||
|
||||
**Revision History**
|
||||
|
||||
- v1.0.0 (2025-11-30): Initial contract drafted.
|
||||
- v2.0.0 (2025-12-01): Added user management endpoints with filtering, pagination, CRUD operations, and avatar handling.
|
||||
@@ -0,0 +1,61 @@
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../hooks/auth';
|
||||
|
||||
// Hackathon Backend API Base URL
|
||||
const HACKATHON_API_URL = 'https://api.hackathon.imphnen.dev/api/v1';
|
||||
|
||||
// Create axios instance for hackathon backend
|
||||
export const hackathonApi = axios.create({
|
||||
baseURL: HACKATHON_API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add auth token interceptor
|
||||
hackathonApi.interceptors.request.use(
|
||||
(config) => {
|
||||
const { session } = useAuthStore.getState();
|
||||
if (session?.token?.access_token) {
|
||||
config.headers.Authorization = `Bearer ${session.token.access_token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(new Error(error.message || 'Request failed'));
|
||||
}
|
||||
);
|
||||
|
||||
// Error handling interceptor
|
||||
hackathonApi.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// Handle 401 - clear session and redirect to login
|
||||
// But skip redirect if already on auth pages (to avoid reload on login failure)
|
||||
if (error.response?.status === 401) {
|
||||
const isAuthPage = globalThis.window !== undefined && globalThis.location.pathname.startsWith('/auth');
|
||||
|
||||
// Only clear session and redirect if not on auth page
|
||||
if (!isAuthPage) {
|
||||
useAuthStore.getState().clearSession();
|
||||
if (globalThis.window !== undefined) {
|
||||
globalThis.location.href = '/auth/login';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If backend sends a message, use it
|
||||
const backendMsg = error?.response?.data?.message;
|
||||
if (backendMsg && typeof backendMsg === 'string') {
|
||||
return Promise.reject(new Error(backendMsg));
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(error.message || 'Request failed'));
|
||||
}
|
||||
);
|
||||
|
||||
// API Response wrapper type
|
||||
export interface HackathonApiResponse<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export * from './gacha';
|
||||
export * from './users';
|
||||
export * from './mentors';
|
||||
export * from './upload';
|
||||
export * from './hackathon';
|
||||
|
||||
// Common API response wrapper interface
|
||||
export interface ApiResponse<T> {
|
||||
|
||||
@@ -1,57 +1,251 @@
|
||||
import { supabase } from '../../supabase';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import * as authApi from '../../api/auth';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
|
||||
import { useAuthStore } from './use-auth-store';
|
||||
|
||||
export * from './use-auth-store';
|
||||
|
||||
// React Query hooks for auth API
|
||||
export const usePostLogin = () => {
|
||||
// Types matching backend response
|
||||
interface TokenInfo {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
fullname: string;
|
||||
phone_number?: string;
|
||||
avatar?: string;
|
||||
birthdate?: string;
|
||||
gender?: string;
|
||||
is_active: boolean;
|
||||
location?: string;
|
||||
bio?: string;
|
||||
skills?: string[];
|
||||
role_id?: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface AuthResponse {
|
||||
token: TokenInfo;
|
||||
user: User;
|
||||
}
|
||||
|
||||
interface SessionResponse {
|
||||
user: User;
|
||||
}
|
||||
|
||||
interface MessageResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Login request type
|
||||
interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// Signup request type
|
||||
interface SignupRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
fullname: string;
|
||||
}
|
||||
|
||||
// GitHub auth request type
|
||||
interface GitHubAuthRequest {
|
||||
code: string;
|
||||
}
|
||||
|
||||
// Forgot password request type
|
||||
interface ForgotPasswordRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Reset password request type
|
||||
interface ResetPasswordRequest {
|
||||
access_token: string;
|
||||
new_password: string;
|
||||
}
|
||||
|
||||
// Backend API-based auth hooks
|
||||
|
||||
// Email/Password Login
|
||||
export const useLogin = () => {
|
||||
const { setSession } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: authApi.postLogin,
|
||||
mutationFn: async (data: LoginRequest) => {
|
||||
const response = await hackathonApi.post<HackathonApiResponse<AuthResponse>>(
|
||||
'/auth/login',
|
||||
data
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setSession({
|
||||
token: data.token,
|
||||
user: {
|
||||
id: data.user.id,
|
||||
email: data.user.email,
|
||||
fullname: data.user.fullname,
|
||||
phone_number: data.user.phone_number || '',
|
||||
avatar: data.user.avatar || '',
|
||||
birthdate: data.user.birthdate || '',
|
||||
gender: data.user.gender || '',
|
||||
is_active: data.user.is_active,
|
||||
location: data.user.location,
|
||||
bio: data.user.bio,
|
||||
skills: data.user.skills,
|
||||
role: {
|
||||
id: '',
|
||||
name: 'user',
|
||||
permissions: [],
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const usePostRegister = () => {
|
||||
// Email/Password Signup - returns message only (user needs to activate via email)
|
||||
export const useSignup = () => {
|
||||
return useMutation({
|
||||
mutationFn: authApi.postRegister,
|
||||
mutationFn: async (data: SignupRequest) => {
|
||||
const response = await hackathonApi.post<HackathonApiResponse<MessageResponse>>(
|
||||
'/auth/signup',
|
||||
data
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const usePostVerifyEmail = () => {
|
||||
// GitHub OAuth - exchange code for token
|
||||
export const useGitHubCallback = () => {
|
||||
const { setSession } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: authApi.postVerifyEmail,
|
||||
mutationFn: async (data: GitHubAuthRequest) => {
|
||||
const response = await hackathonApi.post<HackathonApiResponse<AuthResponse>>(
|
||||
'/auth/github',
|
||||
data
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setSession({
|
||||
token: data.token,
|
||||
user: {
|
||||
id: data.user.id,
|
||||
email: data.user.email,
|
||||
fullname: data.user.fullname,
|
||||
phone_number: data.user.phone_number || '',
|
||||
avatar: data.user.avatar || '',
|
||||
birthdate: data.user.birthdate || '',
|
||||
gender: data.user.gender || '',
|
||||
is_active: data.user.is_active,
|
||||
location: data.user.location,
|
||||
bio: data.user.bio,
|
||||
skills: data.user.skills,
|
||||
role: {
|
||||
id: '',
|
||||
name: 'user',
|
||||
permissions: [],
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const usePostSendOtp = () => {
|
||||
return useMutation({
|
||||
mutationFn: authApi.postSendOtp,
|
||||
// Get current session (protected)
|
||||
export const useSession = () => {
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['auth-session'],
|
||||
queryFn: async () => {
|
||||
const response = await hackathonApi.get<HackathonApiResponse<SessionResponse>>(
|
||||
'/auth/session'
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
enabled: !!session?.token,
|
||||
});
|
||||
};
|
||||
|
||||
export const useGoogleCallback = () => {
|
||||
// Forgot password
|
||||
export const useForgotPassword = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({ code, state }: { code: string; state: string }) =>
|
||||
authApi.postGoogleCallback(code, state),
|
||||
mutationFn: async (data: ForgotPasswordRequest) => {
|
||||
const response = await hackathonApi.post<HackathonApiResponse<MessageResponse>>(
|
||||
'/auth/forgot-password',
|
||||
data
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Supabase GitHub OAuth hook
|
||||
// Reset password
|
||||
export const useResetPassword = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (data: ResetPasswordRequest) => {
|
||||
const response = await hackathonApi.post<HackathonApiResponse<MessageResponse>>(
|
||||
'/auth/reset-password',
|
||||
data
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Sign out (clears local session)
|
||||
export const useSignOut = () => {
|
||||
const { clearSession } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
// No backend call needed - just clear local session
|
||||
clearSession();
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// GitHub OAuth URL helper
|
||||
// The frontend needs to redirect to GitHub with the client_id
|
||||
// After GitHub redirects back with a code, use useGitHubCallback
|
||||
export const getGitHubOAuthUrl = (clientId: string, redirectUri: string) => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: 'read:user user:email',
|
||||
});
|
||||
return `https://github.com/login/oauth/authorize?${params.toString()}`;
|
||||
};
|
||||
|
||||
// Backward compatibility hooks - these wrap the new backend API
|
||||
|
||||
// GitHub OAuth hook (backward compatible)
|
||||
export const useGitHubAuth = () => {
|
||||
const signInWithGitHub = async () => {
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'github',
|
||||
options: {
|
||||
redirectTo: `${globalThis.location.origin}/auth/callback`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
// Get GitHub client ID from environment
|
||||
const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID || '';
|
||||
if (!clientId) {
|
||||
throw new Error('GitHub Client ID not configured. Set VITE_GITHUB_CLIENT_ID environment variable.');
|
||||
}
|
||||
|
||||
// Return the OAuth URL for debugging
|
||||
return data;
|
||||
const redirectUri = `${globalThis.location.origin}/auth/callback`;
|
||||
const url = getGitHubOAuthUrl(clientId, redirectUri);
|
||||
|
||||
return { url };
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -59,45 +253,33 @@ export const useGitHubAuth = () => {
|
||||
};
|
||||
};
|
||||
|
||||
// Supabase Email/Password authentication hook
|
||||
// Email/Password auth hook (backward compatible)
|
||||
export const useEmailAuth = () => {
|
||||
const loginMutation = useLogin();
|
||||
const signupMutation = useSignup();
|
||||
const { clearSession } = useAuthStore();
|
||||
|
||||
const signInWithEmail = async (email: string, password: string) => {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
const result = await loginMutation.mutateAsync({ email, password });
|
||||
return {
|
||||
user: result.user,
|
||||
session: {
|
||||
access_token: result.token.access_token,
|
||||
refresh_token: result.token.refresh_token,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
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 result = await signupMutation.mutateAsync({ email, password, fullname });
|
||||
// Signup only returns a message (user needs to verify email first)
|
||||
return {
|
||||
message: result.message,
|
||||
};
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
clearSession();
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -106,3 +288,58 @@ export const useEmailAuth = () => {
|
||||
signOut,
|
||||
};
|
||||
};
|
||||
|
||||
// Legacy hooks for old API compatibility (deprecated)
|
||||
|
||||
/** @deprecated Use useLogin instead */
|
||||
export const usePostLogin = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (data: LoginRequest) => {
|
||||
const response = await hackathonApi.post<HackathonApiResponse<AuthResponse>>(
|
||||
'/auth/login',
|
||||
data
|
||||
);
|
||||
return { data: response.data.data };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** @deprecated Use useSignup instead */
|
||||
export const usePostRegister = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (data: SignupRequest) => {
|
||||
const response = await hackathonApi.post<HackathonApiResponse<AuthResponse>>(
|
||||
'/auth/signup',
|
||||
data
|
||||
);
|
||||
return { data: response.data.data };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** @deprecated Not needed with new backend */
|
||||
export const usePostVerifyEmail = () => {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
throw new Error('Email verification not required with new backend');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** @deprecated Not needed with new backend */
|
||||
export const usePostSendOtp = () => {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
throw new Error('OTP not required with new backend');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** @deprecated Use useGitHubCallback instead */
|
||||
export const useGoogleCallback = () => {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
throw new Error('Google OAuth not supported. Use GitHub OAuth instead.');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { supabase } from '../../supabase';
|
||||
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
|
||||
import { useAuthStore } from '../auth';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
@@ -24,101 +23,22 @@ export const messageKeys = {
|
||||
team: (teamId: string) => [...messageKeys.all, 'team', teamId] as const,
|
||||
};
|
||||
|
||||
// Fetch messages for a team
|
||||
// Fetch messages for a team with polling
|
||||
export const useTeamMessages = (teamId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
return 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[];
|
||||
const response = await hackathonApi.get<HackathonApiResponse<Message[]>>(
|
||||
`/chat/teams/${teamId}`
|
||||
);
|
||||
return response.data.data || [];
|
||||
},
|
||||
enabled: !!teamId,
|
||||
// Poll every 3 seconds for new messages
|
||||
refetchInterval: 3000,
|
||||
// Keep refetching even when window loses focus
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
// 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
|
||||
@@ -132,26 +52,15 @@ export const useSendMessage = (teamId: string) => {
|
||||
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();
|
||||
const response = await hackathonApi.post<HackathonApiResponse<Message>>(
|
||||
`/chat/teams/${teamId}`,
|
||||
{ message }
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
throw new Error(error.message || 'Failed to send message');
|
||||
}
|
||||
|
||||
return data;
|
||||
return response.data.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Realtime will handle adding the message to the list
|
||||
// But we can invalidate to ensure consistency
|
||||
// Invalidate to trigger immediate refetch
|
||||
queryClient.invalidateQueries({ queryKey: messageKeys.team(teamId) });
|
||||
},
|
||||
});
|
||||
@@ -163,18 +72,9 @@ export const useDeleteMessage = (teamId: string) => {
|
||||
|
||||
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');
|
||||
}
|
||||
await hackathonApi.delete(`/chat/messages/${messageId}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Realtime will handle removing the message from the list
|
||||
queryClient.invalidateQueries({ queryKey: messageKeys.team(teamId) });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import * as teamsApi from '../../api/teams';
|
||||
import { supabase, getAuthenticatedClient } from '../../supabase';
|
||||
import { useMutation, useQuery, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
|
||||
import { useAuthStore } from '../auth';
|
||||
import type {
|
||||
TCreateTeamRequest,
|
||||
@@ -24,6 +23,101 @@ export const teamKeys = {
|
||||
myInvitations: () => [...teamKeys.all, 'my-invitations'] as const,
|
||||
};
|
||||
|
||||
// API response types
|
||||
interface TeamMember {
|
||||
id: string;
|
||||
team_id: string;
|
||||
user_id: string;
|
||||
role: string;
|
||||
status: string;
|
||||
joined_at: string;
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
fullname: string;
|
||||
avatar: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
logo?: string;
|
||||
banner?: string;
|
||||
description?: string;
|
||||
city?: string;
|
||||
visibility: string;
|
||||
leader_id: string;
|
||||
created_at: string;
|
||||
leader?: {
|
||||
id: string;
|
||||
email: string;
|
||||
fullname: string;
|
||||
avatar: string;
|
||||
};
|
||||
members?: TeamMember[];
|
||||
member_count?: number;
|
||||
has_submission?: boolean;
|
||||
}
|
||||
|
||||
interface JoinRequest {
|
||||
id: string;
|
||||
team_id: string;
|
||||
user_id: string;
|
||||
message?: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
fullname: string;
|
||||
avatar: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Invitation {
|
||||
id: string;
|
||||
team_id: string;
|
||||
inviter_id: string;
|
||||
invitee_email: string;
|
||||
invitee_id?: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
team?: Team;
|
||||
inviter?: {
|
||||
id: string;
|
||||
fullname: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Submission {
|
||||
id: string;
|
||||
team_id: string;
|
||||
project_name: string;
|
||||
description?: string;
|
||||
repository_url?: string;
|
||||
demo_url?: string;
|
||||
video_url?: string;
|
||||
presentation_url?: string;
|
||||
status: string;
|
||||
submitted_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// List response type with pagination
|
||||
interface ListResponseWithMeta<T> {
|
||||
message: string;
|
||||
data: T[];
|
||||
meta: {
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_page: number;
|
||||
total_data: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Team CRUD Hooks
|
||||
export const useTeams = (params?: {
|
||||
page?: number;
|
||||
@@ -31,100 +125,78 @@ export const useTeams = (params?: {
|
||||
city?: string;
|
||||
visibility?: string;
|
||||
search?: string;
|
||||
minMembers?: number;
|
||||
maxMembers?: number;
|
||||
hasSubmission?: boolean;
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryKey: teamKeys.list(params),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
// Supabase client now has auth context from setSession()
|
||||
let query = supabase.from('teams').select(`
|
||||
*,
|
||||
members:team_members(id)
|
||||
`);
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.page) queryParams.append('page', String(params.page));
|
||||
if (params?.limit) queryParams.append('per_page', String(params.limit));
|
||||
if (params?.search) queryParams.append('search', params.search);
|
||||
if (params?.city) queryParams.append('city', params.city);
|
||||
if (params?.visibility) queryParams.append('visibility', params.visibility);
|
||||
if (params?.minMembers) queryParams.append('min_members', String(params.minMembers));
|
||||
if (params?.maxMembers) queryParams.append('max_members', String(params.maxMembers));
|
||||
if (params?.hasSubmission !== undefined) queryParams.append('has_submission', String(params.hasSubmission));
|
||||
|
||||
// Filter by visibility
|
||||
if (params?.visibility) {
|
||||
query = query.eq('visibility', params.visibility);
|
||||
}
|
||||
const response = await hackathonApi.get<ListResponseWithMeta<Team>>(
|
||||
`/teams/browse${queryParams.toString() ? `?${queryParams.toString()}` : ''}`
|
||||
);
|
||||
|
||||
// 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: [] };
|
||||
}
|
||||
const { data, meta } = response.data;
|
||||
return {
|
||||
teams: data || [],
|
||||
total: meta?.total_data || 0,
|
||||
page: meta?.page || 1,
|
||||
perPage: meta?.per_page || 12,
|
||||
totalPages: meta?.total_page || 1,
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Infinite scroll teams hook
|
||||
const TEAMS_PAGE_SIZE = 12;
|
||||
|
||||
export const useInfiniteTeams = (params?: {
|
||||
city?: string;
|
||||
visibility?: string;
|
||||
search?: string;
|
||||
}) => {
|
||||
return useInfiniteQuery({
|
||||
queryKey: [...teamKeys.lists(), 'infinite', params],
|
||||
queryFn: async ({ pageParam = 1 }) => {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('page', String(pageParam));
|
||||
queryParams.append('limit', String(TEAMS_PAGE_SIZE));
|
||||
if (params?.search) queryParams.append('search', params.search);
|
||||
if (params?.city) queryParams.append('city', params.city);
|
||||
if (params?.visibility) queryParams.append('visibility', params.visibility);
|
||||
|
||||
const response = await hackathonApi.get<HackathonApiResponse<Team[]>>(
|
||||
`/teams/browse?${queryParams.toString()}`
|
||||
);
|
||||
|
||||
const teams = response.data.data || [];
|
||||
return {
|
||||
data: teams,
|
||||
nextPage: teams.length === TEAMS_PAGE_SIZE ? pageParam + 1 : undefined,
|
||||
};
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) => lastPage.nextPage,
|
||||
});
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
const response = await hackathonApi.get<HackathonApiResponse<Team>>(`/teams/${teamId}`);
|
||||
return { data: response.data.data };
|
||||
},
|
||||
enabled: enabled && !!teamId,
|
||||
});
|
||||
@@ -140,50 +212,16 @@ export const useCreateTeam = () => {
|
||||
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();
|
||||
const response = await hackathonApi.post<HackathonApiResponse<Team>>('/teams', {
|
||||
name: data.name,
|
||||
logo: data.logo,
|
||||
banner: data.banner,
|
||||
description: data.description,
|
||||
city: data.city,
|
||||
visibility: data.visibility,
|
||||
});
|
||||
|
||||
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 };
|
||||
return { data: response.data.data };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
|
||||
@@ -202,27 +240,16 @@ export const useUpdateTeam = (teamId: string) => {
|
||||
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();
|
||||
const response = await hackathonApi.put<HackathonApiResponse<Team>>(`/teams/${teamId}`, {
|
||||
name: data.name,
|
||||
logo: data.logo,
|
||||
banner: data.banner,
|
||||
description: data.description,
|
||||
city: data.city,
|
||||
visibility: data.visibility,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to update team:', error);
|
||||
throw new Error(error.message || 'Failed to update team');
|
||||
}
|
||||
|
||||
return { data: team };
|
||||
return { data: response.data.data };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
|
||||
@@ -231,32 +258,13 @@ export const useUpdateTeam = (teamId: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Team Members Hooks
|
||||
// Team Members Hooks - using team detail endpoint which includes members
|
||||
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 || [] };
|
||||
const response = await hackathonApi.get<HackathonApiResponse<Team>>(`/teams/${teamId}`);
|
||||
return { data: response.data.data?.members || [] };
|
||||
},
|
||||
enabled: enabled && !!teamId,
|
||||
});
|
||||
@@ -272,24 +280,12 @@ export const useInviteMember = (teamId: string) => {
|
||||
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();
|
||||
const response = await hackathonApi.post<HackathonApiResponse<Invitation>>(
|
||||
`/teams/${teamId}/invite`,
|
||||
{ invitee_email: data.email }
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to create invitation:', error);
|
||||
throw new Error(error.message || 'Failed to send invitation');
|
||||
}
|
||||
|
||||
return { data: invitation };
|
||||
return { data: response.data.data };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.members(teamId) });
|
||||
@@ -301,8 +297,11 @@ export const useManageMember = (teamId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, data }: { userId: string; data: any }) =>
|
||||
teamsApi.manageMember(teamId, userId, data),
|
||||
mutationFn: async ({ userId, data }: { userId: string; data: { role?: string; status?: string } }) => {
|
||||
// This endpoint may not exist in the backend yet
|
||||
// For now, we'll throw an error indicating it's not implemented
|
||||
throw new Error('Manage member functionality not yet implemented in backend');
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.members(teamId) });
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
|
||||
@@ -320,38 +319,7 @@ export const useRemoveMember = (teamId: string) => {
|
||||
throw new Error('You must be logged in to remove a member');
|
||||
}
|
||||
|
||||
// Verify the current user is the team leader
|
||||
const { data: team, error: teamError } = await supabase
|
||||
.from('teams')
|
||||
.select('leader_id')
|
||||
.eq('id', teamId)
|
||||
.single();
|
||||
|
||||
if (teamError || !team) {
|
||||
throw new Error('Team not found');
|
||||
}
|
||||
|
||||
if (team.leader_id !== session.user.id) {
|
||||
throw new Error('Only the team leader can remove members');
|
||||
}
|
||||
|
||||
// Cannot remove the leader
|
||||
if (userId === team.leader_id) {
|
||||
throw new Error('Cannot remove the team leader');
|
||||
}
|
||||
|
||||
// Delete the team member record
|
||||
const { error: deleteError } = await supabase
|
||||
.from('team_members')
|
||||
.delete()
|
||||
.eq('team_id', teamId)
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (deleteError) {
|
||||
console.error('Failed to remove member:', deleteError);
|
||||
throw new Error(deleteError.message || 'Failed to remove member');
|
||||
}
|
||||
|
||||
await hackathonApi.delete(`/teams/${teamId}/members/${userId}`);
|
||||
return { success: true };
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -372,24 +340,12 @@ export const useJoinTeam = () => {
|
||||
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();
|
||||
const response = await hackathonApi.post<HackathonApiResponse<JoinRequest>>(
|
||||
`/join-requests/teams/${teamId}`,
|
||||
{ message: data.message }
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to create join request:', error);
|
||||
throw new Error(error.message || 'Failed to send join request');
|
||||
}
|
||||
|
||||
return { data: joinRequest };
|
||||
return { data: response.data.data };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
|
||||
@@ -401,28 +357,10 @@ 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)
|
||||
.eq('status', 'pending')
|
||||
.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 || [] };
|
||||
const response = await hackathonApi.get<HackathonApiResponse<JoinRequest[]>>(
|
||||
`/join-requests/teams/${teamId}/pending`
|
||||
);
|
||||
return { data: response.data.data || [] };
|
||||
},
|
||||
enabled: enabled && !!teamId,
|
||||
});
|
||||
@@ -438,76 +376,14 @@ export const useRespondToJoinRequest = (teamId: string) => {
|
||||
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();
|
||||
// Backend uses 'accept' instead of 'approve'
|
||||
const backendAction = action === 'approve' ? 'accept' : 'reject';
|
||||
|
||||
if (fetchError || !joinRequest) {
|
||||
throw new Error('Join request not found');
|
||||
}
|
||||
await hackathonApi.post(`/join-requests/${requestId}/respond`, {
|
||||
action: backendAction,
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// Parse Supabase error for user-friendly message
|
||||
let errorMsg = memberError.message;
|
||||
if (errorMsg.includes('Team already has 5 members') || errorMsg.includes('Team cannot have more than 5 members')) {
|
||||
errorMsg = 'Team is full! Maximum 5 members allowed.';
|
||||
} else if (errorMsg.includes('already in a team') || errorMsg.includes('User is already in a team')) {
|
||||
errorMsg = 'This user is already in another team.';
|
||||
} else if (errorMsg.includes('Bulk insert')) {
|
||||
errorMsg = 'Invalid operation detected.';
|
||||
}
|
||||
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
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' };
|
||||
}
|
||||
return { success: true, action };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.joinRequests(teamId) });
|
||||
@@ -524,49 +400,10 @@ export const useMyInvitations = () => {
|
||||
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 || [] };
|
||||
const response = await hackathonApi.get<HackathonApiResponse<Invitation[]>>('/invitations/my');
|
||||
return { data: response.data.data || [] };
|
||||
},
|
||||
enabled: !!session?.user?.email,
|
||||
enabled: !!session?.user?.id,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -580,75 +417,9 @@ export const useRespondToInvitation = () => {
|
||||
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();
|
||||
await hackathonApi.post(`/invitations/${invitationId}/respond`, { action });
|
||||
|
||||
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' };
|
||||
}
|
||||
return { success: true, action };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.myInvitations() });
|
||||
@@ -665,38 +436,10 @@ export const useMyTeams = () => {
|
||||
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) || [];
|
||||
|
||||
const response = await hackathonApi.get<HackathonApiResponse<any[]>>('/teams/my');
|
||||
const rawData = response.data.data || [];
|
||||
// Unwrap nested team data if present (API returns [{team: {...}}] or [{id, name, ...}])
|
||||
const teams = rawData.map((item: any) => item.team || item);
|
||||
return { data: teams };
|
||||
},
|
||||
enabled: !!session?.user?.id,
|
||||
@@ -709,7 +452,60 @@ export const useSubmitProject = (teamId: string) => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: TSubmitProjectRequest) => {
|
||||
return await teamsApi.submitProject(teamId, data);
|
||||
let submissionId: string;
|
||||
|
||||
// First, check if submission exists
|
||||
try {
|
||||
const existingResponse = await hackathonApi.get<HackathonApiResponse<Submission | null>>(
|
||||
`/submissions/teams/${teamId}`
|
||||
);
|
||||
|
||||
if (existingResponse.data.data?.id) {
|
||||
// Update existing submission
|
||||
const response = await hackathonApi.put<HackathonApiResponse<Submission>>(
|
||||
`/submissions/${existingResponse.data.data.id}`,
|
||||
{
|
||||
project_name: data.project_name,
|
||||
description: data.description,
|
||||
repository_url: data.repository_url,
|
||||
demo_url: data.demo_url,
|
||||
video_url: data.video_url,
|
||||
presentation_url: data.presentation_url,
|
||||
screenshots: data.screenshots,
|
||||
}
|
||||
);
|
||||
submissionId = response.data.data.id;
|
||||
} else {
|
||||
throw new Error('No existing submission');
|
||||
}
|
||||
} catch {
|
||||
// No existing submission, create new one
|
||||
const response = await hackathonApi.post<HackathonApiResponse<Submission>>(
|
||||
`/submissions/teams/${teamId}`,
|
||||
{
|
||||
project_name: data.project_name,
|
||||
description: data.description,
|
||||
repository_url: data.repository_url,
|
||||
demo_url: data.demo_url,
|
||||
video_url: data.video_url,
|
||||
presentation_url: data.presentation_url,
|
||||
screenshots: data.screenshots,
|
||||
}
|
||||
);
|
||||
submissionId = response.data.data.id;
|
||||
}
|
||||
|
||||
// Step 2: Submit the project (draft -> pending_verification)
|
||||
await hackathonApi.post<HackathonApiResponse<Submission>>(
|
||||
`/submissions/${submissionId}/submit`
|
||||
);
|
||||
|
||||
// Step 3: Confirm the submission (pending_verification -> submitted)
|
||||
const finalResponse = await hackathonApi.post<HackathonApiResponse<Submission>>(
|
||||
`/submissions/${submissionId}/confirm`
|
||||
);
|
||||
|
||||
return { data: finalResponse.data.data };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.submission(teamId) });
|
||||
@@ -722,8 +518,10 @@ export const useTeamSubmission = (teamId: string, enabled = true) => {
|
||||
return useQuery({
|
||||
queryKey: teamKeys.submission(teamId),
|
||||
queryFn: async () => {
|
||||
const submission = await teamsApi.getTeamSubmission(teamId);
|
||||
return { data: submission };
|
||||
const response = await hackathonApi.get<HackathonApiResponse<Submission | null>>(
|
||||
`/submissions/teams/${teamId}`
|
||||
);
|
||||
return { data: response.data.data };
|
||||
},
|
||||
enabled: enabled && !!teamId,
|
||||
});
|
||||
@@ -732,10 +530,17 @@ export const useTeamSubmission = (teamId: string, enabled = true) => {
|
||||
// Leave Team Hook
|
||||
export const useLeaveTeam = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (teamId: string) => {
|
||||
return await teamsApi.leaveTeam(teamId);
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('You must be logged in to leave a team');
|
||||
}
|
||||
|
||||
// Use the dedicated leave team endpoint
|
||||
await hackathonApi.post(`/teams/${teamId}/leave`);
|
||||
return { success: true };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.myTeams() });
|
||||
@@ -744,43 +549,36 @@ export const useLeaveTeam = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Get Teams by User ID
|
||||
// Delete Team Hook (Leader only)
|
||||
export const useDeleteTeam = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (teamId: string) => {
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('You must be logged in to delete a team');
|
||||
}
|
||||
|
||||
await hackathonApi.delete(`/teams/${teamId}`);
|
||||
return { success: true };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.myTeams() });
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Get Teams by User ID - uses /users/{user_id}/teams
|
||||
export const useTeamsByUserId = (userId: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['teams-by-user', userId],
|
||||
queryFn: async () => {
|
||||
if (!userId) {
|
||||
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', userId)
|
||||
.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) || [];
|
||||
|
||||
const response = await hackathonApi.get<HackathonApiResponse<any[]>>(`/users/${userId}/teams`);
|
||||
const rawData = response.data.data || [];
|
||||
// Unwrap nested team data if present (API returns [{team: {...}}] or [{id, name, ...}])
|
||||
const teams = rawData.map((item: any) => item.team || item);
|
||||
return { data: teams };
|
||||
},
|
||||
enabled: !!userId,
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { supabase, getAuthenticatedClient } from '../../supabase';
|
||||
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
|
||||
import { useAuthStore } from '../auth';
|
||||
|
||||
// Supabase Storage-based upload hooks
|
||||
// Upload response type from backend
|
||||
interface UploadResponse {
|
||||
url: string;
|
||||
}
|
||||
|
||||
// Helper function to convert File to base64
|
||||
const fileToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
// Remove the data:image/xxx;base64, prefix
|
||||
const base64 = (reader.result as string).split(',')[1];
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = (error) => reject(error);
|
||||
});
|
||||
};
|
||||
|
||||
// Backend API-based upload hooks
|
||||
|
||||
export const useUploadFile = () => {
|
||||
const { session } = useAuthStore();
|
||||
@@ -14,30 +33,18 @@ export const useUploadFile = () => {
|
||||
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}`;
|
||||
const base64Data = await fileToBase64(file);
|
||||
|
||||
// Supabase client now has auth context from setSession()
|
||||
const { error } = await supabase.storage
|
||||
.from('hackathon-uploads')
|
||||
.upload(filePath, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: true,
|
||||
});
|
||||
const response = await hackathonApi.post<HackathonApiResponse<UploadResponse>>(
|
||||
'/upload/team',
|
||||
{
|
||||
filename: file.name,
|
||||
content_type: file.type,
|
||||
data: base64Data,
|
||||
}
|
||||
);
|
||||
|
||||
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 } };
|
||||
return { data: { url: response.data.data.url } };
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -52,34 +59,131 @@ export const useUploadAvatar = () => {
|
||||
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');
|
||||
// Validate file type
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
throw new Error('Invalid file type. Allowed types: JPEG, PNG, WebP, GIF');
|
||||
}
|
||||
|
||||
// Get public URL
|
||||
const { data: publicUrlData } = supabase.storage
|
||||
.from('hackathon-uploads')
|
||||
.getPublicUrl(filePath);
|
||||
// Validate file size (max 5MB)
|
||||
const maxSize = 5 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
throw new Error('File too large. Maximum size: 5MB');
|
||||
}
|
||||
|
||||
return { data: { url: publicUrlData.publicUrl } };
|
||||
const base64Data = await fileToBase64(file);
|
||||
|
||||
const response = await hackathonApi.post<HackathonApiResponse<UploadResponse>>(
|
||||
'/upload/avatar',
|
||||
{
|
||||
filename: file.name,
|
||||
content_type: file.type,
|
||||
data: base64Data,
|
||||
}
|
||||
);
|
||||
|
||||
return { data: { url: response.data.data.url } };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUploadTeamFile = () => {
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['upload-team-file'],
|
||||
mutationFn: async (file: File) => {
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('You must be logged in to upload files');
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'application/pdf',
|
||||
];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
throw new Error('Invalid file type. Allowed types: JPEG, PNG, WebP, GIF, PDF');
|
||||
}
|
||||
|
||||
// Validate file size (max 20MB)
|
||||
const maxSize = 20 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
throw new Error('File too large. Maximum size: 20MB');
|
||||
}
|
||||
|
||||
const base64Data = await fileToBase64(file);
|
||||
|
||||
const response = await hackathonApi.post<HackathonApiResponse<UploadResponse>>(
|
||||
'/upload/team',
|
||||
{
|
||||
filename: file.name,
|
||||
content_type: file.type,
|
||||
data: base64Data,
|
||||
}
|
||||
);
|
||||
|
||||
return { data: { url: response.data.data.url } };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUploadSubmission = () => {
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['upload-submission'],
|
||||
mutationFn: async (file: File) => {
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('You must be logged in to upload submissions');
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'application/pdf',
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
throw new Error(
|
||||
'Invalid file type. Allowed types: Images, PDF, ZIP, MP4, WebM'
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file size (max 50MB)
|
||||
const maxSize = 50 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
throw new Error('File too large. Maximum size: 50MB');
|
||||
}
|
||||
|
||||
const base64Data = await fileToBase64(file);
|
||||
|
||||
const response = await hackathonApi.post<HackathonApiResponse<UploadResponse>>(
|
||||
'/upload/submission',
|
||||
{
|
||||
filename: file.name,
|
||||
content_type: file.type,
|
||||
data: base64Data,
|
||||
}
|
||||
);
|
||||
|
||||
return { data: { url: response.data.data.url } };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Keep useUploadCV for compatibility, using team upload endpoint
|
||||
export const useUploadCV = () => {
|
||||
const { session } = useAuthStore();
|
||||
|
||||
@@ -90,30 +194,29 @@ export const useUploadCV = () => {
|
||||
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');
|
||||
// Validate file type
|
||||
if (file.type !== 'application/pdf') {
|
||||
throw new Error('CV must be a PDF file');
|
||||
}
|
||||
|
||||
// Get public URL
|
||||
const { data: publicUrlData } = supabase.storage
|
||||
.from('hackathon-uploads')
|
||||
.getPublicUrl(filePath);
|
||||
// Validate file size (max 20MB)
|
||||
const maxSize = 20 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
throw new Error('File too large. Maximum size: 20MB');
|
||||
}
|
||||
|
||||
return { data: { url: publicUrlData.publicUrl } };
|
||||
const base64Data = await fileToBase64(file);
|
||||
|
||||
const response = await hackathonApi.post<HackathonApiResponse<UploadResponse>>(
|
||||
'/upload/team',
|
||||
{
|
||||
filename: file.name,
|
||||
content_type: file.type,
|
||||
data: base64Data,
|
||||
}
|
||||
);
|
||||
|
||||
return { data: { url: response.data.data.url } };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { userService } from '../../api/users';
|
||||
import { supabase, getAuthenticatedClient } from '../../supabase';
|
||||
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
|
||||
import { useAuthStore } from '../auth';
|
||||
|
||||
// Supabase-based user hooks
|
||||
// User type
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
fullname: string;
|
||||
bio?: string;
|
||||
location?: string;
|
||||
avatar?: string;
|
||||
skills?: string[];
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// Update user request type
|
||||
interface UpdateUserRequest {
|
||||
fullname?: string;
|
||||
bio?: string;
|
||||
location?: string;
|
||||
avatar?: string;
|
||||
skills?: string[];
|
||||
}
|
||||
|
||||
// Backend API-based user hooks
|
||||
|
||||
export const useUserMe = () => {
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['user-me'],
|
||||
queryFn: async () => {
|
||||
const user = await userService.getUserMe();
|
||||
return { data: user };
|
||||
const response = await hackathonApi.get<HackathonApiResponse<User>>('/users/me');
|
||||
return { data: response.data.data };
|
||||
},
|
||||
enabled: !!session?.user?.id,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -19,8 +43,8 @@ export const useUserById = (id: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['user-by-id', id],
|
||||
queryFn: async () => {
|
||||
const user = await userService.getUserById(id);
|
||||
return { data: user };
|
||||
const response = await hackathonApi.get<HackathonApiResponse<User>>(`/users/${id}`);
|
||||
return { data: response.data.data };
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
@@ -32,35 +56,24 @@ export const useUpdateUserMe = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['update-user-me'],
|
||||
mutationFn: async (data: any) => {
|
||||
mutationFn: async (data: UpdateUserRequest) => {
|
||||
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();
|
||||
const response = await hackathonApi.put<HackathonApiResponse<User>>('/users/me', {
|
||||
fullname: data.fullname,
|
||||
bio: data.bio,
|
||||
location: data.location,
|
||||
avatar: data.avatar,
|
||||
skills: data.skills,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to update user profile:', error);
|
||||
throw new Error(error.message || 'Failed to update profile');
|
||||
}
|
||||
return { data: updatedUser };
|
||||
return { data: response.data.data };
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
// Update Zustand session store with new user data
|
||||
if (session && result.data) {
|
||||
if (session?.user && result.data) {
|
||||
setSession({
|
||||
token: session.token,
|
||||
user: {
|
||||
@@ -83,9 +96,11 @@ export const useUpdateUserById = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['update-user-by-id'],
|
||||
mutationFn: async ({ id, data }: { id: string; data: any }) => {
|
||||
const updated = await userService.updateUserById(id, data);
|
||||
return { data: updated };
|
||||
mutationFn: async ({ id, data }: { id: string; data: UpdateUserRequest }) => {
|
||||
// Note: This might not be supported by backend (only /users/me for updates)
|
||||
// Keeping for API compatibility but it will likely fail
|
||||
const response = await hackathonApi.put<HackathonApiResponse<User>>(`/users/${id}`, data);
|
||||
return { data: response.data.data };
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user-by-id', variables.id] });
|
||||
@@ -95,19 +110,10 @@ export const useUpdateUserById = () => {
|
||||
|
||||
export const useUserDetailsById = (userId: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['user-supabase', userId],
|
||||
queryKey: ['user-details', userId],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('users')
|
||||
.select('*')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || 'Failed to fetch user');
|
||||
}
|
||||
|
||||
return { data };
|
||||
const response = await hackathonApi.get<HackathonApiResponse<User>>(`/users/${userId}`);
|
||||
return { data: response.data.data };
|
||||
},
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
@@ -2,5 +2,5 @@ export * from './api';
|
||||
export * from './hooks';
|
||||
export * from './types';
|
||||
export * from './schemas';
|
||||
export * from './supabase';
|
||||
export * from './storage';
|
||||
// Note: Supabase export removed - using backend API instead
|
||||
|
||||
@@ -36,6 +36,7 @@ export type TTeamItem = {
|
||||
city: string;
|
||||
visibility: ETeamVisibility;
|
||||
leader_id: string;
|
||||
member_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -43,7 +44,6 @@ export type TTeamItem = {
|
||||
export type TTeamDetailItem = TTeamItem & {
|
||||
leader: TUserItem;
|
||||
members: TTeamMemberItem[];
|
||||
member_count: number;
|
||||
has_submission: boolean;
|
||||
};
|
||||
|
||||
@@ -125,6 +125,7 @@ export type TSubmitProjectRequest = {
|
||||
description: string;
|
||||
repository_url: string;
|
||||
demo_url?: string;
|
||||
video_url?: string;
|
||||
presentation_url?: string;
|
||||
screenshots?: string[];
|
||||
};
|
||||
|
||||
@@ -3,64 +3,257 @@ import {
|
||||
AuditOutlined,
|
||||
BookOutlined,
|
||||
CommentOutlined,
|
||||
DownOutlined,
|
||||
InboxOutlined,
|
||||
LogoutOutlined,
|
||||
ReadOutlined,
|
||||
ReloadOutlined,
|
||||
RightOutlined,
|
||||
ScheduleOutlined,
|
||||
SettingOutlined,
|
||||
StockOutlined,
|
||||
UsergroupAddOutlined,
|
||||
UserOutlined,
|
||||
UserSwitchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button } from '../../atoms';
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { cn, For, useSession } from '@imphnen-frontend-service/utils';
|
||||
|
||||
const MENUS = [
|
||||
{ label: 'Dashboard & Set Gacha', href: '/dashboard', icon: <AppstoreOutlined className="text-[20px]" /> },
|
||||
{ label: 'Dashboard - Dimentorin', href: '/dashboard-dimentorin', icon: <AppstoreOutlined className="text-[20px]" /> },
|
||||
{ label: 'Gacha Roll', href: '/gacha-roll', icon: <ReloadOutlined className="text-[20px]" /> },
|
||||
{ label: 'Permissions', href: '/permissions', icon: <UserSwitchOutlined className="text-[20px]" /> },
|
||||
{ label: 'Roles', href: '/roles', icon: <UsergroupAddOutlined className="text-[20px]" /> },
|
||||
{ label: 'Data Akun', href: '/accounts', icon: <UserOutlined className="text-[20px]" /> },
|
||||
{ label: 'Validasi Transaksi', href: '/transactions', icon: <AuditOutlined className="text-[20px]" /> },
|
||||
{ label: 'Data Pengiriman Hadiah', href: '/prizes', icon: <InboxOutlined className="text-[20px]" /> },
|
||||
{ label: 'User - Dimentorin', href: '/users-dimentorin', icon: <UserSwitchOutlined className="text-[20px]" /> },
|
||||
{ label: 'Session - Dimentorin', href: '/session-dimentorin', icon: <ScheduleOutlined className="text-[20px]" /> },
|
||||
{ label: 'Content & Roadmap', href: '/roadmap-dimentorin', icon: <BookOutlined className="text-[20px]" /> },
|
||||
{ label: 'Feedback & Review', href: '/feedback-review-dimentorin', icon: <CommentOutlined className="text-[20px]" /> },
|
||||
{ label: 'Settings - Dimentorin', href: '/settings-dimentorin', icon: <SettingOutlined className="text-[20px]" /> },
|
||||
]
|
||||
type MenuItem = {
|
||||
label: string;
|
||||
href?: string;
|
||||
icon?: ReactElement;
|
||||
children?: Array<{ label: string; href: string; icon?: ReactElement }>;
|
||||
};
|
||||
|
||||
export const BackofficeSidebar: FC = (): ReactElement => {
|
||||
const MENUS: MenuItem[] = [
|
||||
{
|
||||
label: 'Hackathon',
|
||||
icon: <StockOutlined className="text-p3" />,
|
||||
children: [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
href: '/hackathon-dashboard',
|
||||
icon: <AppstoreOutlined className="text-p3" />,
|
||||
},
|
||||
{
|
||||
label: 'Users',
|
||||
href: '/hackathon-users',
|
||||
icon: <UserOutlined className="text-p3" />,
|
||||
},
|
||||
{
|
||||
label: 'Teams',
|
||||
href: '/hackathon-teams',
|
||||
icon: <UsergroupAddOutlined className="text-p3" />,
|
||||
},
|
||||
{
|
||||
label: 'Submissions',
|
||||
href: '/hackathon-submissions',
|
||||
icon: <AuditOutlined className="text-p3" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Dimentorin',
|
||||
icon: <ReadOutlined className="text-p3" />,
|
||||
children: [
|
||||
{
|
||||
label: 'Dashboard - Dimentorin',
|
||||
href: '/dashboard-dimentorin',
|
||||
icon: <AppstoreOutlined className="text-[20px]" />,
|
||||
},
|
||||
{
|
||||
label: 'User - Dimentorin',
|
||||
href: '/users-dimentorin',
|
||||
icon: <UserSwitchOutlined className="text-[20px]" />,
|
||||
},
|
||||
{
|
||||
label: 'Session - Dimentorin',
|
||||
href: '/session-dimentorin',
|
||||
icon: <ScheduleOutlined className="text-[20px]" />,
|
||||
},
|
||||
{
|
||||
label: 'Content & Roadmap',
|
||||
href: '/roadmap-dimentorin',
|
||||
icon: <BookOutlined className="text-[20px]" />,
|
||||
},
|
||||
{
|
||||
label: 'Feedback & Review',
|
||||
href: '/feedback-review-dimentorin',
|
||||
icon: <CommentOutlined className="text-[20px]" />,
|
||||
},
|
||||
{
|
||||
label: 'Settings - Dimentorin',
|
||||
href: '/settings-dimentorin',
|
||||
icon: <SettingOutlined className="text-[20px]" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Gacha',
|
||||
icon: <ReloadOutlined className="text-[20px]" />,
|
||||
children: [
|
||||
{
|
||||
label: 'Dashboard & Set Gacha',
|
||||
href: '/dashboard',
|
||||
icon: <AppstoreOutlined className="text-[20px]" />,
|
||||
},
|
||||
{
|
||||
label: 'Gacha Roll',
|
||||
href: '/gacha-roll',
|
||||
icon: <ReloadOutlined className="text-[20px]" />,
|
||||
},
|
||||
{
|
||||
label: 'Validasi Transaksi',
|
||||
href: '/transactions',
|
||||
icon: <AuditOutlined className="text-[20px]" />,
|
||||
},
|
||||
{
|
||||
label: 'Data Pengiriman Hadiah',
|
||||
href: '/prizes',
|
||||
icon: <InboxOutlined className="text-[20px]" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Permissions',
|
||||
href: '/permissions',
|
||||
icon: <UserSwitchOutlined className="text-[20px]" />,
|
||||
},
|
||||
{
|
||||
label: 'Roles',
|
||||
href: '/roles',
|
||||
icon: <UsergroupAddOutlined className="text-[20px]" />,
|
||||
},
|
||||
{
|
||||
label: 'Data Akun',
|
||||
href: '/accounts',
|
||||
icon: <UserOutlined className="text-[20px]" />,
|
||||
},
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const BackofficeSidebar: FC<SidebarProps> = ({
|
||||
isOpen = false,
|
||||
onClose,
|
||||
}): ReactElement => {
|
||||
const { signOut } = useSession();
|
||||
const location = useLocation();
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/dashboard' && location.pathname === '/dashboard-dimentorin') return false
|
||||
return location.pathname.includes(path)
|
||||
if (path === '/dashboard' && location.pathname === '/dashboard-dimentorin')
|
||||
return false;
|
||||
return location.pathname.includes(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="sticky top-0 left-0 w-[280px] bg-white h-svh py-[60px] px-[28px] shadow-xl flex flex-col justify-between">
|
||||
<div className="flex flex-col gap-20 justify-between items-center">
|
||||
<img src="/logos/simple.svg" alt="IMPHNEN Logo" className="w-[150px]" />
|
||||
const toggleGroup = (groupLabel: string) => {
|
||||
setOpenGroups((prev) => ({ ...prev, [groupLabel]: !prev[groupLabel] }));
|
||||
};
|
||||
|
||||
const sidebarContent = (
|
||||
<div className="w-[280px] bg-white h-svh py-10 lg:py-[60px] px-7 shadow-xl flex flex-col justify-between">
|
||||
<div className="flex flex-col gap-10 lg:gap-20 justify-between items-center">
|
||||
<div className="flex justify-around lg:justify-center items-center w-full">
|
||||
<img
|
||||
src="/logos/simple.svg"
|
||||
alt="IMPHNEN Logo"
|
||||
className="w-[150px]"
|
||||
/>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-4 w-full h-[calc(100svh-20rem)] overflow-y-auto">
|
||||
<For data={MENUS}>
|
||||
{({ label, href, icon }) => (
|
||||
<Link
|
||||
key={href}
|
||||
to={href}
|
||||
className={cn(
|
||||
"flex items-center justify-items-start gap-3 px-[8px] py-[10px]",
|
||||
isActive(href) ? "bg-primary-500 text-white rounded-md" : "text-gray-700 hover:bg-gray-100"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span className="text-p3 font-medium">{label}</span>
|
||||
</Link>
|
||||
)}
|
||||
{(menu) =>
|
||||
menu.children && menu.children.length > 0 ? (
|
||||
<div key={menu.label} className="w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroup(menu.label)}
|
||||
className={cn(
|
||||
'flex items-center justify-between w-full gap-3 px-2 py-2.5 rounded-md cursor-pointer',
|
||||
openGroups[menu.label]
|
||||
? 'bg-primary-400 hover:bg-primary-500 text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{menu.icon}
|
||||
<span className="text-p3 font-medium">{menu.label}</span>
|
||||
</div>
|
||||
<span className="text-label2">
|
||||
{openGroups[menu.label] ? (
|
||||
<DownOutlined className="text-label1" />
|
||||
) : (
|
||||
<RightOutlined className="text-label1" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{openGroups[menu.label] && (
|
||||
<div className="mt-2 ml-6 flex flex-col gap-2">
|
||||
{menu.children.map((child) => (
|
||||
<Link
|
||||
key={child.href}
|
||||
to={child.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-2 py-2.5 rounded-md',
|
||||
isActive(child.href)
|
||||
? 'bg-primary-100 text-primary-700 hover:bg-primary-200'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
{child.icon}
|
||||
<span className="text-label1 font-medium">
|
||||
{child.label}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
key={menu.href ?? menu.label}
|
||||
to={menu.href ?? '#'}
|
||||
className={cn(
|
||||
'flex items-center justify-items-start gap-3 px-2 py-2.5',
|
||||
menu.href && isActive(menu.href)
|
||||
? 'bg-primary-500 text-white rounded-md'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
{menu.icon}
|
||||
<span className="text-p3 font-medium">{menu.label}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
</For>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -70,12 +263,36 @@ export const BackofficeSidebar: FC = (): ReactElement => {
|
||||
<Button
|
||||
onClick={signOut}
|
||||
variant="text"
|
||||
className="items-start justify-start gap-3 px-[8px] py-[10px] text-gray-700 hover:text-red-500 transition-colors w-full"
|
||||
className="items-start justify-start gap-3 px-2 py-2.5 text-gray-700 hover:text-red-500 transition-colors w-full"
|
||||
>
|
||||
<LogoutOutlined className="text-[20px]" />
|
||||
<LogoutOutlined className="text-p3" />
|
||||
<span className="text-p3 font-medium">Log Out</span>
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Sidebar - visible on lg+, sticky */}
|
||||
<div className="hidden lg:block sticky top-0 h-screen overflow-y-auto shadow">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
|
||||
{/* Mobile Sidebar - overlay */}
|
||||
{isOpen && (
|
||||
<div className="lg:hidden fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Sidebar */}
|
||||
<div className="fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { cn } from "@imphnen-frontend-service/utils"
|
||||
import { Icon } from '@iconify/react'
|
||||
import { FC, ReactElement, ReactNode } from "react"
|
||||
import { Button } from "../../atoms"
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { FC, ReactElement, ReactNode } from 'react';
|
||||
import { Button } from '../../atoms';
|
||||
import { useAuthStore } from '@imphnen-frontend-service/service';
|
||||
|
||||
export type TBackofficeWrapperProps = {
|
||||
children: ReactNode
|
||||
title?: string
|
||||
className?: string
|
||||
classHeader?: string
|
||||
classTitle?: string
|
||||
}
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
className?: string;
|
||||
classHeader?: string;
|
||||
classTitle?: string;
|
||||
};
|
||||
|
||||
export const BackofficeWrapper: FC<TBackofficeWrapperProps> = ({
|
||||
children,
|
||||
@@ -18,34 +19,52 @@ export const BackofficeWrapper: FC<TBackofficeWrapperProps> = ({
|
||||
classHeader,
|
||||
classTitle,
|
||||
}): ReactElement => {
|
||||
const { session } = useAuthStore();
|
||||
const user = session?.user;
|
||||
|
||||
return (
|
||||
<main className={cn("w-full px-[48px] py-[40px] flex flex-col gap-8", className)}>
|
||||
<header className={cn("bg-white py-5 px-7 rounded-md shadow flex items-center justify-between", classHeader)}>
|
||||
<h1 className={cn("text-[19px] text-primary-500 font-semibold", classTitle)}>{title}</h1>
|
||||
<main
|
||||
className={cn(
|
||||
'w-full px-[48px] py-[40px] flex flex-col gap-8',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<header
|
||||
className={cn(
|
||||
'bg-white py-5 px-7 rounded-md shadow flex items-center justify-between',
|
||||
classHeader
|
||||
)}
|
||||
>
|
||||
<h1
|
||||
className={cn(
|
||||
'text-[19px] text-primary-500 font-semibold',
|
||||
classTitle
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-x-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="max-h-full p-3"
|
||||
>
|
||||
{/* <Button type="button" variant="secondary" className="max-h-full p-3">
|
||||
<Icon icon="mdi:bell-outline" className="size-6" />
|
||||
</Button>
|
||||
</Button> */}
|
||||
<div className="flex items-center gap-x-6">
|
||||
<div className="text-neutral-600 font-medium">
|
||||
<p className="text-p3">Rizal Syaepulloh</p>
|
||||
<p className="text-label1">Super Admin</p>
|
||||
<p className="text-p3">{user?.fullname || 'Full Name'}</p>
|
||||
<p className="text-label1">Admin</p>
|
||||
</div>
|
||||
<div className="size-12 rounded-full overflow-hidden">
|
||||
<img src="/images/asd687hwq6nds4dfjj2983.webp" alt="Profile" className="size-full object-cover" />
|
||||
<img
|
||||
src={user?.avatar || '/images/asd687hwq6nds4dfjj2983.webp'}
|
||||
alt="Profile"
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
{children}
|
||||
</section>
|
||||
<section>{children}</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,57 +1,113 @@
|
||||
import {
|
||||
PaginationState,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel,
|
||||
flexRender,
|
||||
ColumnDef,
|
||||
Table,
|
||||
RowData,
|
||||
TableOptions,
|
||||
} from '@tanstack/react-table';
|
||||
import { Pagination } from '../../molecules';
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
interface DataTableProps<T> {
|
||||
data: T[];
|
||||
columns: ColumnDef<T>[];
|
||||
table: Table<T>;
|
||||
interface DataTableProps<T extends RowData> {
|
||||
table?: Table<T>;
|
||||
data?: T[];
|
||||
columns?: ColumnDef<T, unknown>[];
|
||||
pageSize?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DataTable = <T extends RowData>({
|
||||
data,
|
||||
columns,
|
||||
table,
|
||||
data = [],
|
||||
columns = [],
|
||||
pageSize = 9,
|
||||
className,
|
||||
}: DataTableProps<T>) => {
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize,
|
||||
});
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
});
|
||||
// Update pagination state when pageSize prop changes
|
||||
React.useEffect(() => {
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
pageSize,
|
||||
}));
|
||||
}, [pageSize]);
|
||||
|
||||
// Reset pagination when data changes to prevent out-of-bounds errors
|
||||
React.useEffect(() => {
|
||||
if (data.length > 0) {
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
pageIndex: 0, // Reset to first page when data changes
|
||||
}));
|
||||
}
|
||||
}, [data.length]);
|
||||
|
||||
// Memoize data and columns to prevent unnecessary re-renders
|
||||
const memoizedData = React.useMemo(() => data, [data]);
|
||||
const memoizedColumns = React.useMemo(() => columns, [columns]);
|
||||
|
||||
// Memoize table configuration to prevent recreation on every render
|
||||
const tableConfig = React.useMemo(() => {
|
||||
const config: TableOptions<T> = {
|
||||
data: memoizedData,
|
||||
columns: memoizedColumns,
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
};
|
||||
|
||||
return config;
|
||||
}, [memoizedData, memoizedColumns, pagination, sorting]);
|
||||
|
||||
// Prefer external table instance if provided; otherwise create an internal one
|
||||
const internalTable = useReactTable(tableConfig);
|
||||
const t = table ?? internalTable;
|
||||
|
||||
// Handle empty data state
|
||||
const isEmpty = t.getRowModel().rows.length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className={cn('flex flex-col gap-8', className)}>
|
||||
<div className="w-full overflow-x-auto">
|
||||
<table className="w-full min-w-full text-base">
|
||||
<thead className="bg-primary-50 mb-3 text-left text-nowrap">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
{t.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
className={cn("py-4 px-5 font-normal first:rounded-l-lg last:rounded-r-lg", header?.column?.columnDef?.meta?.headerClassName)}
|
||||
onClick={
|
||||
header.column.getCanSort()
|
||||
? header.column.getToggleSortingHandler()
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
'py-4 px-5 font-normal first:rounded-l-lg last:rounded-r-lg',
|
||||
header.column.getCanSort() &&
|
||||
'cursor-pointer select-none hover:bg-primary-100 transition-colors',
|
||||
header?.column?.columnDef?.meta?.headerClassName
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
@@ -59,28 +115,58 @@ export const DataTable = <T extends RowData>({
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{header.column.getCanSort() && (
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
{header.column.getIsSorted() === 'asc' && '▲'}
|
||||
{header.column.getIsSorted() === 'desc' && '▼'}
|
||||
{!header.column.getIsSorted() && <span>⇅</span>}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id} className="bg-primary-100 odd:bg-white">
|
||||
{row.getVisibleCells().map((cell, index) => (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={cn("py-3 px-5 first:rounded-l-lg last:rounded-r-lg", cell?.column?.columnDef?.meta?.cellClassName)}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
{isEmpty ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={t.getAllColumns().length}
|
||||
className="py-8 px-5 text-center text-neutral-500"
|
||||
>
|
||||
No data available
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
) : (
|
||||
t.getRowModel().rows.map((row, rowIndex) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={cn(
|
||||
'hover:bg-primary-50 transition-colors',
|
||||
rowIndex % 2 === 0 ? 'bg-white' : 'bg-primary-100'
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
'py-3 px-5 first:rounded-l-lg last:rounded-r-lg',
|
||||
cell?.column?.columnDef?.meta?.cellClassName
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination table={table} />
|
||||
<Pagination table={t} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { supabase, useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import { useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
export const useSession = () => {
|
||||
@@ -6,9 +6,9 @@ export const useSession = () => {
|
||||
const { clearSession, session, status } = useAuthStore();
|
||||
const isAuthenticated = status === 'authenticated';
|
||||
|
||||
const signOut = async () => {
|
||||
await supabase.auth.signOut();
|
||||
const signOut = () => {
|
||||
clearSession();
|
||||
localStorage.clear();
|
||||
navigate('/auth/login');
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user