Compare commits

..
12 Commits
Author SHA1 Message Date
Hafid Nur a5c1655256 Merge branch 'develop' into backoffice/hackathon-teams-submission 2025-12-09 23:05:28 +07:00
Hafid Nur 3d970aab45 feat(backoffice): submission page integration
- Fetch submissions data from API
- Move submission modal to hackathon-submissions page
2025-12-09 22:58:31 +07:00
Hafid Nur 93df14169c feat(backoffice): teams page integration
- Get teams data from API
- Hide filter that doesn't exists in back-end
- Simplify modal according to the back-end
2025-12-09 22:20:41 +07:00
Hafid Nur 7fc43d0f09 feat(backoffice): users page integration
- Get users data from API
- Set up server-side pagination and match URL params
- Hide filter that doesn't exist in back-end
2025-12-09 21:29:53 +07:00
Hafid Nur 254a2f154d feat(backoffice): hackathon dashboard integration 2025-12-09 19:51:43 +07:00
Hafid Nur b74ea9307e feat(backoffice): authentication middleware, error pages, and 404 page 2025-12-09 18:35:25 +07:00
Denta abc4c26d5d chore: adjust landing page (#69)
- change typo text in footer "patners" -> "partnerts"
- add github in community section, and footer
- update the value of stat members in hero and cta section
2025-12-09 14:16:21 +07:00
Anka Tama a3d35716a6 Feat/certificate (#68)
* feat: implement certificate encoding and decoding functionality with AES encryption

* feat: blank cert

* Refactor code structure for improved readability and maintainability
2025-12-09 14:15:23 +07:00
Maulana SodiqinandClaude ac22bd6cab feat: update submission description to allow demo video links
- Add helper text indicating users can paste demo video links
- Update placeholder to mention YouTube, Loom, etc.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-07 11:36:42 +07:00
Maulana SodiqinandClaude 27ed5605ce feat: add submission countdown to dashboard
- Display countdown timer on dashboard showing days, hours, minutes, seconds
- Shows time remaining until December 7, 2025 23:59 WIB
- Automatically hides after deadline passes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-07 00:06:00 +07:00
Maulana SodiqinandClaude 2c6389a67a feat: add submission deadline with countdown timer
- Add countdown timer showing days, hours, minutes, seconds
- Disable submission after December 7, 2025 23:59 WIB
- Show "Submission Closed" screen after deadline

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 23:32:01 +07:00
Hafid Nur a4c75770ae feat(backoffice): Backoffice for Hackathon - user management page (#67)
* feat(backoffice): create a boilerplate page for Hackathon dashboard

- Create an empty page for Hackathon dashboard
- Comment out and hide the existing backoffice sidebar

* feat(backoffice): Create a nested/dropdown sidebar list

- Create a dropdown sidebar list
- Show back the old navigation and group them
- Make the sidebar responsive for mobile view

* test datatable with mock data

* base UI for Hackathon backoffice

TODO:
- Organize table schema for users, teams, and submissions management
- Create API Contract for additional back-end endpoint

* feat(hackathon): draft data table column & API Contract

* update endpoint

* feat(backoffice): update page hackathon user management

- update data table component
- update filtering & pagination
- add modal display to edit and add user
- hide notification icon in backoffice wrapper

* feat(backoffice): add API contract for hackathon users

* feat(backoffice): little adjustment in hackathon users management UI and API contract
2025-12-05 11:55:28 +07:00
41 changed files with 7555 additions and 1718 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8" />
<title>Backoffice</title>
@@ -1,7 +1,35 @@
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
import { FC, ReactElement } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
getAdminUsers,
getAdminTeams,
getAdminSubmissions,
} from '@imphnen-frontend-service/service';
export const HackathonDashboardPage: FC = (): ReactElement => {
// Fetch total participants
const { data: usersData } = useQuery({
queryKey: ['admin-users-count'],
queryFn: () => getAdminUsers({ page: 1, per_page: 1 }),
});
// Fetch total teams
const { data: teamsData } = useQuery({
queryKey: ['admin-teams-count'],
queryFn: () => getAdminTeams({ page: 1, per_page: 1 }),
});
// Fetch total submissions
const { data: submissionsData } = useQuery({
queryKey: ['admin-submissions-count'],
queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }),
});
const totalParticipants = usersData?.meta?.total_data ?? '??';
const totalTeams = teamsData?.meta?.total_data ?? '??';
const totalSubmissions = submissionsData?.meta?.total_data ?? '??';
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
@@ -10,18 +38,22 @@ export const HackathonDashboardPage: FC = (): ReactElement => {
{/* 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
{totalParticipants}
</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>
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
{totalTeams}
</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>
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
{totalSubmissions}
</h3>
<p className="text-neutral-400 text-p3">Total Project Submitted</p>
</div>
</section>
@@ -0,0 +1,249 @@
import { FC } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import {
CloseOutlined,
LinkOutlined,
ProjectOutlined,
FileImageOutlined,
} from '@ant-design/icons';
import { TAdminSubmissionItem } from '@imphnen-frontend-service/service';
import { cn } from '@imphnen-frontend-service/utils';
interface SubmissionModalProps {
isOpen: boolean;
onClose: () => void;
submission: TAdminSubmissionItem;
}
const SubmissionModal: FC<SubmissionModalProps> = ({
isOpen,
onClose,
submission,
}) => {
if (!isOpen) return null;
const getStatusColor = (status: string) => {
switch (status) {
case 'submitted':
return 'bg-success-50 border-success-200 text-success-800';
case 'pending':
return 'bg-orange-50 border-orange-200 text-orange-800';
case 'approved':
return 'bg-blue-50 border-blue-200 text-blue-800';
case 'rejected':
return 'bg-error-50 border-error-200 text-error-800';
default:
return 'bg-neutral-50 border-neutral-200 text-neutral-800';
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-success-100 flex items-center justify-center">
<ProjectOutlined className="text-success-600 text-lg" />
</div>
<div>
<h2 className="text-xl font-semibold text-neutral-900">
{submission.project_name}
</h2>
<p className="text-sm text-neutral-500">
Team ID: {submission.team_id} Submitted{' '}
{new Date(submission.submitted_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</div>
</div>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 transition-colors cursor-pointer"
>
<CloseOutlined className="text-xl" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Submission Status */}
<div
className={cn(
'flex items-center gap-3 p-4 border rounded-lg',
getStatusColor(submission.status)
)}
>
<div
className={cn(
'w-3 h-3 rounded-full',
submission.status === 'submitted' && 'bg-success-500',
submission.status === 'pending' && 'bg-orange-500',
submission.status === 'approved' && 'bg-blue-500',
submission.status === 'rejected' && 'bg-error-500'
)}
></div>
<div>
<p className="text-sm font-medium">
Status:{' '}
{submission.status.charAt(0).toUpperCase() +
submission.status.slice(1)}
</p>
<p className="text-xs">Submitted by: {submission.submitted_by}</p>
</div>
</div>
{/* Project Description */}
<div>
<h3 className="text-sm font-medium text-neutral-700 mb-2">
Project Description
</h3>
<p className="text-sm text-neutral-600 leading-relaxed">
{submission.description}
</p>
</div>
{/* Project Links */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-neutral-700">
Project Links
</h3>
{/* Repository URL */}
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
<LinkOutlined className="text-primary-500 mt-1" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-neutral-600 mb-1">
Repository
</p>
<a
href={submission.repository_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary-600 hover:text-primary-700 hover:underline break-all"
>
{submission.repository_url}
</a>
</div>
</div>
{/* Demo URL */}
{submission.demo_url && (
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
<LinkOutlined className="text-primary-500 mt-1" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-neutral-600 mb-1">
Live Demo
</p>
<a
href={submission.demo_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary-600 hover:text-primary-700 hover:underline break-all"
>
{submission.demo_url}
</a>
</div>
</div>
)}
{/* Presentation URL */}
{submission.presentation_url && (
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
<LinkOutlined className="text-primary-500 mt-1" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-neutral-600 mb-1">
Presentation
</p>
<a
href={submission.presentation_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary-600 hover:text-primary-700 hover:underline break-all"
>
{submission.presentation_url}
</a>
</div>
</div>
)}
</div>
{/* Screenshots */}
{submission.screenshots && submission.screenshots.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-neutral-700 flex items-center gap-2">
<FileImageOutlined className="text-primary-500" />
Screenshots ({submission.screenshots.length})
</h3>
<div className="grid grid-cols-2 gap-3">
{submission.screenshots.map((screenshot, index) => (
<a
key={index}
href={screenshot}
target="_blank"
rel="noopener noreferrer"
className="block rounded-lg overflow-hidden border border-neutral-200 hover:border-primary-300 transition-colors"
>
<img
src={screenshot}
alt={`Screenshot ${index + 1}`}
className="w-full h-40 object-cover"
/>
</a>
))}
</div>
</div>
)}
{/* Metadata */}
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-neutral-200">
<div>
<p className="text-xs text-neutral-500 mb-1">Created</p>
<p className="text-sm text-neutral-900">
{new Date(submission.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
<div>
<p className="text-xs text-neutral-500 mb-1">Last Updated</p>
<p className="text-sm text-neutral-900">
{new Date(submission.updated_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-neutral-200 bg-neutral-50">
<Button variant="secondary" onClick={onClose}>
Close
</Button>
{/* <Button
variant="primary"
onClick={() => {
console.log('Edit submission:', submission.id);
}}
>
Edit Status
</Button> */}
</div>
</div>
</div>
);
};
export default SubmissionModal;
@@ -1,128 +1,375 @@
import { FC, ReactElement, useState } from 'react';
import {
FC,
ReactElement,
useState,
useEffect,
useMemo,
useCallback,
} from 'react';
import SubmissionModal from './_components/submission-modal';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import { ColumnDef } 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';
import {
SearchOutlined,
FilterOutlined,
LoadingOutlined,
EyeOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import {
getAdminSubmissions,
TAdminSubmissionItem,
} from '@imphnen-frontend-service/service';
import { useSearchParams } from 'react-router-dom';
export const HackathonUsersPage: FC = (): ReactElement => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
type SubmissionType = TAdminSubmissionItem;
export const HackathonSubmissionsPage: FC = (): ReactElement => {
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = Math.max(
1,
parseInt(searchParams.get('page') || '1', 10)
);
const searchQuery = searchParams.get('search') || '';
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
const statusFilter = searchParams.get('status') || 'all';
const [showSubmissionModal, setShowSubmissionModal] = useState(false);
const [selectedSubmission, setSelectedSubmission] =
useState<SubmissionType | null>(null);
const [globalFilter, setGlobalFilter] = useState(searchQuery);
// Fetch submissions from API
const {
data: submissionsResponse,
isLoading,
isFetching,
} = useQuery({
queryKey: [
'admin-submissions',
currentPage,
perPage,
statusFilter,
searchQuery,
],
queryFn: () =>
getAdminSubmissions({
page: currentPage,
per_page: perPage,
status: statusFilter !== 'all' ? statusFilter : undefined,
search: searchQuery || undefined,
}),
staleTime: 30000, // 30 seconds cache
gcTime: 5 * 60 * 1000, // 5 minutes
});
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}`,
}));
const totalData = submissionsResponse?.meta?.total_data || 0;
const totalPages = submissionsResponse?.meta?.total_page || 1;
type UserStatus = 'active' | 'inactive';
// Handle page change
const handlePageChange = useCallback(
(newPage: number) => {
const params = new URLSearchParams();
params.set('page', newPage.toString());
if (perPage !== 10) params.set('per_page', perPage.toString());
if (searchQuery) params.set('search', searchQuery);
if (statusFilter !== 'all') params.set('status', statusFilter);
setSearchParams(params);
window.scrollTo({ top: 0, behavior: 'smooth' });
},
[setSearchParams, perPage, searchQuery, statusFilter]
);
interface SubmissionType {
id: number;
project_name: string;
repository_url: string;
demo_url: string;
presentation_url: string;
}
// Validate page number
useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
setSearchParams({ page: totalPages.toString() });
}
}, [currentPage, totalPages, setSearchParams, isLoading]);
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>
),
},
];
// Sync globalFilter with URL
useEffect(() => {
setGlobalFilter(searchQuery);
}, [searchQuery]);
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
// Handle search
const handleSearch = useCallback(() => {
const params = new URLSearchParams();
params.set('page', '1');
if (perPage !== 10) params.set('per_page', perPage.toString());
if (globalFilter.trim()) {
params.set('search', globalFilter.trim());
}
if (statusFilter !== 'all') params.set('status', statusFilter);
setSearchParams(params);
}, [globalFilter, setSearchParams, perPage, statusFilter]);
const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
[handleSearch]
);
// Handle per page change
const handlePerPageChange = useCallback(
(newPerPage: number) => {
const params = new URLSearchParams();
params.set('page', '1');
params.set('per_page', newPerPage.toString());
if (searchQuery) params.set('search', searchQuery);
if (statusFilter !== 'all') params.set('status', statusFilter);
setSearchParams(params);
},
[setSearchParams, searchQuery, statusFilter]
);
// Handle status filter change
// const handleStatusFilterChange = useCallback(
// (newStatus: string) => {
// const params = new URLSearchParams();
// params.set('page', '1');
// if (perPage !== 10) params.set('per_page', perPage.toString());
// if (searchQuery) params.set('search', searchQuery);
// if (newStatus !== 'all') params.set('status', newStatus);
// setSearchParams(params);
// },
// [setSearchParams, perPage, searchQuery]
// );
// Handle modal
const handleShowSubmissionModal = useCallback(
(submission: SubmissionType) => {
setSelectedSubmission(submission);
setShowSubmissionModal(true);
},
[]
);
const handleCloseSubmissionModal = useCallback(() => {
setShowSubmissionModal(false);
setSelectedSubmission(null);
}, []);
// Get submissions data
const filteredData = useMemo(() => {
return submissionsResponse?.data || [];
}, [submissionsResponse]);
// Memoize columns
const columns: ColumnDef<SubmissionType>[] = useMemo(
() => [
{
accessorKey: 'project_name',
header: 'Project Name',
cell: ({ row }) => (
<span className="font-medium text-neutral-900">
{row.original.project_name}
</span>
),
enableSorting: true,
},
{
accessorKey: 'team_id',
header: 'Team ID',
cell: ({ row }) => (
<span className="text-sm text-neutral-700 font-mono">
{row.original.team_id}
</span>
),
enableSorting: false,
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const status = row.original.status;
return (
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
status === 'submitted'
? 'bg-success-100 text-success-800'
: status === 'pending'
? 'bg-orange-100 text-orange-800'
: 'bg-neutral-100 text-neutral-700'
)}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
},
enableSorting: true,
},
{
accessorKey: 'submitted_at',
header: 'Submitted',
cell: ({ row }) => (
<span className="text-neutral-900 text-sm">
{new Date(row.original.submitted_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 }) => (
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 text-sm px-4 py-2"
onClick={() => handleShowSubmissionModal(row.original)}
>
<EyeOutlined className="text-sm" />
View
</Button>
),
enableSorting: false,
},
],
[handleShowSubmissionModal]
);
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
Project Submission
Project Submissions
</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 className="flex flex-wrap gap-3 items-center justify-between">
<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 by project name..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
onKeyPress={handleSearchKeyPress}
/>
</div>
{/* Per Page Dropdown */}
<div className="relative">
<select
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={perPage}
onChange={(e) =>
handlePerPageChange(parseInt(e.target.value, 10))
}
>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
<option value={100}>100 / page</option>
</select>
</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-40 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={statusFilter}
onChange={(e) => handleStatusFilterChange(e.target.value)}
>
<option value="all">All Status</option>
<option value="submitted">Submitted</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
</div> */}
</div>
</div>
{/* Table */}
<DataTable data={mockData} columns={columns} table={table} />
{/* Active filters */}
{/* {statusFilter !== 'all' && (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span>
<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={() => handleStatusFilterChange('all')}
className="text-info-600 hover:text-info-800 cursor-pointer"
>
</button>
</span>
<Button
variant="secondary"
size="sm"
onClick={() => {
handleStatusFilterChange('all');
setGlobalFilter('');
}}
className="text-sm text-neutral-600"
>
Clear All
</Button>
</div>
)} */}
{/* Loading & results */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
<span className="ml-3 text-neutral-600">
Loading submissions...
</span>
</div>
) : filteredData.length > 0 ? (
<>
<div className="text-sm text-neutral-600">
Showing {filteredData.length} of {totalData} submissions (Page{' '}
{currentPage} of {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">(Updating...)</span>
)}
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination={true}
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="text-center py-12 text-neutral-500">
No submissions found. Try adjusting your filters.
</div>
)}
</section>
{/* Modals extracted into shared backoffice components */}
{/* Submission Modal */}
{selectedSubmission && (
<SubmissionModal
isOpen={showSubmissionModal}
onClose={handleCloseSubmissionModal}
submission={selectedSubmission}
/>
)}
</BackofficeWrapper>
);
};
export default HackathonUsersPage;
export default HackathonSubmissionsPage;
@@ -1,63 +1,24 @@
import { FC, useState, useEffect, useMemo, useRef } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import TeamBannerPlaceholder from './team-banner-placeholder';
import { CityFilterSelect } from '../../../../components/city-filter-select';
import TeamBannerPlaceholder from './team-banner-placeholder';
import { cn } from '@imphnen-frontend-service/utils';
import { TAdminTeamItem } from '@imphnen-frontend-service/service';
import {
TeamOutlined,
CalendarOutlined,
SaveOutlined,
CloseOutlined,
ExclamationOutlined,
UserOutlined,
DeleteOutlined,
SaveOutlined,
CalendarOutlined,
CrownOutlined,
ExclamationOutlined,
UploadOutlined,
CameraOutlined,
EyeOutlined,
EyeInvisibleOutlined,
CrownOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
CameraOutlined,
UploadOutlined,
} from '@ant-design/icons';
interface TeamMember {
id: string;
joined_at: string;
role: 'leader' | 'member';
status: 'pending' | 'accepted' | 'rejected';
team_id: string;
user: {
avatar?: string;
bio?: string;
created_at: string;
email: string;
fullname: string;
id: string;
is_active: boolean;
location: string;
phone_number?: string;
skills: string[];
updated_at: string;
};
user_id: string;
}
interface TeamType {
id: string;
name: string;
description?: string;
city: string;
banner?: string;
logo?: string;
visibility: 'public' | 'private';
member_count: number;
has_submission: boolean;
created_at: string;
updated_at: string;
leader_id: string;
members: TeamMember[];
}
type TeamType = TAdminTeamItem;
interface ModalProps {
isOpen: boolean;
@@ -68,7 +29,6 @@ interface ModalProps {
const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
const [formData, setFormData] = useState<TeamType | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [activeTab, setActiveTab] = useState<'details' | 'members'>('details');
const [showLogoMenu, setShowLogoMenu] = useState(false);
const logoInputRef = useRef<HTMLInputElement>(null);
const bannerInputRef = useRef<HTMLInputElement>(null);
@@ -77,24 +37,19 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
useEffect(() => {
if (isOpen) {
if (team) {
// Edit existing team
setFormData({ ...team });
} else {
// Create new team
setFormData({
id: '', // Will be generated by backend
id: '',
name: '',
description: '',
city: '',
banner: undefined,
logo: undefined,
banner: null,
logo: null,
visibility: 'public',
member_count: 1,
has_submission: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
leader_id: '',
members: [],
});
}
}
@@ -102,8 +57,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
// Check if form has changes
const hasChanges = useMemo(() => {
if (!formData) return false;
if (!team) return true; // New team always has changes
if (!formData || !team) return !!formData;
return (
formData.name !== team.name ||
formData.description !== team.description ||
@@ -117,37 +71,28 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
// Check if required fields are filled
const isFormValid = useMemo(() => {
if (!formData) return false;
return formData.name.trim() !== '' && formData.city.trim() !== '';
return (
formData.name.trim() !== '' &&
formData.city.trim() !== '' &&
formData.description.trim() !== ''
);
}, [formData]);
const canSave = hasChanges && isFormValid;
// Get leader and other members
const leader = team?.members.find((m) => m.role === 'leader');
const acceptedMembers =
team?.members.filter((m) => m.status === 'accepted') || [];
const pendingMembers =
team?.members.filter((m) => m.status === 'pending') || [];
if (!isOpen || !formData) return null;
const handleInputChange = (
field: keyof TeamType,
value: string | boolean | 'public' | 'private' | undefined
) => {
const handleInputChange = (field: keyof TeamType, value: string | null) => {
setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
};
const handleSave = () => {
if (!formData) return;
console.log('Saving team:', formData);
onClose();
};
const handleDelete = () => {
if (!team) return;
console.log('Deleting team:', team.id);
setShowDeleteConfirm(false);
onClose();
@@ -155,56 +100,45 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
const handleLogoUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
if (file.size > 5 * 1024 * 1024) {
alert('Image size must be less than 5MB');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const logoUrl = e.target?.result as string;
handleInputChange('logo', logoUrl);
setShowLogoMenu(false);
};
reader.readAsDataURL(file);
if (!file) return;
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
if (file.size > 5 * 1024 * 1024) {
alert('Image size must be less than 5MB');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const logoUrl = e.target?.result as string;
handleInputChange('logo', logoUrl);
setShowLogoMenu(false);
};
reader.readAsDataURL(file);
};
const handleBannerUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
if (file.size > 5 * 1024 * 1024) {
alert('Image size must be less than 5MB');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const bannerUrl = e.target?.result as string;
handleInputChange('banner', bannerUrl);
};
reader.readAsDataURL(file);
if (!file) return;
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
if (file.size > 5 * 1024 * 1024) {
alert('Image size must be less than 5MB');
return;
}
};
const handleRemoveLogo = () => {
handleInputChange('logo', undefined);
setShowLogoMenu(false);
};
const handleRemoveBanner = () => {
handleInputChange('banner', undefined);
};
const triggerLogoUpload = () => {
logoInputRef.current?.click();
const reader = new FileReader();
reader.onload = (e) => {
const bannerUrl = e.target?.result as string;
handleInputChange('banner', bannerUrl);
};
reader.readAsDataURL(file);
};
return (
@@ -240,7 +174,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
{/* Content */}
<div className="p-6 space-y-6" onClick={() => setShowLogoMenu(false)}>
{/* Hidden File Inputs */}
<input
type="file"
ref={logoInputRef}
@@ -256,21 +189,20 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
className="hidden"
/>
{/* Interactive Banner Section */}
{/* Banner Section */}
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Team Banner
<span className="text-xs text-neutral-500 ml-2">
Team Banner{' '}
<span className="text-xs text-neutral-500">
(3:1 aspect ratio recommended)
</span>
</label>
<div className="relative group">
<TeamBannerPlaceholder
banner={formData.banner}
banner={formData.banner || undefined}
teamName={formData.name || 'Team Name'}
className="rounded-lg border border-neutral-200 transition-all group-hover:border-primary-300"
/>
{/* Banner Action Buttons */}
<div className="absolute inset-0 bg-black/40 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
<Button
variant="primary"
@@ -290,21 +222,20 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
size="sm"
onClick={(e) => {
e.stopPropagation();
handleRemoveBanner();
handleInputChange('banner', null);
}}
className="bg-white/90 hover:bg-white text-red-600 border-transparent shadow-sm hover:text-red-700 gap-2"
>
<DeleteOutlined className="text-sm" />
Delete Banner
Delete
</Button>
)}
</div>
</div>
</div>
{/* Team Logo & Name Row */}
{/* Logo & Name */}
<div className="grid grid-cols-12 gap-4 items-start">
{/* Interactive Team Logo */}
<div className="col-span-2">
<label className="block text-sm font-medium text-neutral-700 mb-2">
Logo
@@ -321,7 +252,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
<TeamOutlined className="text-neutral-400 text-xl" />
)}
</div>
{/* Logo Hover Overlay - Full circle */}
<button
onClick={(e) => {
e.stopPropagation();
@@ -331,14 +261,12 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
>
<CameraOutlined className="text-white text-lg" />
</button>
{/* Logo Menu Dropdown */}
{showLogoMenu && (
<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={(e) => {
e.stopPropagation();
triggerLogoUpload();
logoInputRef.current?.click();
}}
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"
>
@@ -349,7 +277,8 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
<button
onClick={(e) => {
e.stopPropagation();
handleRemoveLogo();
handleInputChange('logo', null);
setShowLogoMenu(false);
}}
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"
>
@@ -362,7 +291,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
</div>
</div>
{/* Team Name */}
<div className="col-span-10 space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Team Name <span className="text-danger-500">*</span>
@@ -374,22 +302,19 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
/>
<p className="text-xs text-neutral-500">
Click logo to upload or change team logo (circular format)
</p>
</div>
</div>
{/* Team Description */}
{/* Description */}
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Description
Description <span className="text-danger-500">*</span>
</label>
<textarea
className="w-full border border-neutral-200 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none resize-none"
placeholder="Enter team description (optional)"
placeholder="Enter team description"
rows={3}
value={formData.description || ''}
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
/>
</div>
@@ -424,7 +349,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
value="public"
checked={formData.visibility === 'public'}
onChange={(e) =>
handleInputChange('visibility', e.target.value as 'public')
handleInputChange('visibility', e.target.value)
}
className="text-primary-600"
/>
@@ -438,7 +363,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
value="private"
checked={formData.visibility === 'private'}
onChange={(e) =>
handleInputChange('visibility', e.target.value as 'private')
handleInputChange('visibility', e.target.value)
}
className="text-primary-600"
/>
@@ -448,288 +373,57 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
</div>
</div>
{/* Team Information (Read-only for existing teams) */}
{/* Team Details */}
{team && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-4 border-t border-neutral-200 pt-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Members
Team Leader ID
</label>
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
<UserOutlined className="text-neutral-500" />
<span className="text-sm text-neutral-700">
{team.member_count} member
{team.member_count !== 1 ? 's' : ''}
<div className="p-3 bg-neutral-50 rounded-lg flex items-center gap-3">
<CrownOutlined className="text-yellow-600 text-lg" />
<span className="text-sm text-neutral-700 font-mono">
{team.leader_id}
</span>
</div>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Submission Status
</label>
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
<div
className={cn(
'w-2 h-2 rounded-full',
team.has_submission ? 'bg-success-500' : 'bg-danger-500'
)}
/>
<span
className={cn(
'text-sm font-medium',
team.has_submission
? 'text-success-700'
: 'text-danger-700'
)}
>
{team.has_submission ? 'Submitted' : 'Not Submitted'}
</span>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Created
</label>
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
<CalendarOutlined className="text-neutral-500" />
<span className="text-sm text-neutral-700">
{new Date(team.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
</div>
</div>
</div>
)}
{/* Tabs for Details and Members */}
{team && (
<div>
<div className="flex border-b border-neutral-200">
<button
className={cn(
'px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer',
activeTab === 'details'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
)}
onClick={() => setActiveTab('details')}
>
Team Details
</button>
<button
className={cn(
'px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer',
activeTab === 'members'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
)}
onClick={() => setActiveTab('members')}
>
Members ({team.member_count})
</button>
</div>
<div className="pt-4">
{activeTab === 'details' && (
<div className="space-y-4">
{/* Leader Information */}
{leader && (
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Team Leader
</label>
<div className="p-3 bg-neutral-50 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden">
{leader.user.avatar ? (
<img
src={leader.user.avatar}
alt={leader.user.fullname}
className="w-full h-full object-cover"
/>
) : (
<CrownOutlined className="text-yellow-600 text-sm" />
)}
</div>
<div>
<p className="text-sm font-medium text-neutral-900">
{leader.user.fullname}
</p>
<p className="text-xs text-neutral-500">
{leader.user.email}
</p>
</div>
</div>
</div>
</div>
)}
{/* Timestamps */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Created
</label>
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
<CalendarOutlined className="text-neutral-500" />
<span className="text-sm text-neutral-700">
{new Date(team.created_at).toLocaleDateString(
'en-US',
{
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}
)}
</span>
</div>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Last Updated
</label>
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
<CalendarOutlined className="text-neutral-500" />
<span className="text-sm text-neutral-700">
{new Date(team.updated_at).toLocaleDateString(
'en-US',
{
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}
)}
</span>
</div>
</div>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Last Updated
</label>
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
<CalendarOutlined className="text-neutral-500" />
<span className="text-sm text-neutral-700">
{new Date(team.updated_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
)}
{activeTab === 'members' && (
<div className="space-y-4">
{/* Accepted Members */}
<div>
<h4 className="text-sm font-medium text-neutral-700 mb-3">
Active Members ({acceptedMembers.length})
</h4>
<div className="space-y-2">
{acceptedMembers.map((member) => (
<div
key={member.id}
className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg"
>
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden">
{member.user.avatar ? (
<img
src={member.user.avatar}
alt={member.user.fullname}
className="w-full h-full object-cover"
/>
) : (
<UserOutlined className="text-neutral-500" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-neutral-900">
{member.user.fullname}
</p>
{member.role === 'leader' && (
<CrownOutlined className="text-yellow-600 text-xs" />
)}
</div>
<p className="text-xs text-neutral-500">
{member.user.email}
</p>
{member.user.skills.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{member.user.skills
.slice(0, 2)
.map((skill, idx) => (
<span
key={idx}
className="px-1 py-0.5 bg-primary-100 text-primary-700 text-xs rounded"
>
{skill}
</span>
))}
{member.user.skills.length > 2 && (
<span className="text-xs text-neutral-400">
+{member.user.skills.length - 2}
</span>
)}
</div>
)}
</div>
<div className="text-xs text-neutral-500">
Joined{' '}
{new Date(member.joined_at).toLocaleDateString()}
</div>
</div>
))}
</div>
</div>
{/* Pending Members */}
{pendingMembers.length > 0 && (
<div>
<h4 className="text-sm font-medium text-neutral-700 mb-3">
Pending Members ({pendingMembers.length})
</h4>
<div className="space-y-2">
{pendingMembers.map((member) => (
<div
key={member.id}
className="flex items-center gap-3 p-3 bg-orange-50 border border-orange-200 rounded-lg"
>
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden">
{member.user.avatar ? (
<img
src={member.user.avatar}
alt={member.user.fullname}
className="w-full h-full object-cover"
/>
) : (
<UserOutlined className="text-neutral-500" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-neutral-900">
{member.user.fullname}
</p>
<ClockCircleOutlined className="text-orange-600 text-xs" />
</div>
<p className="text-xs text-neutral-500">
{member.user.email}
</p>
</div>
<div className="flex gap-2">
<Button
variant="primary"
size="sm"
className="flex items-center gap-1"
onClick={() =>
console.log('Accept member:', member.id)
}
>
<CheckCircleOutlined className="text-xs" />
Accept
</Button>
<Button
variant="secondary"
size="sm"
className="flex items-center gap-1"
onClick={() =>
console.log('Reject member:', member.id)
}
>
<CloseCircleOutlined className="text-xs" />
Reject
</Button>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
)}
@@ -772,46 +466,44 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-60 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md">
<div className="p-6">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-full bg-danger-100 flex items-center justify-center">
<ExclamationOutlined className="text-danger-600 text-xl" />
</div>
<div>
<h3 className="text-lg font-semibold text-neutral-900">
Delete Team
</h3>
<p className="text-sm text-neutral-500">
This action cannot be undone.
</p>
</div>
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-full bg-danger-100 flex items-center justify-center">
<ExclamationOutlined className="text-danger-600 text-xl" />
</div>
<p className="text-sm text-neutral-700 mb-6">
Are you sure you want to delete "{team?.name}"? This will
permanently remove the team and all associated data.
</p>
<div className="flex items-center gap-3 justify-end">
<Button
variant="secondary"
size="md"
onClick={() => setShowDeleteConfirm(false)}
>
Cancel
</Button>
<Button
variant="danger"
size="md"
onClick={handleDelete}
className="flex items-center gap-2"
>
<DeleteOutlined />
<div>
<h3 className="text-lg font-semibold text-neutral-900">
Delete Team
</Button>
</h3>
<p className="text-sm text-neutral-500">
This action cannot be undone.
</p>
</div>
</div>
<p className="text-sm text-neutral-700 mb-6">
Are you sure you want to delete "{team?.name}"? This will
permanently remove the team and all associated data.
</p>
<div className="flex items-center gap-3 justify-end">
<Button
variant="secondary"
size="md"
onClick={() => setShowDeleteConfirm(false)}
>
Cancel
</Button>
<Button
variant="danger"
size="md"
onClick={handleDelete}
className="flex items-center gap-2"
>
<DeleteOutlined />
Delete Team
</Button>
</div>
</div>
</div>
)}
@@ -1,216 +0,0 @@
import { FC } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import {
CloseOutlined,
LinkOutlined,
ProjectOutlined,
} from '@ant-design/icons';
interface SubmissionModalProps {
isOpen: boolean;
onClose: () => void;
teamId: string;
teamName: string;
}
const SubmissionModal: FC<SubmissionModalProps> = ({
isOpen,
onClose,
teamId,
teamName,
}) => {
if (!isOpen) return null;
// Mock submission data
const mockSubmission = {
id: `submission-${teamId}`,
project_name: `${teamName} Project`,
repository_url: `https://github.com/${teamName
.toLowerCase()
.replace(/\s+/g, '-')}/hackathon-project`,
demo_url: `https://${teamName
.toLowerCase()
.replace(/\s+/g, '-')}.vercel.app`,
presentation_url: `https://docs.google.com/presentation/d/${teamId}/edit`,
submitted_at: new Date().toISOString(),
status: 'submitted',
description:
'An innovative solution built during the IMPHNEN x Kolosal.ai Hackathon 2025.',
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-success-100 flex items-center justify-center">
<ProjectOutlined className="text-success-600 text-lg" />
</div>
<div>
<h2 className="text-xl font-semibold text-neutral-900">
Project Submission
</h2>
<p className="text-sm text-neutral-500">
{teamName} - Hackathon Submission Details
</p>
</div>
</div>
<button
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors cursor-pointer"
onClick={onClose}
>
<CloseOutlined className="text-neutral-400 text-lg" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Submission Status */}
<div className="flex items-center gap-3 p-4 bg-success-50 border border-success-200 rounded-lg">
<div className="w-3 h-3 rounded-full bg-success-500"></div>
<div>
<p className="text-sm font-medium text-success-800">
Submission Completed
</p>
<p className="text-xs text-success-600">
Submitted on{' '}
{new Date(mockSubmission.submitted_at).toLocaleDateString(
'en-UK',
{
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}
)}
</p>
</div>
</div>
{/* Project Information */}
<div className="grid gap-6">
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Project Name
</label>
<p className="text-sm text-neutral-900 p-3 bg-neutral-50 rounded-lg">
{mockSubmission.project_name}
</p>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Project Description
</label>
<p className="text-sm text-neutral-900 p-3 bg-neutral-50 rounded-lg">
{mockSubmission.description}
</p>
</div>
{/* Links Section */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Repository
</label>
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-700 flex-1 truncate">
{mockSubmission.repository_url}
</span>
<Button
variant="secondary"
size="sm"
className="shrink-0"
onClick={() =>
window.open(mockSubmission.repository_url, '_blank')
}
>
<LinkOutlined className="text-xs" />
</Button>
</div>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Live Demo
</label>
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-700 flex-1 truncate">
{mockSubmission.demo_url}
</span>
<Button
variant="secondary"
size="sm"
className="shrink-0"
onClick={() =>
window.open(mockSubmission.demo_url, '_blank')
}
>
<LinkOutlined className="text-xs" />
</Button>
</div>
</div>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Presentation
</label>
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-700 flex-1 truncate">
{mockSubmission.presentation_url}
</span>
<Button
variant="secondary"
size="sm"
className="shrink-0"
onClick={() =>
window.open(mockSubmission.presentation_url, '_blank')
}
>
<LinkOutlined className="text-xs" />
</Button>
</div>
</div>
</div>
{/* Action Note */}
<div className="p-4 bg-info-50 border border-info-200 rounded-lg">
<p className="text-sm text-info-800">
<strong>Note:</strong> This is a submission preview. The team has
successfully submitted their project. You can review the
submission details and access the project links above.
</p>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end p-6 border-t border-neutral-200">
<div className="flex items-center gap-3">
<Button variant="secondary" size="md" onClick={onClose}>
Close
</Button>
<Button
variant="primary"
size="md"
onClick={() => {
// Navigate to hackathon-submissions page
console.log('Navigate to full submissions page');
// You can implement navigation here
onClose();
}}
className="flex items-center gap-2"
>
<ProjectOutlined />
View All Submissions
</Button>
</div>
</div>
</div>
</div>
);
};
export default SubmissionModal;
@@ -1,8 +1,13 @@
import { FC, ReactElement, useState, useMemo, useCallback } from 'react';
import {
FC,
ReactElement,
useState,
useEffect,
useMemo,
useCallback,
} from 'react';
import ModalTeamDetail from './_components/modal-team-detail-new';
import SubmissionModal from './_components/submission-modal';
import { CityFilterSelect } from '../../../components/city-filter-select';
import INDONESIAN_CITIES from '../../../constants/cities';
import {
BackofficeWrapper,
DataTable,
@@ -16,205 +21,119 @@ import {
SearchOutlined,
FilterOutlined,
PlusOutlined,
UserOutlined,
ArrowRightOutlined,
LoadingOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import {
getAdminTeams,
TAdminTeamItem,
} from '@imphnen-frontend-service/service';
import { useSearchParams } from 'react-router-dom';
// Define interface outside component
interface TeamMember {
id: string;
joined_at: string;
role: 'leader' | 'member';
status: 'pending' | 'accepted' | 'rejected';
team_id: string;
user: {
avatar?: string;
bio?: string;
created_at: string;
email: string;
fullname: string;
id: string;
is_active: boolean;
location: string;
phone_number?: string;
skills: string[];
updated_at: string;
};
user_id: string;
}
interface TeamType {
id: string;
name: string;
description?: string;
city: string;
banner?: string;
logo?: string;
visibility: 'public' | 'private';
member_count: number;
has_submission: boolean;
created_at: string;
updated_at: string;
leader_id: string;
members: TeamMember[];
}
// Move mock data outside component to prevent recreation
// Sample data for popular cities from the INDONESIAN_CITIES constant
const cities = INDONESIAN_CITIES.slice(0, 20); // Use first 20 cities for variety
const teamNames = [
'Innovators',
'Hackers',
'Builders',
'Creators',
'Pioneers',
'Developers',
'Engineers',
'Coders',
'Tech Stars',
'Digital Wizards',
];
const descriptions = [
'Building innovative solutions for modern problems with cutting-edge technology',
'Passionate developers creating the next generation of web applications',
'Focused on sustainable tech solutions that make a positive impact',
'Experienced team working on scalable fintech innovations',
'Creative minds developing user-centric mobile applications',
'Full-stack developers building comprehensive business solutions',
'AI enthusiasts creating intelligent automation tools',
'Open source advocates building community-driven platforms',
];
const skills = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
const generateMembers = (
count: number,
teamId: string,
leaderId: string
): TeamMember[] => {
return Array.from({ length: count }, (_, i) => {
const isLeader = i === 0;
const memberId = isLeader ? leaderId : `user-${teamId}-${i}`;
return {
id: `member-${teamId}-${i}`,
joined_at: new Date(
Date.now() - (count - i) * 86400000 * Math.random() * 5
).toISOString(),
role: isLeader ? 'leader' : 'member',
status: Math.random() > 0.8 ? 'pending' : 'accepted',
team_id: teamId,
user: {
id: memberId,
avatar:
Math.random() > 0.6
? `https://ui-avatars.com/api/?name=${encodeURIComponent(
`User ${i}`
)}`
: undefined,
bio:
Math.random() > 0.5
? `Passionate ${skills[
Math.floor(Math.random() * skills.length)
].toLowerCase()} with ${
Math.floor(Math.random() * 8) + 1
}+ years experience`
: undefined,
created_at: new Date(
Date.now() - Math.random() * 365 * 86400000
).toISOString(),
email: `user${i}.team${teamId}@example.com`,
fullname: `${
['Ahmad', 'Sofia', 'Budi', 'Sari', 'Rizki', 'Maya', 'Andi', 'Dina'][
Math.floor(Math.random() * 8)
]
} ${
[
'Wijuana',
'Santoso',
'Pratama',
'Dewi',
'Nugroho',
'Sari',
'Putra',
'Lestari',
][Math.floor(Math.random() * 8)]
}`,
is_active: true,
location: cities[Math.floor(Math.random() * cities.length)],
phone_number:
Math.random() > 0.7
? `+62${Math.floor(Math.random() * 9000000000) + 1000000000}`
: undefined,
skills: skills.slice(0, Math.floor(Math.random() * 3) + 1),
updated_at: new Date().toISOString(),
},
user_id: memberId,
};
});
};
const mockData: TeamType[] = Array.from({ length: 50 }, (_, i) => {
const teamId = `team-${String(i + 1).padStart(3, '0')}`;
const memberCount = Math.floor(Math.random() * 5) + 1; // 1-5 members
const leaderId = `leader-${teamId}`;
const members = generateMembers(memberCount, teamId, leaderId);
return {
id: teamId,
name: `Team ${teamNames[i % teamNames.length]} ${
Math.floor(i / teamNames.length) + 1
}`,
description:
i % 4 === 0 ? undefined : descriptions[i % descriptions.length],
city: cities[i % cities.length],
banner:
i % 3 === 0 ? undefined : `https://picsum.photos/600/200?random=${i}`, // 3:1 aspect ratio
logo:
i % 4 === 0
? undefined
: `https://ui-avatars.com/api/?name=${encodeURIComponent(
teamNames[i % teamNames.length]
)}&background=random&size=120`,
visibility: i % 4 === 0 ? 'private' : 'public',
member_count: memberCount,
has_submission: i % 3 !== 0,
created_at: new Date(
Date.now() - i * 86400000 * (Math.random() * 15 + 1)
).toISOString(),
updated_at: new Date().toISOString(),
leader_id: leaderId,
members: members,
};
});
type TeamType = TAdminTeamItem;
export const HackathonTeamsPage: FC = (): ReactElement => {
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = Math.max(
1,
parseInt(searchParams.get('page') || '1', 10)
);
const searchQuery = searchParams.get('search') || '';
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showNewTeamModal, setShowNewTeamModal] = useState(false);
const [showSubmissionModal, setShowSubmissionModal] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<TeamType | null>(null);
const [selectedSubmissionTeam, setSelectedSubmissionTeam] =
useState<TeamType | null>(null);
const [globalFilter, setGlobalFilter] = useState('');
useState<TeamType | null>(null);
const [globalFilter, setGlobalFilter] = useState(searchQuery);
// Advanced filtering states
const [visibilityFilter, setVisibilityFilter] = useState('all');
const [cityFilter, setCityFilter] = useState('all');
const [submissionFilter, setSubmissionFilter] = useState('all');
const [memberCountFilter, setMemberCountFilter] = useState('all');
// Constants
const pageSize = 10;
// Fetch teams from API
const {
data: teamsResponse,
isLoading,
isFetching,
} = useQuery({
queryKey: [
'admin-teams',
currentPage,
perPage,
cityFilter,
visibilityFilter,
searchQuery,
],
queryFn: () =>
getAdminTeams({
page: currentPage,
per_page: perPage,
search: searchQuery || undefined,
}),
staleTime: 30000, // 30 seconds cache
gcTime: 5 * 60 * 1000, // 5 minutes
});
const totalData = teamsResponse?.meta?.total_data || 0;
const totalPages = teamsResponse?.meta?.total_page || 1;
// Handle page change - update URL query params
const handlePageChange = useCallback(
(newPage: number) => {
const params = new URLSearchParams();
params.set('page', newPage.toString());
if (perPage !== 10) params.set('per_page', perPage.toString());
if (searchQuery) params.set('search', searchQuery);
setSearchParams(params);
window.scrollTo({ top: 0, behavior: 'smooth' });
},
[setSearchParams, perPage, searchQuery]
);
// Validate page number doesn't exceed total pages
useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
setSearchParams({ page: totalPages.toString() });
}
}, [currentPage, totalPages, setSearchParams, isLoading]);
// Sync globalFilter with URL search param on mount
useEffect(() => {
setGlobalFilter(searchQuery);
}, [searchQuery]);
// Handle search teams
const handleSearch = useCallback(() => {
const params = new URLSearchParams();
params.set('page', '1');
if (perPage !== 10) params.set('per_page', perPage.toString());
if (globalFilter.trim()) {
params.set('search', globalFilter.trim());
}
setSearchParams(params);
}, [globalFilter, setSearchParams, perPage]);
// Handle Enter key press in search input
const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
},
[handleSearch]
);
// Handle per page change
const handlePerPageChange = useCallback(
(newPerPage: number) => {
const params = new URLSearchParams();
params.set('page', '1');
params.set('per_page', newPerPage.toString());
if (searchQuery) params.set('search', searchQuery);
setSearchParams(params);
},
[setSearchParams, searchQuery]
);
// Memoize the callback to prevent recreation
const handleShowDetailModal = useCallback((team: TeamType) => {
@@ -235,67 +154,10 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
setShowNewTeamModal(false);
}, []);
const handleShowSubmissionModal = useCallback((team: TeamType) => {
setSelectedSubmissionTeam(team);
setShowSubmissionModal(true);
}, []);
const handleCloseSubmissionModal = useCallback(() => {
setShowSubmissionModal(false);
setSelectedSubmissionTeam(null);
}, []);
// Filter data based on current filter states
// Get teams data from API response
const filteredData = useMemo(() => {
return mockData.filter((team) => {
// Global search filter
if (globalFilter) {
const searchTerm = globalFilter.toLowerCase();
const leaderName =
team.members.find((m) => m.role === 'leader')?.user.fullname || '';
const memberNames = team.members.map((m) => m.user.fullname).join(' ');
if (
!team.name.toLowerCase().includes(searchTerm) &&
!team.city.toLowerCase().includes(searchTerm) &&
!leaderName.toLowerCase().includes(searchTerm) &&
!memberNames.toLowerCase().includes(searchTerm)
) {
return false;
}
}
// Visibility filter
if (visibilityFilter !== 'all' && team.visibility !== visibilityFilter) {
return false;
}
// City filter
if (cityFilter !== 'all' && team.city !== cityFilter) {
return false;
}
// Submission filter
if (submissionFilter !== 'all') {
const hasSubmission = submissionFilter === 'submitted';
if (team.has_submission !== hasSubmission) return false;
}
// Member count filter
if (memberCountFilter !== 'all') {
const count = parseInt(memberCountFilter);
if (team.member_count !== count) return false;
}
return true;
});
}, [
globalFilter,
visibilityFilter,
cityFilter,
submissionFilter,
memberCountFilter,
]);
return teamsResponse?.data || [];
}, [teamsResponse]);
// Memoize columns to prevent recreation on every render
const columns: ColumnDef<TeamType>[] = useMemo(
@@ -319,9 +181,12 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
<TeamOutlined className="text-neutral-400 text-lg" />
)}
</div>
{/* Team Name & Description */}
{/* Team Name */}
<div className="min-w-0 flex-1">
<p className="font-medium text-neutral-900 truncate">
<p
className="font-medium text-neutral-900 truncate max-w-sm"
title={team.name}
>
{team.name}
</p>
</div>
@@ -359,81 +224,15 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
enableSorting: true,
},
{
accessorKey: 'member_count',
header: 'Members',
id: 'leader',
header: 'Leader ID',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<UserOutlined className="text-neutral-400 text-sm" />
<span className="text-sm text-neutral-700">
{row.original.member_count}
</span>
<div className="text-sm text-neutral-700 font-mono">
{row.original.leader_id}
</div>
),
enableSorting: true,
},
{
id: 'leader',
header: 'Leader',
cell: ({ row }) => {
const leader = row.original.members.find(
(m) => m.role === 'leader'
)?.user;
return leader ? (
<div>
<div className="text-sm font-medium text-neutral-900">
{leader.fullname}
</div>
<div className="text-xs text-neutral-500">{leader.email}</div>
</div>
) : (
<span className="text-neutral-400 italic">No leader</span>
);
},
enableSorting: false,
},
{
accessorKey: 'has_submission',
header: 'Submission',
cell: ({ row }) => {
const hasSubmission = row.original.has_submission;
return (
<div className="flex items-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
hasSubmission ? 'bg-success-500' : 'bg-danger-500'
)}
/>
<div className="flex flex-col">
<span
className={cn(
'text-sm font-medium',
hasSubmission ? 'text-success-700' : 'text-danger-700'
)}
>
{hasSubmission ? 'Submitted' : 'Not Submitted'}
</span>
{hasSubmission && (
<button
className="text-xs text-primary-600 hover:text-primary-800 text-left cursor-pointer"
onClick={() => handleShowSubmissionModal(row.original)}
>
View Submission <ArrowRightOutlined />
</button>
)}
</div>
</div>
);
},
enableSorting: true,
sortingFn: (rowA, rowB) => {
const aSubmission = rowA.original.has_submission;
const bSubmission = rowB.original.has_submission;
if (aSubmission && !bSubmission) return -1;
if (!aSubmission && bSubmission) return 1;
return 0;
},
},
{
accessorKey: 'created_at',
header: 'Created',
@@ -469,7 +268,7 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
enableSorting: false,
},
],
[handleShowDetailModal, handleShowSubmissionModal]
[handleShowDetailModal]
);
return (
@@ -488,14 +287,31 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
<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 teams by name, city, or leader..."
placeholder="Search teams by name or city..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
onKeyPress={handleSearchKeyPress}
/>
</div>
{/* Visibility Filter */}
{/* Per Page Dropdown */}
<div className="relative">
<select
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={perPage}
onChange={(e) =>
handlePerPageChange(parseInt(e.target.value, 10))
}
>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
<option value={100}>100 / page</option>
</select>
</div>
{/* Visibility 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-40 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
@@ -506,47 +322,16 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
<option value="public">Public</option>
<option value="private">Private</option>
</select>
</div>
</div> */}
{/* City Filter */}
<CityFilterSelect
{/* <CityFilterSelect
value={cityFilter}
onChange={setCityFilter}
className="w-full sm:w-44"
placeholder="Search cities..."
allOptionLabel="All Cities"
/>
{/* Member Count 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-55 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={memberCountFilter}
onChange={(e) => setMemberCountFilter(e.target.value)}
>
<option value="all">All Member Count</option>
<option value="1">1 Member</option>
<option value="2">2 Members</option>
<option value="3">3 Members</option>
<option value="4">4 Members</option>
<option value="5">5 Members</option>
</select>
</div>
{/* Submission 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={submissionFilter}
onChange={(e) => setSubmissionFilter(e.target.value)}
>
<option value="all">All Submissions</option>
<option value="submitted">Submitted</option>
<option value="not_submitted">Not Submitted</option>
</select>
</div>
/> */}
</div>
{/* Right side - Add Team Button */}
@@ -564,10 +349,7 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
</div>
{/* Active filters display */}
{(visibilityFilter !== 'all' ||
cityFilter !== 'all' ||
submissionFilter !== 'all' ||
memberCountFilter !== 'all') && (
{(visibilityFilter !== 'all' || cityFilter !== 'all') && (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span>
@@ -597,32 +379,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
</span>
)}
{/* Member count filter badge */}
{memberCountFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-800 rounded-2xl text-sm">
Members: {memberCountFilter}
<button
onClick={() => setMemberCountFilter('all')}
className="text-blue-600 hover:text-blue-800 cursor-pointer"
>
</button>
</span>
)}
{/* Submission filter badge */}
{submissionFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm">
Submission: {submissionFilter}
<button
onClick={() => setSubmissionFilter('all')}
className="text-purple-600 hover:text-purple-800 cursor-pointer"
>
</button>
</span>
)}
{/* Clear all filters */}
<Button
variant="secondary"
@@ -630,8 +386,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
onClick={() => {
setVisibilityFilter('all');
setCityFilter('all');
setSubmissionFilter('all');
setMemberCountFilter('all');
setGlobalFilter('');
}}
className="text-sm text-neutral-600"
@@ -641,17 +395,36 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
</div>
)}
{/* Pagination-aware results display */}
{filteredData.length > 0 && (
<div className="text-sm text-neutral-600">
Showing {Math.min(pageSize, filteredData.length)} of{' '}
{filteredData.length} teams
{filteredData.length > pageSize}
{/* Loading & results display */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
<span className="ml-3 text-neutral-600">Loading teams...</span>
</div>
) : filteredData.length > 0 ? (
<>
<div className="text-sm text-neutral-600">
Showing {filteredData.length} of {totalData} teams (Page{' '}
{currentPage} of {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">(Updating...)</span>
)}
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination={true}
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="text-center py-12 text-neutral-500">
No teams found. Try adjusting your filters.
</div>
)}
{/* Table */}
<DataTable data={filteredData} columns={columns} pageSize={10} />
</section>
{/* Modals component */}
@@ -667,16 +440,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
onClose={handleCloseNewTeamModal}
team={null} // null indicates creating new team
/>
{/* Submission Modal */}
{selectedSubmissionTeam && (
<SubmissionModal
isOpen={showSubmissionModal}
onClose={handleCloseSubmissionModal}
teamId={selectedSubmissionTeam.id}
teamName={selectedSubmissionTeam.name}
/>
)}
</BackofficeWrapper>
);
};
@@ -15,10 +15,10 @@ import {
interface UserType {
id: string;
avatar?: string;
avatar?: string | null;
fullname: string;
bio?: string;
location: string;
location: string | null;
is_active: boolean;
skills: string[];
created_at: string;
@@ -77,7 +77,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
// Check if required fields are filled
const isFormValid = useMemo(() => {
if (!formData) return false;
return formData.fullname.trim() !== '' && formData.location.trim() !== '';
return formData.fullname?.trim() !== '' && formData.location?.trim() !== '';
}, [formData]);
const canSave = hasChanges && isFormValid;
@@ -259,13 +259,13 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
</div>
</div>
<button
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors cursor-pointer"
onClick={() => {
setShowAvatarMenu(false);
handleCancel();
}}
>
<CloseOutlined className="text-neutral-400 text-lg cursor-pointer" />
<CloseOutlined className="text-neutral-400 text-lg" />
</button>
</div>
@@ -294,13 +294,15 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
}
className={cn(
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none',
formData.fullname.trim() === ''
!formData.fullname ||
formData.fullname.trim() === ''
? 'border-red-300 bg-red-50'
: 'border-neutral-300'
)}
placeholder="Enter full name"
/>
{formData.fullname.trim() === '' && (
{(!formData.fullname ||
formData.fullname.trim() === '') && (
<p className="text-red-500 text-xs mt-1">
Full name is required
</p>
@@ -316,13 +318,14 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
Location <span className="text-red-500">*</span>
</label>
<select
value={formData.location}
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() === ''
!formData.location ||
formData.location.trim() === ''
? 'border-red-300 bg-red-50'
: 'border-neutral-300'
)}
@@ -334,7 +337,8 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
<option value="Medan">Medan</option>
<option value="Yogyakarta">Yogyakarta</option>
</select>
{formData.location.trim() === '' && (
{(!formData.location ||
formData.location.trim() === '') && (
<p className="text-red-500 text-xs mt-1">
Location is required
</p>
@@ -1,4 +1,11 @@
import { FC, ReactElement, useState, useMemo, useCallback } from 'react';
import {
FC,
ReactElement,
useState,
useEffect,
useMemo,
useCallback,
} from 'react';
import ModalUserDetail from './_components/modal-user-detail';
import {
BackofficeWrapper,
@@ -13,23 +20,19 @@ import {
SearchOutlined,
FilterOutlined,
PlusOutlined,
LoadingOutlined,
} from '@ant-design/icons';
import { CityFilterSelect } from '../../../components/city-filter-select';
import { useQuery } from '@tanstack/react-query';
import {
getAdminUsers,
TAdminUserItem,
} from '@imphnen-frontend-service/service';
import { useSearchParams } from 'react-router-dom';
// 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;
}
type UserType = TAdminUserItem;
// Move mock data outside component to prevent recreation
// Skills options for filter
const skillsOptions = [
'Frontend Developer',
'Backend Developer',
@@ -41,59 +44,108 @@ const skillsOptions = [
'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 [searchParams, setSearchParams] = useSearchParams();
const currentPage = Math.max(
1,
parseInt(searchParams.get('page') || '1', 10)
);
const searchQuery = searchParams.get('search') || '';
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showNewUserModal, setShowNewUserModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
const [globalFilter, setGlobalFilter] = useState('');
const [globalFilter, setGlobalFilter] = useState(searchQuery);
// Advanced filtering states
const [statusFilter, setStatusFilter] = useState('all');
const [cityFilter, setCityFilter] = useState('all');
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
// Constants
const pageSize = 10;
// Fetch users from API
const {
data: usersResponse,
isLoading,
isFetching,
} = useQuery({
queryKey: [
'admin-users',
currentPage,
perPage,
cityFilter,
statusFilter,
searchQuery,
],
queryFn: () =>
getAdminUsers({
page: currentPage,
per_page: perPage,
search: searchQuery || undefined,
}),
staleTime: 30000, // 30 seconds cache
gcTime: 5 * 60 * 1000, // 5 minutes
});
const totalData = usersResponse?.meta?.total_data || 0;
const totalPages = usersResponse?.meta?.total_page || 1;
// Handle page change - update URL query params
const handlePageChange = useCallback(
(newPage: number) => {
const params = new URLSearchParams();
params.set('page', newPage.toString());
if (perPage !== 10) params.set('per_page', perPage.toString());
if (searchQuery) params.set('search', searchQuery);
setSearchParams(params);
window.scrollTo({ top: 0, behavior: 'smooth' });
},
[setSearchParams, perPage, searchQuery]
);
// Validate page number doesn't exceed total pages
useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
setSearchParams({ page: totalPages.toString() });
}
}, [currentPage, totalPages, setSearchParams, isLoading]);
// Sync globalFilter with URL search param on mount
useEffect(() => {
setGlobalFilter(searchQuery);
}, [searchQuery]);
// Handle search users
const handleSearch = useCallback(() => {
const params = new URLSearchParams();
params.set('page', '1');
if (perPage !== 10) params.set('per_page', perPage.toString());
if (globalFilter.trim()) {
params.set('search', globalFilter.trim());
}
setSearchParams(params);
}, [globalFilter, setSearchParams, perPage]);
// Handle Enter key press in search input
const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
},
[handleSearch]
);
// Handle per page change
const handlePerPageChange = useCallback(
(newPerPage: number) => {
const params = new URLSearchParams();
params.set('page', '1');
params.set('per_page', newPerPage.toString());
if (searchQuery) params.set('search', searchQuery);
setSearchParams(params);
},
[setSearchParams, searchQuery]
);
// Memoize the callback to prevent recreation
const handleShowDetailModal = useCallback((user: UserType) => {
@@ -116,29 +168,26 @@ export const HackathonUsersPage: FC = (): ReactElement => {
// Filter data based on current filter states
const filteredData = useMemo(() => {
return mockData.filter((user) => {
const usersData = usersResponse?.data || [];
return usersData.filter((user: UserType) => {
// Status filter
if (statusFilter !== 'all') {
const isActive = statusFilter === 'active';
if (user.is_active !== isActive) return false;
}
// City filter
if (cityFilter !== 'all' && user.location !== cityFilter) {
return false;
}
// Skills filter
if (skillsFilter.length > 0) {
const userSkills = user.skills || [];
const hasMatchingSkill = skillsFilter.some((skill) =>
user.skills.includes(skill)
userSkills.includes(skill)
);
if (!hasMatchingSkill) return false;
}
return true;
});
}, [statusFilter, cityFilter, skillsFilter]);
}, [usersResponse, statusFilter, skillsFilter]);
// Memoize columns to prevent recreation on every render
const columns: ColumnDef<UserType>[] = useMemo(
@@ -173,23 +222,32 @@ export const HackathonUsersPage: FC = (): ReactElement => {
{
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>
),
cell: ({ row }) => {
const skills = row.original.skills || [];
return (
<div className="flex flex-wrap gap-1 max-w-xs">
{skills.length > 0 ? (
<>
{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>
))}
{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">
+{skills.length - 2}
</span>
)}
</>
) : (
<span className="text-neutral-400">-</span>
)}
</div>
);
},
enableSorting: false,
},
{
@@ -260,7 +318,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
<EditOutlined className="text-sm" />
Manage
</Button>
<Button
{/* <Button
variant="secondary"
size="sm"
className="text-sm px-4 py-2"
@@ -270,7 +328,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
}}
>
{row.original.is_active ? 'Deactivate' : 'Activate'}
</Button>
</Button> */}
</div>
),
enableSorting: false,
@@ -298,11 +356,28 @@ export const HackathonUsersPage: FC = (): ReactElement => {
placeholder="Search users by name or location..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
onKeyPress={handleSearchKeyPress}
/>
</div>
{/* Status Filter */}
{/* Per Page Dropdown */}
<div className="relative">
<select
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={perPage}
onChange={(e) =>
handlePerPageChange(parseInt(e.target.value, 10))
}
>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
<option value={100}>100 / page</option>
</select>
</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"
@@ -313,10 +388,10 @@ export const HackathonUsersPage: FC = (): ReactElement => {
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div> */}
{/* City Filter */}
<CityFilterSelect
{/* <CityFilterSelect
value={cityFilter}
onChange={setCityFilter}
className="w-full sm:w-44"
@@ -333,10 +408,10 @@ export const HackathonUsersPage: FC = (): ReactElement => {
</button>
</span>
)}
)} */}
{/* Skills Filter with Icon */}
<div className="relative">
{/* <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"
@@ -361,7 +436,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
</option>
))}
</select>
</div>
</div> */}
</div>
{/* Right side - Add User Button */}
@@ -446,17 +521,36 @@ export const HackathonUsersPage: FC = (): ReactElement => {
</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}
{/* Loading & results display */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
<span className="ml-3 text-neutral-600">Loading users...</span>
</div>
) : filteredData.length > 0 ? (
<>
<div className="text-sm text-neutral-600">
Showing {filteredData.length} of {totalData} users (Page{' '}
{currentPage} of {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">(Updating...)</span>
)}
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination={true}
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="text-center py-12 text-neutral-500">
No users found. Try adjusting your filters.
</div>
)}
{/* Table */}
<DataTable data={filteredData} columns={columns} pageSize={10} />
</section>
{/* Modals component */}
@@ -23,7 +23,7 @@ export const AppLayout: FC = (): ReactElement => {
{/* Mobile menu button (shown on small screens) */}
<button
type="button"
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700"
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700 cursor-pointer"
onClick={() => setMobileSidebarOpen(true)}
aria-label="Open sidebar"
>
@@ -2,22 +2,39 @@ import { useForm } from 'react-hook-form';
import {
authLoginSchema,
TLoginRequest,
useBackofficeLogin,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useSession } from '@imphnen-frontend-service/utils';
import { useNavigate } from 'react-router';
import { toast } from 'sonner';
export const useLogin = () => {
const navigate = useNavigate();
const loginMutation = useBackofficeLogin();
const form = useForm<TLoginRequest>({
resolver: zodResolver(authLoginSchema),
mode: 'all',
defaultValues: {
email: '',
password: '',
},
});
const { signIn } = useSession();
const onSubmit = form.handleSubmit((data) => signIn(data));
const onSubmit = form.handleSubmit(async (data) => {
try {
await loginMutation.mutateAsync(data);
toast.success('Login berhasil!');
navigate('/hackathon-dashboard');
} catch (error) {
console.error('[Backoffice Login] Error:', error);
toast.error((error as Error).message || 'Login gagal');
}
});
return {
form,
onSubmit,
isLoading: loginMutation.isPending,
};
};
@@ -4,7 +4,7 @@ import { useLogin } from './_hooks/use-login';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
export const Components: FC = (): ReactElement => {
const { form, onSubmit } = useLogin();
const { form, onSubmit, isLoading } = useLogin();
return (
<div className="flex justify-center items-center min-h-screen">
@@ -22,6 +22,7 @@ export const Components: FC = (): ReactElement => {
name="email"
size="lg"
className="w-full"
disabled={isLoading}
/>
<ControlledInputField
control={form.control}
@@ -31,10 +32,11 @@ export const Components: FC = (): ReactElement => {
name="password"
size="lg"
className="w-full"
disabled={isLoading}
/>
<Button
disabled={
form.formState.isSubmitting ||
isLoading ||
form.formState.isValidating ||
!form.formState.isValid
}
@@ -42,7 +44,7 @@ export const Components: FC = (): ReactElement => {
size="md"
className="w-full"
>
Login
{isLoading ? 'Loading...' : 'Login'}
</Button>
</form>
</div>
+23
View File
@@ -0,0 +1,23 @@
import { Link } from 'react-router-dom';
export default function NotFoundPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<h1 className="text-9xl font-bold text-gray-200 mb-4">404</h1>
<h2 className="text-3xl font-semibold text-gray-900 dark:text-gray-300 mb-4">
Page Not Found
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
The page you are looking for doesn't exist or has been moved.
</p>
<Link
to="/hackathon-dashboard"
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
Go Back Home
</Link>
</div>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
export default function ErrorPage() {
const error = useRouteError();
let errorMessage: string;
if (isRouteErrorResponse(error)) {
errorMessage = error.statusText;
} else if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === 'string') {
errorMessage = error;
} else {
console.error(error);
errorMessage = 'Unknown error';
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-6xl font-bold text-red-600 mb-4">Oops!</h1>
<p className="text-xl text-gray-700 mb-2">
Sorry, an unexpected error has occurred.
</p>
<p className="text-gray-500 italic">{errorMessage}</p>
</div>
</div>
);
}
+3 -3
View File
@@ -84,11 +84,11 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
if (mappingPublicRoutes.includes(pathname)) {
if (token) return redirect('/dashboard');
if (token) return redirect('/hackathon-dashboard');
return null;
}
// if (!session) return redirect('/auth/login');
if (!session) return redirect('/auth/login');
const matchedRoute = mappingRoutePermissions.find(
(route) => route.path === pathname
@@ -100,7 +100,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
if (!hasPermission) {
return '/dashboard';
return '/hackathon-dashboard';
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/apps/backoffice',
server: {
port: 3000,
port: 3003,
host: 'localhost',
},
preview: {
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

@@ -0,0 +1,517 @@
import { FC, ReactElement, useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { decodeCertificateId } from '../../../utils/certificate';
import {
useTeamById,
useTeamSubmission,
useAuthStore,
} from '@imphnen-frontend-service/service';
import QRCode from 'qrcode';
import html2canvas from 'html2canvas';
interface DecodedCert {
teamId: string;
submissionId: string;
}
const CertificatePage: FC = (): ReactElement => {
const { certId } = useParams<{ certId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [decodedInfo, setDecodedInfo] = useState<DecodedCert | null>(null);
const [error, setError] = useState<string | null>(null);
const teamNameRef = useRef<HTMLHeadingElement>(null);
const userNameRef = useRef<HTMLHeadingElement>(null);
const [teamNameFontSize, setTeamNameFontSize] = useState('2.25rem');
const [userNameFontSize, setUserNameFontSize] = useState('2.25rem');
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
const certificateRef = useRef<HTMLDivElement>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [certificateImage, setCertificateImage] = useState<string>('');
const [showTemplate, setShowTemplate] = useState(true);
useEffect(() => {
if (certId) {
decodeCertificateId(certId)
.then(setDecodedInfo)
.catch(() => {
setError('Invalid certificate ID');
});
}
}, [certId]);
// Generate QR Code
useEffect(() => {
if (certId) {
const certificateUrl = `${window.location.origin}/certificate/${certId}`;
QRCode.toDataURL(certificateUrl, {
width: 200,
margin: 1,
color: {
dark: '#000000',
light: '#ffffff',
},
})
.then(setQrCodeUrl)
.catch((err) => console.error('QR Code generation failed:', err));
}
}, [certId]);
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
decodedInfo?.teamId || '',
!!decodedInfo?.teamId
);
const { data: submissionData, isLoading: isLoadingSubmission } =
useTeamSubmission(decodedInfo?.teamId || '', !!decodedInfo?.teamId);
const team = teamData?.data;
const submission = submissionData?.data;
const isLoading =
(!decodedInfo && !error) || isLoadingTeam || isLoadingSubmission;
// Dynamic font sizing: shrink by 2px if height exceeds 80px
useEffect(() => {
const adjustFontSize = (
element: HTMLElement | null,
maxHeight: number,
startSize: number,
setter: (size: string) => void
) => {
if (!element) return;
let currentSize = startSize;
element.style.fontSize = `${currentSize}px`;
while (element.offsetHeight > maxHeight && currentSize > 1) {
currentSize -= 2;
element.style.fontSize = `${currentSize}px`;
}
setter(`${currentSize}px`);
};
const timer = setTimeout(() => {
adjustFontSize(teamNameRef.current, 80, 20, setTeamNameFontSize);
adjustFontSize(userNameRef.current, 80, 36, setUserNameFontSize);
}, 0);
return () => clearTimeout(timer);
}, [team?.name, session?.user?.fullname]);
// Generate certificate canvas screenshot
useEffect(() => {
const generateCertificate = async () => {
if (!certificateRef.current || !team || !submission || !qrCodeUrl) return;
setIsGenerating(true);
try {
// Wait a bit for fonts and images to load
await new Promise((resolve) => setTimeout(resolve, 500));
const canvas = await html2canvas(certificateRef.current, {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
width: 1000,
height: (1000 * 2480) / 3508,
});
const imageUrl = canvas.toDataURL('image/png');
setCertificateImage(imageUrl);
setShowTemplate(false);
} catch (error) {
console.error('Failed to generate certificate:', error);
} finally {
setIsGenerating(false);
}
};
generateCertificate();
}, [team, submission, qrCodeUrl, session?.user?.fullname]);
// Download certificate
const handleDownloadCertificate = () => {
if (!certificateImage) return;
const link = document.createElement('a');
link.href = certificateImage;
link.download = `certificate-${team?.name || 'hackathon'}.png`;
link.click();
};
// Print certificate
const handlePrintCertificate = () => {
if (!certificateImage) return;
const printWindow = window.open('', '_blank');
if (printWindow) {
printWindow.document.write(`
<html>
<head>
<title>Certificate - ${team?.name}</title>
<style>
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
img { max-width: 100%; height: auto; }
@media print {
@page { size: A4 landscape; margin: 0; }
body { margin: 0; }
img { width: 100%; height: auto; }
}
</style>
</head>
<body>
<img src="${certificateImage}" />
</body>
</html>
`);
printWindow.document.close();
printWindow.onload = () => {
printWindow.print();
};
}
};
if (error || !certId) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-6xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Invalid Certificate
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
{error || 'The certificate ID is invalid or malformed.'}
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</div>
);
}
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<div className="text-gray-600 dark:text-gray-400">
Loading certificate...
</div>
</div>
</div>
);
}
if (!submission || submission.id !== decodedInfo?.submissionId) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-6xl mb-4">📄</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Certificate Not Found
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
The submission associated with this certificate could not be found.
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
{/* Print Styles */}
<style>{`
@media print {
@page {
size: A4 landscape;
margin: 0;
}
body {
margin: 0;
padding: 0;
}
body * {
visibility: hidden;
}
#certificate-wrapper {
visibility: visible;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: white;
}
#certificate, #certificate * {
visibility: visible;
}
#certificate {
position: relative;
max-width: 100%;
page-break-after: avoid;
}
.no-print {
display: none !important;
}
}
/* Mobile responsive - zoom out to fit */
@media (max-width: 768px) {
#certificate-container {
transform-origin: top center;
}
}
`}</style>
{/* Header */}
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 no-print">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Certificate
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{team?.name}
</p>
</div>
<Button
variant="secondary"
onClick={() =>
navigate(`/teams/${decodedInfo?.teamId}/submission`)
}
>
Back to Submission
</Button>
</div>
</div>
</div>
{/* Certificate Content */}
<div
className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"
id="certificate-wrapper"
>
{/* Hidden Template for Canvas Generation */}
<div
className={showTemplate ? 'block' : 'hidden'}
style={{ position: 'absolute', left: '-9999px' }}
>
<div
ref={certificateRef}
id="certificate-template"
style={{
position: 'relative',
backgroundImage: 'url(/images/blank_cert.png)',
backgroundSize: 'cover',
backgroundPosition: 'center',
width: '1000px',
height: `${(1000 * 2480) / 3508}px`,
}}
>
{/* User Name (from session) */}
<div
style={{
position: 'absolute',
top: '40%',
left: '50%',
transform: 'translateX(-50%)',
width: '80%',
}}
>
<h3
ref={userNameRef}
style={{
fontWeight: 'bold',
color: '#111827',
textAlign: 'center',
fontSize: userNameFontSize,
lineHeight: '1.2',
wordBreak: 'break-word',
textShadow: '0 1px 2px rgba(0,0,0,0.1)',
margin: 0,
}}
>
{session?.user?.fullname || 'N/A'}
</h3>
</div>
{/* Team Name */}
<div
style={{
position: 'absolute',
top: '46%',
left: '50%',
transform: 'translateX(-50%)',
width: '70%',
}}
>
<h3
ref={teamNameRef}
style={{
fontWeight: '600',
color: '#1f2937',
textAlign: 'center',
fontSize: teamNameFontSize,
lineHeight: '1.2',
wordBreak: 'break-word',
margin: 0,
}}
>
{team?.name}
</h3>
</div>
{/* Participation Text */}
<div
style={{
position: 'absolute',
top: '60%',
left: '50%',
transform: 'translateX(-50%)',
width: '70%',
}}
>
<p
style={{
textAlign: 'center',
color: '#374151',
fontSize: '18px',
fontWeight: '500',
margin: 0,
}}
>
<span style={{ fontWeight: 'bold' }}>
Peserta Hackathon IMPHNEN x KOLOSAL AI
</span>
</p>
</div>
{/* QR Code */}
<div
style={{
position: 'absolute',
bottom: '8%',
left: '8%',
}}
>
{qrCodeUrl && (
<div
style={{
backgroundColor: '#ffffff',
padding: '8px',
borderRadius: '4px',
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
}}
>
<img
src={qrCodeUrl}
alt="Certificate QR Code"
style={{ width: '96px', height: '96px' }}
/>
</div>
)}
</div>
{/* Date */}
<div
style={{
position: 'absolute',
bottom: '8%',
right: '8%',
}}
>
<p
style={{
fontSize: '14px',
color: '#374151',
margin: 0,
}}
>
{submission.submitted_at
? new Date(submission.submitted_at).toLocaleDateString(
'id-ID',
{
day: 'numeric',
month: 'long',
year: 'numeric',
}
)
: 'N/A'}
</p>
</div>
</div>
</div>
{/* Display Certificate Image */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 overflow-hidden p-2">
{isGenerating && (
<div className="flex items-center justify-center p-12">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<div className="text-gray-600 dark:text-gray-400">
Generating certificate...
</div>
</div>
</div>
)}
{certificateImage && !isGenerating && (
<img
src={certificateImage}
alt="Certificate"
className="w-full h-auto"
style={{ maxWidth: '100%', height: 'auto' }}
/>
)}
{/* Actions */}
<div className="bg-gray-50 dark:bg-gray-800 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
<Button
variant="secondary"
onClick={handleDownloadCertificate}
className="flex items-center gap-2"
disabled={isGenerating}
>
{isGenerating ? '⏳ Generating...' : '📥 Download'}
</Button>
<Button
variant="secondary"
onClick={handlePrintCertificate}
className="flex items-center gap-2"
disabled={isGenerating}
>
{isGenerating ? '⏳ Generating...' : '🖨️ Print'}
</Button>
<Button
onClick={() =>
navigate(`/teams/${decodedInfo?.teamId}/submission`)
}
variant="secondary"
className="col-span-2 flex items-center gap-2 xl:col-span-1"
>
View Submission
</Button>
</div>
</div>
{/* Info Box */}
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 no-print">
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
Certificate Information
</h3>
<p className="text-sm text-blue-800 dark:text-blue-200">
This certificate is a digital record of your hackathon participation
and project submission. You can print or save this page as a PDF for
your records.
</p>
</div>
</div>
</div>
);
};
export default CertificatePage;
+91
View File
@@ -14,6 +14,9 @@ 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');
// Submission deadline: 2025-12-07 23:59:00 WIB (UTC+7)
const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z');
type Invitation = {
id: string;
team: {
@@ -37,9 +40,46 @@ type Invitation = {
const DashboardPage: FC = (): ReactElement => {
const { session } = useAuthStore();
const [showProfileModal, setShowProfileModal] = useState(false);
const [timeLeft, setTimeLeft] = useState<{
days: number;
hours: number;
minutes: number;
seconds: number;
} | null>(null);
// Check if team features are closed
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
// Check if submission deadline passed
const isSubmissionDeadlinePassed = new Date() >= SUBMISSION_DEADLINE;
// Countdown timer
useEffect(() => {
if (isSubmissionDeadlinePassed) return;
const calculateTimeLeft = () => {
const now = new Date();
const difference = SUBMISSION_DEADLINE.getTime() - now.getTime();
if (difference <= 0) {
setTimeLeft(null);
return;
}
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
const minutes = Math.floor((difference / 1000 / 60) % 60);
const seconds = Math.floor((difference / 1000) % 60);
setTimeLeft({ days, hours, minutes, seconds });
};
calculateTimeLeft();
const timer = setInterval(calculateTimeLeft, 1000);
return () => clearInterval(timer);
}, [isSubmissionDeadlinePassed]);
// Lock background scroll when profile modal is open
useEffect(() => {
if (showProfileModal) {
@@ -94,6 +134,57 @@ const DashboardPage: FC = (): ReactElement => {
)}
</div>
{/* Countdown Timer */}
{timeLeft && !isSubmissionDeadlinePassed && (
<div className="mb-8 bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6">
<div className="flex items-start space-x-3">
<span className="text-3xl"></span>
<div className="flex-1">
<h3 className="font-bold text-blue-900 dark:text-blue-100 text-lg">
Submission Deadline
</h3>
<p className="text-blue-800 dark:text-blue-200 mt-2 text-sm font-sans">
Project submissions close on December 7, 2025 at 23:59 WIB
</p>
<div className="mt-4 grid grid-cols-4 gap-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{timeLeft.days}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Days
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{timeLeft.hours.toString().padStart(2, '0')}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Hours
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{timeLeft.minutes.toString().padStart(2, '0')}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Minutes
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{timeLeft.seconds.toString().padStart(2, '0')}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Seconds
</div>
</div>
</div>
</div>
</div>
</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">
@@ -2,6 +2,7 @@ import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import { useTeamById, useTeamSubmission } from '@imphnen-frontend-service/service';
import { encodeCertificateId } from '../../../../utils/certificate';
const SubmissionViewPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
@@ -15,18 +16,18 @@ const SubmissionViewPage: 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">Loading submission...</div>
<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 submission...</div>
</div>
);
}
if (!submission) {
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">
<div className="text-6xl mb-4">📄</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">No Submission Yet</h2>
<p className="text-gray-600 dark:text-neutral-400 mb-4">Your team hasn't submitted a project</p>
<p className="text-gray-600 dark:text-gray-400 mb-4">Your team hasn't submitted a project</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
</div>
);
@@ -43,13 +44,13 @@ const SubmissionViewPage: FC = (): ReactElement => {
: 'Not submitted';
return (
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950">
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700">
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Project Submission</h1>
<p className="text-gray-600 dark:text-neutral-400 mt-1">{team?.name}</p>
<p className="text-gray-600 dark:text-gray-400 mt-1">{team?.name}</p>
</div>
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
@@ -101,9 +102,37 @@ const SubmissionViewPage: FC = (): ReactElement => {
</div>
)}
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-950/50 overflow-hidden">
{/* Certificate Banner - Only show when submission is submitted */}
{submission.status === 'submitted' && (
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-lg p-6 mb-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center space-x-3 flex-1 min-w-0">
<span className="text-4xl shrink-0">🏆</span>
<div className="min-w-0">
<h3 className="font-bold text-amber-900 dark:text-amber-100 text-lg">
View Your Certificate
</h3>
<p className="text-amber-700 dark:text-amber-300 text-sm">
Congratulations! Your certificate is ready to download and share.
</p>
</div>
</div>
<button
onClick={async () => {
const certId = await encodeCertificateId(teamId || '', submission.id);
navigate(`/certificate/${encodeURIComponent(certId)}`);
}}
className="shrink-0 px-6 py-2 bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors"
>
Get Certificate
</button>
</div>
</div>
)}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 overflow-hidden">
{/* Project Header */}
<div className="bg-gradient-to-r from-blue-600 to-blue-800 text-white p-8">
<div className="bg-linear-to-r from-blue-600 to-blue-800 text-white p-8">
<h2 className="text-3xl font-bold mb-2">{submission.project_name}</h2>
<p className="text-blue-100">Team: {team?.name}</p>
</div>
@@ -113,8 +142,8 @@ const SubmissionViewPage: FC = (): ReactElement => {
{/* Description */}
<div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Project Description</h3>
<div className="bg-gray-50 dark:bg-neutral-800 rounded-lg p-4">
<p className="text-gray-700 dark:text-neutral-300 whitespace-pre-wrap">{submission.description}</p>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{submission.description}</p>
</div>
</div>
@@ -170,7 +199,7 @@ const SubmissionViewPage: FC = (): ReactElement => {
<img
src={url}
alt={`Screenshot ${index + 1}`}
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-neutral-700 hover:border-blue-500 dark:hover:border-primary-500 transition-colors cursor-pointer"
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-primary-500 transition-colors cursor-pointer"
/>
</a>
))}
@@ -179,11 +208,11 @@ const SubmissionViewPage: FC = (): ReactElement => {
)}
{/* Submission Info */}
<div className="bg-gray-50 dark:bg-neutral-800 rounded-lg p-4 border-t-4 border-blue-600 dark:border-primary-500">
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border-t-4 border-blue-600 dark:border-primary-500">
<h3 className="text-sm font-bold text-gray-900 dark:text-white mb-2">Submission Information</h3>
<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="text-gray-600 dark:text-gray-400">Status:</span>
<span className={`font-medium ${
submission.status === 'submitted'
? 'text-green-600 dark:text-green-400'
@@ -199,11 +228,11 @@ const SubmissionViewPage: FC = (): ReactElement => {
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-neutral-400">Submitted:</span>
<span className="text-gray-600 dark:text-gray-400">Submitted:</span>
<span className="font-medium text-gray-900 dark:text-white">{submittedDate}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-neutral-400">Submission ID:</span>
<span className="text-gray-600 dark:text-gray-400">Submission ID:</span>
<span className="font-medium text-gray-900 dark:text-white font-mono text-xs">
{submission.id}
</span>
@@ -1,4 +1,4 @@
import { FC, ReactElement, useState } from 'react';
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, useParams } from 'react-router';
@@ -14,10 +14,14 @@ import {
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
const MIN_TEAM_MEMBERS = 2; // Minimum members required to submit (including leader)
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
// Submission deadline: 2025-12-07 23:59:00 WIB (UTC+7)
const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z');
const SubmitProjectPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
@@ -25,6 +29,42 @@ const SubmitProjectPage: FC = (): ReactElement => {
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [confirmText, setConfirmText] = useState('');
const [screenshots, setScreenshots] = useState<string[]>([]);
const [timeLeft, setTimeLeft] = useState<{
days: number;
hours: number;
minutes: number;
seconds: number;
} | null>(null);
// Check if deadline passed
const isDeadlinePassed = new Date() >= SUBMISSION_DEADLINE;
// Countdown timer
useEffect(() => {
if (isDeadlinePassed) return;
const calculateTimeLeft = () => {
const now = new Date();
const difference = SUBMISSION_DEADLINE.getTime() - now.getTime();
if (difference <= 0) {
setTimeLeft(null);
return;
}
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
const minutes = Math.floor((difference / 1000 / 60) % 60);
const seconds = Math.floor((difference / 1000) % 60);
setTimeLeft({ days, hours, minutes, seconds });
};
calculateTimeLeft();
const timer = setInterval(calculateTimeLeft, 1000);
return () => clearInterval(timer);
}, [isDeadlinePassed]);
const { data: teamData } = useTeamById(teamId || '');
const { data: submissionData } = useTeamSubmission(teamId || '', !!teamId);
@@ -85,6 +125,50 @@ const SubmitProjectPage: FC = (): ReactElement => {
);
}
// Show deadline passed screen
if (isDeadlinePassed) {
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">
Submission Closed
</h2>
<p className="text-gray-600 dark:text-gray-400">
Project submissions are no longer accepted.
</p>
</div>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
The submission deadline was December 7, 2025 at 23:59 WIB.
</p>
<button
onClick={() => navigate(`/teams/${teamId}`)}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer"
>
Back to Team
</button>
<button
onClick={() => navigate('/dashboard')}
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 Dashboard
</button>
</div>
</div>
</div>
);
}
const handleScreenshotUpload = async (
e: React.ChangeEvent<HTMLInputElement>
) => {
@@ -138,6 +222,57 @@ const SubmitProjectPage: FC = (): ReactElement => {
</div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Countdown Timer */}
{timeLeft && (
<div className="bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6 mb-6">
<div className="flex items-start space-x-3">
<span className="text-3xl"></span>
<div className="flex-1">
<h3 className="font-bold text-blue-900 dark:text-blue-100 text-lg">
Submission Deadline
</h3>
<p className="text-blue-800 dark:text-blue-200 mt-2 text-sm font-sans">
Submissions close on December 7, 2025 at 23:59 WIB
</p>
<div className="mt-4 grid grid-cols-4 gap-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{timeLeft.days}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Days
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{timeLeft.hours.toString().padStart(2, '0')}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Hours
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{timeLeft.minutes.toString().padStart(2, '0')}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Minutes
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{timeLeft.seconds.toString().padStart(2, '0')}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Seconds
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* 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">
@@ -200,6 +335,9 @@ const SubmitProjectPage: FC = (): ReactElement => {
<label className="block text-[15px] font-medium text-gray-700 dark:text-gray-300">
Project Description <span className="text-red-500">*</span>
</label>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
Describe your project, its features, and what problem it solves. You can also paste your demo video link here.
</p>
<Controller
control={form.control}
name="description"
@@ -207,7 +345,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
<div>
<Textarea
{...field}
placeholder="Describe your project, its features, and what problem it solves..."
placeholder="Describe your project, its features, and what problem it solves... You can paste your demo video link (YouTube, Loom, etc.) here as well."
rows={6}
className="w-full"
size="lg"
+318
View File
@@ -0,0 +1,318 @@
import { FC, ReactElement } from 'react';
import { useNavigate } from 'react-router';
import { useWinners } from '@imphnen-frontend-service/service';
const WinnerPage: FC = (): ReactElement => {
const navigate = useNavigate();
const { data, isLoading, error } = useWinners();
const winners = data?.data ?? [];
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<div className="text-gray-600 dark:text-gray-400">
Loading winners...
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-6xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Error Loading Winners
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Unable to load winners at this time. Please try again later.
</p>
</div>
);
}
// Sort winners by rank
const sortedWinners = [...winners].sort((a, b) => a.rank - b.rank);
// Medal emojis for top 3
const getMedalEmoji = (rank: number) => {
switch (rank) {
case 1:
return '🥇';
case 2:
return '🥈';
case 3:
return '🥉';
default:
return '🏆';
}
};
// Get rank color
const getRankColor = (rank: number) => {
switch (rank) {
case 1:
return 'from-yellow-400 to-yellow-600';
case 2:
return 'from-gray-300 to-gray-500';
case 3:
return 'from-amber-600 to-amber-800';
default:
return 'from-blue-500 to-blue-700';
}
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
{/* Header */}
<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-8">
<div className="text-center">
<div className="text-6xl mb-4">🏆</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-2">
Hackathon Winners
</h1>
<p className="text-gray-600 dark:text-gray-400">
Congratulations to all the winning teams!
</p>
</div>
</div>
</div>
{/* Winners List */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{winners.length === 0 ? (
<div className="text-center py-16">
<div className="text-6xl mb-4">🎯</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
No Winners Announced Yet
</h2>
<p className="text-gray-600 dark:text-gray-400">
Winners will be announced here once the hackathon concludes.
</p>
</div>
) : (
<div className="space-y-8">
{/* Top 3 Winners - Mobile View */}
<div className="md:hidden space-y-6">
{[1, 2, 3].map((position) => {
const winner = sortedWinners[position - 1];
if(!winner) return null;
return (
<div
key={winner.id}
className={`bg-white dark:bg-gray-800 rounded-lg p-3 shadow-lg ${
getRankColor(winner.rank).includes('yellow')
? 'border-4 border-yellow-400 dark:border-yellow-600'
: getRankColor(winner.rank).includes('gray')
? 'border-4 border-gray-400 dark:border-gray-600'
: 'border-4 border-amber-600 dark:border-amber-500'
}}`}
>
<div className="flex items-center gap-4">
<div
className={`aspect-square p-1 rounded-full bg-linear-to-br ${getRankColor(
winner.rank
)} flex items-center justify-center text-white font-bold text-2xl`}
>
<div className="text-4xl">
{getMedalEmoji(winner.rank)}
</div>
</div>
{winner.team.logo && (
<img
src={winner.team.logo}
alt={`${winner.team.name} logo`}
className="w-20 h-20 rounded-full object-cover border-3 border-white dark:border-gray-700 shadow-lg"
/>
)}
<div className="flex-1">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
{winner.team.name}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
{winner.team.city}
</p>
</div>
</div>
</div>
);
})}
</div>
{/* Top 3 Winners - Tablet/Desktop Podium View */}
<div className="hidden md:flex gap-8 items-end justify-center w-full">
{[2, 1, 3].map((position) => {
const winner = sortedWinners[position - 1];
if(!winner) return null;
return (
<div
key={winner.id}
className="w-full max-w-xs flex flex-col items-center space-y-4"
>
<div className="text-5xl">{getMedalEmoji(winner.rank)}</div>
{/* Team Logo */}
{winner.team.logo && (
<img
src={winner.team.logo}
alt={`${winner.team.name} logo`}
className="w-24 h-24 rounded-full object-cover border-4 border-white dark:border-gray-700 shadow-lg"
/>
)}
<div className="text-center px-2">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{winner.team.name}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{winner.team.city}
</p>
</div>
{/* Podium */}
<div
className={`${
winner.rank === 1
? 'h-42 bg-yellow-500'
: winner.rank === 2
? 'h-32 bg-gray-400'
: 'h-16 bg-amber-600'
} w-full flex items-end justify-center rounded-t-lg shadow-lg`}
>
<div className="text-white font-bold text-3xl pb-4">
#{winner.rank}
</div>
</div>
</div>
);
})}
</div>
{/* Ranks 4-23: Prize Winners */}
{sortedWinners.filter((w) => w.rank >= 4 && w.rank <= 23).length >
0 && (
<div className="mt-12">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">
Favorite
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sortedWinners
.filter((w) => w.rank >= 4 && w.rank <= 23)
.map((winner) => (
<div
key={winner.id}
className="bg-white dark:bg-gray-800 border-2 border-gray-400 dark:border-gray-600 rounded-lg p-4 hover:shadow-lg transition-shadow"
>
<div className="flex items-center gap-4">
<div className="shrink-0">
<div className="w-12 h-12 rounded-full bg-linear-to-br from-gray-400 to-gray-600 flex items-center justify-center text-white font-bold text-lg">
#{winner.rank}
</div>
</div>
{winner.team.logo && (
<img
src={winner.team.logo}
alt={`${winner.team.name} logo`}
className="w-16 h-16 rounded-full object-cover border-2 border-gray-200 dark:border-gray-700"
/>
)}
<div className="flex-1 min-w-0">
<h3 className="font-bold text-gray-900 dark:text-white truncate">
{winner.team.name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{winner.team.city}
</p>
<div className="flex items-center gap-1 mt-1">
<span className="text-xs bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 px-2 py-1 rounded-full">
🎁 Prize Winner
</span>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Rank 24+: Remaining Participants */}
{sortedWinners.filter((w) => w.rank >= 24).length > 0 && (
<div className="mt-12">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">
All Participants
</h2>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{sortedWinners
.filter((w) => w.rank >= 24)
.map((participant) => (
<div
key={participant.id}
className="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<div className="flex items-center gap-4">
<div className="shrink-0 w-10 text-center">
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">
#{participant.rank}
</span>
</div>
{participant.team.logo && (
<img
src={participant.team.logo}
alt={`${participant.team.name} logo`}
className="w-12 h-12 rounded-full object-cover border-2 border-gray-200 dark:border-gray-700"
/>
)}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-white">
{participant.team.name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{participant.team.city}
</p>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
)}
</div>
{/* Footer Info */}
{winners.length > 0 && (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
Congratulations! 🎉
</h3>
<p className="text-sm text-blue-800 dark:text-blue-200">
Thank you to all participants for making this hackathon a success.
Every project and idea contributed to an incredible showcase of
innovation and creativity.
</p>
</div>
</div>
)}
</div>
);
};
export default WinnerPage;
+86
View File
@@ -0,0 +1,86 @@
const enc = new TextEncoder();
const dec = new TextDecoder();
function randBytes(len: number): Uint8Array {
const b = new Uint8Array(len);
crypto.getRandomValues(b);
return b;
}
function bufToBase64(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let s = '';
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s);
}
function base64ToBuf(b64: string): ArrayBuffer {
const s = atob(b64);
const arr = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) arr[i] = s.charCodeAt(i);
return arr.buffer;
}
async function deriveKeyFromPassphrase(passphrase: string, salt: Uint8Array, iterations = 100_000) {
const passKey = await crypto.subtle.importKey(
'raw',
enc.encode(passphrase),
{ name: 'PBKDF2' },
false,
['deriveKey']
);
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations,
hash: 'SHA-256'
},
passKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
/**
* Encrypts plaintext with passphrase -> returns base64(salt||iv||ciphertext)
*/
export async function encryptText(plaintext: string, passphrase: string) {
const salt = randBytes(16); // 128-bit salt
const iv = randBytes(12); // 96-bit IV recommended for GCM
const key = await deriveKeyFromPassphrase(passphrase, salt);
const cipher = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
enc.encode(plaintext)
);
// concat salt + iv + ciphertext
const out = new Uint8Array(salt.length + iv.length + cipher.byteLength);
out.set(salt, 0);
out.set(iv, salt.length);
out.set(new Uint8Array(cipher), salt.length + iv.length);
return bufToBase64(out.buffer);
}
/**
* Decrypts base64(salt||iv||ciphertext) with passphrase -> plaintext
*/
export async function decryptText(b64combined: string, passphrase: string) {
const combined = new Uint8Array(base64ToBuf(b64combined));
const salt = combined.slice(0, 16);
const iv = combined.slice(16, 28);
const cipher = combined.slice(28);
const key = await deriveKeyFromPassphrase(passphrase, salt);
const plainBuf = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
cipher
);
return dec.decode(plainBuf);
}
+41
View File
@@ -0,0 +1,41 @@
import { decryptText, encryptText } from "./aesclient";
const SECRET_KEY = 'imphnen-hackathon-2025';
/**
* Encode teamId and submissionId into a certificate ID
* Uses base64 encoding for simple obfuscation
* @param teamId - The team ID
* @param submissionId - The submission ID
* @returns Encoded certificate ID
*/
export const encodeCertificateId = async (teamId: string, submissionId: string): Promise<string> => {
const combined = `${teamId}::${submissionId}`;
return encryptText(combined, SECRET_KEY);
};
/**
* Decode certificate ID back to teamId and submissionId
* @param certId - The encoded certificate ID
* @returns Object containing teamId and submissionId
*/
export const decodeCertificateId = async (certId: string): Promise<{ teamId: string; submissionId: string }> => {
try {
const decoded = await decryptText(certId, SECRET_KEY);
const [teamId, submissionId] = decoded.split('::');
return { teamId, submissionId };
} catch {
throw new Error('Invalid certificate ID');
}
};
/**
* For development: Create a certId using created_at timestamp
* @param teamId - The team ID
* @param createdAt - The creation timestamp
* @returns Encoded certificate ID
*/
export const encodeCertificateIdWithTimestamp = (teamId: string, createdAt: string): string => {
const combined = `${teamId}::${createdAt}`;
return Buffer.from(combined).toString('base64');
};
@@ -6,6 +6,7 @@ import { useRef } from 'react';
import {
FaArrowRight,
FaDiscord,
FaGithub,
FaFacebook,
FaInstagram,
FaLinkedin,
@@ -45,6 +46,8 @@ export function CommunitySection() {
return FaFacebook;
case 'FaDiscord':
return FaDiscord;
case 'FaGithub':
return FaDiscord;
case 'FaInstagram':
return FaInstagram;
case 'FaTiktok':
@@ -66,6 +69,10 @@ export function CommunitySection() {
return {
iconColor: 'text-[#5865F2]',
};
case 'FaGithub':
return {
iconColor: 'text-[#000000]',
};
case 'FaInstagram':
return {
iconColor: 'text-[#E4405F]',
@@ -78,7 +78,7 @@ export function CTASection() {
<div className="flex flex-col items-center gap-6 text-white">
<LogoSimple className="w-[240px]" />
<div className="text-center space-y-2">
<h3 className="text-2xl font-bold">180.000+</h3>
<h3 className="text-2xl font-bold">250.000+</h3>
<p className="text-lg">Programmer Sudah Bergabung</p>
</div>
</div>
@@ -5,6 +5,7 @@ import Link from 'next/link';
import {
FaDiscord,
FaFacebook,
FaGithub,
FaInstagram,
FaLinkedinIn,
FaTiktok,
@@ -37,6 +38,13 @@ export default function Footer() {
>
<FaDiscord className="size-6" />
</Link>
<Link
href="https://github.com/IMPHNEN/"
className="text-muted-foreground hover:text-foreground"
target="_blank"
>
<FaGithub className="size-6" />
</Link>
<Link
href="https://www.instagram.com/imphnen.dev"
className="text-muted-foreground hover:text-foreground"
@@ -92,7 +100,7 @@ export default function Footer() {
</ul>
</div>
<div className="space-y-4">
<h3 className="text-lg font-bold">Patners</h3>
<h3 className="text-lg font-bold">Partners</h3>
<ul className="space-y-2"></ul>
</div>
</div>
+1 -1
View File
@@ -1,5 +1,5 @@
[
{ "value": "180K+", "label": "Member" },
{ "value": "250K+", "label": "Member" },
{ "value": "500+", "label": "Meme Harian" },
{ "value": "24/7", "label": "Yapping" }
]
+7
View File
@@ -13,6 +13,13 @@
"description": "Real-time collaboration chat",
"link": "https://discord.gg/imphnen"
},
{
"name": "Github",
"icon": "FaGithub",
"color": "#5865F2",
"description": "Platforms to collaborate on your code",
"link": "https://github.com/IMPHNEN/"
},
{
"name": "Instagram",
"icon": "FaInstagram",
+4607
View File
File diff suppressed because it is too large Load Diff
@@ -391,364 +391,7 @@ DELETE /api/v1/admin/users/550e8400-e29b-41d4-a716-446655440000
---
## GET /api/v1/admin/teams
### Purpose
Retrieve paginated list of hackathon teams with filtering, searching, and sorting capabilities for backoffice team 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 team name, city, or leader name (case-insensitive) |
| `visibility` | string | No | `all` | Filter by visibility: `all`, `public`, `private` |
| `city` | string | No | `all` | Filter by city or `all` |
| `submission` | string | No | `all` | Filter by submission status: `all`, `submitted`, `not_submitted` |
| `member_count` | string | No | `all` | Filter by member count: `all`, `1`, `2`, `3`, `4`, `5` |
| `sort_by` | string | No | `created_at` | Sort field: `name`, `city`, `visibility`, `member_count`, `has_submission`, `created_at` |
| `sort_order` | string | No | `desc` | Sort order: `asc`, `desc` |
### Examples
```
GET /api/v1/admin/teams
GET /api/v1/admin/teams?page=2&limit=10
GET /api/v1/admin/teams?search=innovators&visibility=public
GET /api/v1/admin/teams?city=Jakarta&submission=submitted&member_count=3
GET /api/v1/admin/teams?sort_by=name&sort_order=asc
```
### Response Schema
```jsonc
{
"data": {
"teams": [
{
"id": "team-001",
"name": "Team Innovators",
"description": "Building innovative solutions for modern problems", // Optional
"city": "Jakarta",
"banner": "https://example.com/banners/team1.jpg", // Optional
"logo": "https://example.com/logos/team1.jpg", // Optional
"visibility": "public", // "public" | "private"
"member_count": 3,
"has_submission": true,
"created_at": "2024-11-15T08:30:00Z",
"updated_at": "2024-11-30T14:22:00Z",
"leader_id": "leader-team-001",
"members": [
{
"id": "member-team-001-0",
"joined_at": "2024-11-15T08:30:00Z",
"role": "leader", // "leader" | "member"
"status": "accepted", // "pending" | "accepted" | "rejected"
"team_id": "team-001",
"user_id": "leader-team-001",
"user": {
"id": "leader-team-001",
"avatar": "https://ui-avatars.com/api/?name=John+Doe", // Optional
"bio": "Passionate developer with 5+ years experience", // Optional
"created_at": "2024-10-01T08:30:00Z",
"email": "john.doe@example.com",
"fullname": "John Doe",
"is_active": true,
"location": "Jakarta",
"phone_number": "+6281234567890", // Optional
"skills": ["Frontend Developer", "UI/UX Designer"],
"updated_at": "2024-11-30T14:22:00Z"
}
}
// ... more members
]
}
// ... more teams
],
"pagination": {
"current_page": 1,
"total_pages": 15,
"total_items": 147,
"items_per_page": 10,
"has_next": true,
"has_prev": false
},
"filters": {
"available_cities": ["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"]
}
}
}
```
---
## GET /api/v1/admin/teams/{team_id}
### Purpose
Retrieve detailed information for a specific team by ID.
### Authentication & Authorization
- Requires admin (backoffice) scope: e.g. `role=admin`
### Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ----------- |
| `team_id` | string | Yes | Team ID |
### Examples
```
GET /api/v1/admin/teams/team-001
```
### Response Schema
```jsonc
{
"data": {
"id": "team-001",
"name": "Team Innovators",
"description": "Building innovative solutions for modern problems",
"city": "Jakarta",
"banner": "https://example.com/banners/team1.jpg",
"logo": "https://example.com/logos/team1.jpg",
"visibility": "public",
"member_count": 3,
"has_submission": true,
"created_at": "2024-11-15T08:30:00Z",
"updated_at": "2024-11-30T14:22:00Z",
"leader_id": "leader-team-001",
"members": [
{
"id": "member-team-001-0",
"joined_at": "2024-11-15T08:30:00Z",
"role": "leader",
"status": "accepted",
"team_id": "team-001",
"user_id": "leader-team-001",
"user": {
"id": "leader-team-001",
"avatar": "https://ui-avatars.com/api/?name=John+Doe",
"bio": "Passionate developer with 5+ years experience",
"created_at": "2024-10-01T08:30:00Z",
"email": "john.doe@example.com",
"fullname": "John Doe",
"is_active": true,
"location": "Jakarta",
"phone_number": "+6281234567890",
"skills": ["Frontend Developer", "UI/UX Designer"],
"updated_at": "2024-11-30T14:22:00Z"
}
}
// ... all team members
]
}
}
```
---
## POST /api/v1/admin/teams
### Purpose
Create a new team in the hackathon system.
### Authentication & Authorization
- Requires admin (backoffice) scope: e.g. `role=admin`
### Request Body Schema
```jsonc
{
"name": "Team New Innovators", // Required, 1-100 chars
"description": "Building next-gen solutions", // Optional, max 500 chars
"city": "Jakarta", // Required, from predefined list
"visibility": "public", // Required, "public" | "private"
"leader_id": "user-123", // Required, existing user ID
"banner": "https://example.com/banners/new.jpg", // Optional, URL
"logo": "https://example.com/logos/new.jpg" // Optional, URL
}
```
### Response Schema
```jsonc
{
"data": {
"id": "team-new-001",
"name": "Team New Innovators",
"description": "Building next-gen solutions",
"city": "Jakarta",
"banner": "https://example.com/banners/new.jpg",
"logo": "https://example.com/logos/new.jpg",
"visibility": "public",
"member_count": 1,
"has_submission": false,
"created_at": "2024-12-02T10:30:00Z",
"updated_at": "2024-12-02T10:30:00Z",
"leader_id": "user-123",
"members": [
{
"id": "member-new-001-0",
"joined_at": "2024-12-02T10:30:00Z",
"role": "leader",
"status": "accepted",
"team_id": "team-new-001",
"user_id": "user-123",
"user": {
"id": "user-123",
"fullname": "John Doe",
"email": "john.doe@example.com",
"location": "Jakarta",
"is_active": true,
"skills": ["Frontend Developer"],
"created_at": "2024-10-01T08:30:00Z",
"updated_at": "2024-12-02T10:30:00Z"
}
}
]
}
}
```
---
## PUT /api/v1/admin/teams/{team_id}
### Purpose
Update an existing team's information.
### Authentication & Authorization
- Requires admin (backoffice) scope: e.g. `role=admin`
### Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ----------- |
| `team_id` | string | Yes | Team ID |
### Request Body Schema
```jsonc
{
"name": "Team Updated Name", // Optional, 1-100 chars
"description": "Updated description", // Optional, max 500 chars, null to clear
"city": "Bandung", // Optional, from predefined list
"visibility": "private", // Optional, "public" | "private"
"banner": "https://example.com/banners/updated.jpg", // Optional, URL, null to remove
"logo": "https://example.com/logos/updated.jpg" // Optional, URL, null to remove
}
```
### Response Schema
Same as GET /api/v1/admin/teams/{team_id} with updated values.
---
## DELETE /api/v1/admin/teams/{team_id}
### Purpose
Delete a team from the hackathon system.
### Authentication & Authorization
- Requires admin (backoffice) scope: e.g. `role=admin`
### Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ----------- |
| `team_id` | string | Yes | Team ID |
### Response Schema
```jsonc
{
"data": {
"message": "Team successfully deleted",
"deleted_team_id": "team-001",
"deleted_at": "2024-12-02T10:50:00Z"
}
}
```
---
## GET /api/v1/admin/teams/{team_id}/submission
### Purpose
Retrieve submission details for a specific team.
### Authentication & Authorization
- Requires admin (backoffice) scope: e.g. `role=admin`
### Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ----------- |
| `team_id` | string | Yes | Team ID |
### Response Schema
```jsonc
{
"data": {
"team_id": "team-001",
"team_name": "Team Innovators",
"project_name": "EcoTrack - Smart Waste Management",
"project_description": "An AI-powered waste management solution that helps cities optimize collection routes and reduce environmental impact.",
"repository_url": "https://github.com/team-innovators/ecotrack",
"demo_url": "https://ecotrack-demo.vercel.app",
"presentation_url": "https://docs.google.com/presentation/d/team-innovators-pitch/edit",
"submitted_at": "2024-12-01T15:30:00Z",
"updated_at": "2024-12-01T16:45:00Z"
}
}
```
---
## Common Error Responses
### Team 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 | `team_not_found` | `Team not found` | Invalid team ID |
| 404 | `submission_not_found` | `Team submission not found` | Team has no submission |
| 409 | `team_already_exists` | `Team with name already exists` | Duplicate team name |
| 413 | `payload_too_large` | `Banner/logo file too large` | Image exceeds size limit |
| 422 | `invalid_city` | `Invalid city specified` | City not in allowed list |
| 422 | `invalid_leader` | `Invalid leader user ID` | Leader user does not exist |
| 429 | `rate_limited` | `Too many requests` | Rate limiting |
| 500 | `internal_error` | `Unexpected server error` | Unhandled exception |
---
**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.
- v3.0.0 (2025-12-02): Added team management endpoints based on backoffice implementation with team members, submissions, and comprehensive filtering.
+54
View File
@@ -0,0 +1,54 @@
import { api } from '../index';
import type {
TAdminUsersResponse,
TAdminTeamsResponse,
TAdminSubmissionsResponse,
} from '../../types/admin';
const ADMIN_BASE_URL = '/admin';
// Admin Users
export const getAdminUsers = async (params?: {
page?: number;
per_page?: number;
search?: string;
is_admin?: boolean;
}) => {
const response = await api.get<TAdminUsersResponse>(
`${ADMIN_BASE_URL}/users`,
{
params: {
...params,
is_admin: params?.is_admin ?? false,
},
}
);
return response.data;
};
// Admin Teams
export const getAdminTeams = async (params?: {
page?: number;
per_page?: number;
search?: string;
}) => {
const response = await api.get<TAdminTeamsResponse>(
`${ADMIN_BASE_URL}/teams`,
{ params }
);
return response.data;
};
// Admin Submissions
export const getAdminSubmissions = async (params?: {
page?: number;
per_page?: number;
search?: string;
status?: string;
}) => {
const response = await api.get<TAdminSubmissionsResponse>(
`${ADMIN_BASE_URL}/submissions`,
{ params }
);
return response.data;
};
+63
View File
@@ -0,0 +1,63 @@
import axios from 'axios';
import { useAuthStore } from '../hooks/auth';
// Backoffice Backend API Base URL
// In development, use proxy; in production, use full URL
const BACKOFFICE_API_URL = 'https://api.hackathon.imphnen.dev/api/v1';
// Create axios instance for backoffice backend
export const backofficeApi = axios.create({
baseURL: BACKOFFICE_API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Add auth token interceptor
backofficeApi.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
backofficeApi.interceptors.response.use(
(response) => response,
(error) => {
// Handle 401 - clear session and redirect to login
if (error.response?.status === 401) {
const isAuthPage =
globalThis.window !== undefined &&
globalThis.location.pathname.startsWith('/auth');
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));
}
// Fallback error message
return Promise.reject(new Error(error.message || 'An error occurred'));
}
);
// Response type
export interface BackofficeApiResponse<T> {
data: T;
message: string;
}
+10 -3
View File
@@ -6,6 +6,7 @@ export * from './users';
export * from './mentors';
export * from './upload';
export * from './hackathon';
export * from './admin';
// Common API response wrapper interface
export interface ApiResponse<T> {
@@ -20,7 +21,9 @@ const getSessionTokenFromCookies = () => {
if (typeof document === 'undefined') return null;
const cookies = document.cookie.split(';');
const tokenCookie = cookies.find(cookie => cookie.trim().startsWith(`${TOKEN_KEY}=`));
const tokenCookie = cookies.find((cookie) =>
cookie.trim().startsWith(`${TOKEN_KEY}=`)
);
if (!tokenCookie) return null;
@@ -32,13 +35,17 @@ const getSessionTokenFromCookies = () => {
}
};
const setSessionTokenToCookies = (tokenData: { token: { access_token: string; refresh_token: string } }) => {
const setSessionTokenToCookies = (tokenData: {
token: { access_token: string; refresh_token: string };
}) => {
if (typeof document === 'undefined') return;
const expires = new Date();
expires.setDate(expires.getDate() + 7);
document.cookie = `${TOKEN_KEY}=${encodeURIComponent(JSON.stringify(tokenData))}; expires=${expires.toUTCString()}; path=/; secure; samesite=strict`;
document.cookie = `${TOKEN_KEY}=${encodeURIComponent(
JSON.stringify(tokenData)
)}; expires=${expires.toUTCString()}; path=/; secure; samesite=strict`;
};
const removeSessionTokenFromCookies = () => {
+77 -34
View File
@@ -1,5 +1,6 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
import { backofficeApi, BackofficeApiResponse } from '../../api/backoffice';
import { useAuthStore } from './use-auth-store';
export * from './use-auth-store';
@@ -77,10 +78,9 @@ export const useLogin = () => {
return useMutation({
mutationFn: async (data: LoginRequest) => {
const response = await hackathonApi.post<HackathonApiResponse<AuthResponse>>(
'/auth/login',
data
);
const response = await hackathonApi.post<
HackathonApiResponse<AuthResponse>
>('/auth/login', data);
return response.data.data;
},
onSuccess: (data) => {
@@ -115,10 +115,9 @@ export const useLogin = () => {
export const useSignup = () => {
return useMutation({
mutationFn: async (data: SignupRequest) => {
const response = await hackathonApi.post<HackathonApiResponse<MessageResponse>>(
'/auth/signup',
data
);
const response = await hackathonApi.post<
HackathonApiResponse<MessageResponse>
>('/auth/signup', data);
return response.data.data;
},
});
@@ -130,10 +129,9 @@ export const useGitHubCallback = () => {
return useMutation({
mutationFn: async (data: GitHubAuthRequest) => {
const response = await hackathonApi.post<HackathonApiResponse<AuthResponse>>(
'/auth/github',
data
);
const response = await hackathonApi.post<
HackathonApiResponse<AuthResponse>
>('/auth/github', data);
return response.data.data;
},
onSuccess: (data) => {
@@ -171,9 +169,9 @@ export const useSession = () => {
return useQuery({
queryKey: ['auth-session'],
queryFn: async () => {
const response = await hackathonApi.get<HackathonApiResponse<SessionResponse>>(
'/auth/session'
);
const response = await hackathonApi.get<
HackathonApiResponse<SessionResponse>
>('/auth/session');
return response.data.data;
},
enabled: !!session?.token,
@@ -184,10 +182,9 @@ export const useSession = () => {
export const useForgotPassword = () => {
return useMutation({
mutationFn: async (data: ForgotPasswordRequest) => {
const response = await hackathonApi.post<HackathonApiResponse<MessageResponse>>(
'/auth/forgot-password',
data
);
const response = await hackathonApi.post<
HackathonApiResponse<MessageResponse>
>('/auth/forgot-password', data);
return response.data.data;
},
});
@@ -197,10 +194,9 @@ export const useForgotPassword = () => {
export const useResetPassword = () => {
return useMutation({
mutationFn: async (data: ResetPasswordRequest) => {
const response = await hackathonApi.post<HackathonApiResponse<MessageResponse>>(
'/auth/reset-password',
data
);
const response = await hackathonApi.post<
HackathonApiResponse<MessageResponse>
>('/auth/reset-password', data);
return response.data.data;
},
});
@@ -219,6 +215,45 @@ export const useSignOut = () => {
});
};
// Backoffice Login
export const useBackofficeLogin = () => {
const { setSession } = useAuthStore();
return useMutation({
mutationFn: async (data: LoginRequest) => {
const response = await backofficeApi.post<
BackofficeApiResponse<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: data.user.role_id || '',
name: 'admin',
permissions: [],
created_at: '',
updated_at: '',
},
},
});
},
});
};
// GitHub OAuth URL helper
// The frontend needs to redirect to GitHub with the client_id
// After GitHub redirects back with a code, use useGitHubCallback
@@ -239,7 +274,9 @@ export const useGitHubAuth = () => {
// 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.');
throw new Error(
'GitHub Client ID not configured. Set VITE_GITHUB_CLIENT_ID environment variable.'
);
}
const redirectUri = `${globalThis.location.origin}/auth/callback`;
@@ -270,8 +307,16 @@ export const useEmailAuth = () => {
};
};
const signUpWithEmail = async (email: string, password: string, fullname: string) => {
const result = await signupMutation.mutateAsync({ email, password, fullname });
const signUpWithEmail = async (
email: string,
password: string,
fullname: string
) => {
const result = await signupMutation.mutateAsync({
email,
password,
fullname,
});
// Signup only returns a message (user needs to verify email first)
return {
message: result.message,
@@ -295,10 +340,9 @@ export const useEmailAuth = () => {
export const usePostLogin = () => {
return useMutation({
mutationFn: async (data: LoginRequest) => {
const response = await hackathonApi.post<HackathonApiResponse<AuthResponse>>(
'/auth/login',
data
);
const response = await hackathonApi.post<
HackathonApiResponse<AuthResponse>
>('/auth/login', data);
return { data: response.data.data };
},
});
@@ -308,10 +352,9 @@ export const usePostLogin = () => {
export const usePostRegister = () => {
return useMutation({
mutationFn: async (data: SignupRequest) => {
const response = await hackathonApi.post<HackathonApiResponse<AuthResponse>>(
'/auth/signup',
data
);
const response = await hackathonApi.post<
HackathonApiResponse<AuthResponse>
>('/auth/signup', data);
return { data: response.data.data };
},
});
+1
View File
@@ -5,3 +5,4 @@ export * from './mentors';
export * from './upload';
export * from './teams';
export * from './messages';
export * from './winners';
+43
View File
@@ -0,0 +1,43 @@
import { useQuery } from '@tanstack/react-query';
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
// Query keys
export const winnerKeys = {
all: ['winners'] as const,
lists: () => [...winnerKeys.all, 'list'] as const,
};
// API response types
interface Team {
id: string;
name: string;
description: string;
city: string;
visibility: string;
logo: string;
banner: string;
leader_id: string;
created_at: string;
updated_at: string;
}
interface Winner {
id: string;
team_id: string;
team: Team;
rank: number;
prize: string;
announced_at: string;
created_at: string;
updated_at: string;
}
export const useWinners = () => {
return useQuery<HackathonApiResponse<Winner[]>>({
queryKey: winnerKeys.lists(),
queryFn: async () => {
const response = await hackathonApi.get('/winners');
return response.data;
},
});
};
+64
View File
@@ -0,0 +1,64 @@
export type TAdminMetaResponse = {
page: number;
per_page: number;
total_data: number;
total_page: number;
};
export type TAdminListResponse<T = unknown> = {
data: T[];
meta: TAdminMetaResponse;
};
// Admin Users
export type TAdminUserItem = {
id: string;
email: string;
fullname: string;
avatar: string | null;
phone_number: string | null;
location: string | null;
bio: string;
skills: string[];
is_active: boolean;
created_at: string;
updated_at: string;
};
export type TAdminUsersResponse = TAdminListResponse<TAdminUserItem>;
// Admin Teams
export type TAdminTeamItem = {
id: string;
name: string;
description: string;
city: string;
visibility: string;
logo: string | null;
banner: string | null;
leader_id: string;
created_at: string;
updated_at: string;
};
export type TAdminTeamsResponse = TAdminListResponse<TAdminTeamItem>;
// Admin Submissions
export type TAdminSubmissionItem = {
id: string;
team_id: string;
project_name: string;
description: string;
repository_url: string;
demo_url: string | null;
presentation_url: string | null;
screenshots: string[];
status: string;
submitted_at: string;
submitted_by: string;
created_at: string;
updated_at: string;
};
export type TAdminSubmissionsResponse =
TAdminListResponse<TAdminSubmissionItem>;
+1
View File
@@ -5,3 +5,4 @@ export * from './roles';
export * from './permissions';
export * from './mentors';
export * from './teams';
export * from './admin';
+132 -2
View File
@@ -23,6 +23,11 @@ interface DataTableProps<T extends RowData> {
columns?: ColumnDef<T, unknown>[];
pageSize?: number;
className?: string;
// server-side pagination props
manualPagination?: boolean;
pageCount?: number;
currentPage?: number;
onPageChange?: (page: number) => void;
}
export const DataTable = <T extends RowData>({
@@ -31,6 +36,10 @@ export const DataTable = <T extends RowData>({
columns = [],
pageSize = 9,
className,
manualPagination = false,
pageCount,
currentPage = 1,
onPageChange,
}: DataTableProps<T>) => {
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
@@ -75,10 +84,20 @@ export const DataTable = <T extends RowData>({
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
// server-side pagination config
manualPagination,
pageCount: manualPagination ? pageCount : undefined,
};
return config;
}, [memoizedData, memoizedColumns, pagination, sorting]);
}, [
memoizedData,
memoizedColumns,
pagination,
sorting,
manualPagination,
pageCount,
]);
// Prefer external table instance if provided; otherwise create an internal one
const internalTable = useReactTable(tableConfig);
@@ -166,7 +185,118 @@ export const DataTable = <T extends RowData>({
</tbody>
</table>
</div>
<Pagination table={t} />
{manualPagination && onPageChange && pageCount ? (
// Server-side pagination controls with numbered pages
<div className="flex items-center justify-center gap-10">
<button
className="disabled:opacity-50 cursor-pointer"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Previous page"
>
<svg
className="w-4 h-4 text-neutral-800"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<div className="flex gap-4 items-baseline">
{pageCount <= 8 ? (
// Show all pages if 8 or fewer
Array.from({ length: pageCount }, (_, index) => (
<button
key={index}
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
currentPage === index + 1
? 'bg-primary-500 text-white'
: 'bg-primary-100 hover:bg-primary-200'
}`}
onClick={() => onPageChange(index + 1)}
>
{index + 1}
</button>
))
) : (
// Show ellipsis for many pages
<>
<button
onClick={() => onPageChange(1)}
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
currentPage === 1
? 'bg-primary-500 text-white'
: 'bg-primary-100 hover:bg-primary-200'
}`}
>
1
</button>
{currentPage > 3 && <span>...</span>}
{Array.from(
{ length: 5 },
(_, index) => currentPage - 2 + index
)
.filter((page) => page > 1 && page < pageCount)
.map((page) => (
<button
key={page}
onClick={() => onPageChange(page)}
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
currentPage === page
? 'bg-primary-500 text-white'
: 'bg-primary-100 hover:bg-primary-200'
}`}
>
{page}
</button>
))}
{currentPage < pageCount - 2 && <span>...</span>}
<button
onClick={() => onPageChange(pageCount)}
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
currentPage === pageCount
? 'bg-primary-500 text-white'
: 'bg-primary-100 hover:bg-primary-200'
}`}
>
{pageCount}
</button>
</>
)}
</div>
<button
className="disabled:opacity-50 cursor-pointer"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === pageCount}
aria-label="Next page"
>
<svg
className="w-4 h-4 text-neutral-800"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</div>
) : (
// Client-side pagination (default)
<Pagination table={t} />
)}
</div>
);
};
+3
View File
@@ -49,11 +49,13 @@
"dayjs": "^1.11.13",
"framer-motion": "^12.9.2",
"graphql": "^16.11.0",
"html2canvas": "^1.4.1",
"js-cookie": "^3.0.5",
"next": "~16.0.3",
"next-themes": "^0.4.6",
"openapi-fetch": "^0.15.0",
"openapi-react-query": "^0.5.0",
"qrcode": "^1.5.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.4",
@@ -96,6 +98,7 @@
"@testing-library/user-event": "^14.6.1",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.12.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.2.0",