Compare commits

..
Author SHA1 Message Date
xirf 38ec9a28f7 Refactor code structure for improved readability and maintainability 2025-12-09 00:46:34 +07:00
xirf 97a3ac90eb feat: blank cert 2025-12-07 22:47:22 +07:00
xirf 8f8c720632 feat: implement certificate encoding and decoding functionality with AES encryption 2025-12-07 22:38:01 +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
20 changed files with 6097 additions and 2843 deletions
@@ -1,822 +0,0 @@
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 {
TeamOutlined,
CalendarOutlined,
SaveOutlined,
CloseOutlined,
ExclamationOutlined,
UserOutlined,
DeleteOutlined,
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[];
}
interface ModalProps {
isOpen: boolean;
onClose: () => void;
team: TeamType | null;
}
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);
// Initialize form data when modal opens
useEffect(() => {
if (isOpen) {
if (team) {
// Edit existing team
setFormData({ ...team });
} else {
// Create new team
setFormData({
id: '', // Will be generated by backend
name: '',
description: '',
city: '',
banner: undefined,
logo: undefined,
visibility: 'public',
member_count: 1,
has_submission: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
leader_id: '',
members: [],
});
}
}
}, [isOpen, team]);
// Check if form has changes
const hasChanges = useMemo(() => {
if (!formData) return false;
if (!team) return true; // New team always has changes
return (
formData.name !== team.name ||
formData.description !== team.description ||
formData.city !== team.city ||
formData.visibility !== team.visibility ||
formData.logo !== team.logo ||
formData.banner !== team.banner
);
}, [formData, team]);
// Check if required fields are filled
const isFormValid = useMemo(() => {
if (!formData) return false;
return formData.name.trim() !== '' && formData.city.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
) => {
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();
};
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);
}
};
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);
}
};
const handleRemoveLogo = () => {
handleInputChange('logo', undefined);
setShowLogoMenu(false);
};
const handleRemoveBanner = () => {
handleInputChange('banner', undefined);
};
const triggerLogoUpload = () => {
logoInputRef.current?.click();
};
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-4xl 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-primary-100 flex items-center justify-center">
<TeamOutlined className="text-primary-600 text-lg" />
</div>
<div>
<h2 className="text-xl font-semibold text-neutral-900">
{team ? 'Team Details' : 'Create New Team'}
</h2>
<p className="text-sm text-neutral-500">
{team
? 'View and manage team information'
: 'Add a new team to the hackathon'}
</p>
</div>
</div>
<button
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors cursor-pointer"
onClick={() => {
setShowLogoMenu(false);
onClose();
}}
>
<CloseOutlined className="text-neutral-400 text-lg" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6" onClick={() => setShowLogoMenu(false)}>
{/* Hidden File Inputs */}
<input
type="file"
ref={logoInputRef}
onChange={handleLogoUpload}
accept="image/*"
className="hidden"
/>
<input
type="file"
ref={bannerInputRef}
onChange={handleBannerUpload}
accept="image/*"
className="hidden"
/>
{/* Interactive 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">
(3:1 aspect ratio recommended)
</span>
</label>
<div className="relative group">
<TeamBannerPlaceholder
banner={formData.banner}
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"
size="sm"
onClick={(e) => {
e.stopPropagation();
bannerInputRef.current?.click();
}}
className="bg-white/90 hover:bg-white text-neutral-700 border-transparent shadow-sm gap-2"
>
<UploadOutlined className="text-sm" />
{formData.banner ? 'Change Banner' : 'Add Banner'}
</Button>
{formData.banner && (
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleRemoveBanner();
}}
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
</Button>
)}
</div>
</div>
</div>
{/* Team Logo & Name Row */}
<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
</label>
<div className="relative group">
<div className="w-24 h-24 rounded-full bg-neutral-100 flex items-center justify-center overflow-hidden border border-neutral-200 group-hover:border-primary-300 transition-colors">
{formData.logo ? (
<img
src={formData.logo}
alt={formData.name || 'Team Logo'}
className="w-full h-full object-cover"
/>
) : (
<TeamOutlined className="text-neutral-400 text-xl" />
)}
</div>
{/* Logo Hover Overlay - Full circle */}
<button
onClick={(e) => {
e.stopPropagation();
setShowLogoMenu(!showLogoMenu);
}}
className="absolute inset-0 bg-neutral-300/80 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center w-24 h-24"
>
<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();
}}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-2 cursor-pointer"
>
<UploadOutlined className="text-sm" />
{formData.logo ? 'Change Logo' : 'Upload Logo'}
</button>
{formData.logo && (
<button
onClick={(e) => {
e.stopPropagation();
handleRemoveLogo();
}}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer"
>
<DeleteOutlined className="text-sm" />
Remove Logo
</button>
)}
</div>
)}
</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>
</label>
<input
type="text"
className="w-full border border-neutral-200 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
placeholder="Enter team name"
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 */}
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Description
</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)"
rows={3}
value={formData.description || ''}
onChange={(e) => handleInputChange('description', e.target.value)}
/>
</div>
{/* City */}
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
City <span className="text-danger-500">*</span>
</label>
<CityFilterSelect
value={formData.city || 'all'}
onChange={(city) =>
handleInputChange('city', city === 'all' ? '' : city)
}
className="w-full"
placeholder="Search cities..."
allOptionLabel="Select a city"
filterIcon={false}
/>
</div>
{/* Visibility */}
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Team Visibility
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="visibility"
value="public"
checked={formData.visibility === 'public'}
onChange={(e) =>
handleInputChange('visibility', e.target.value as 'public')
}
className="text-primary-600"
/>
<EyeOutlined className="text-info-600" />
<span className="text-sm">Public</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="visibility"
value="private"
checked={formData.visibility === 'private'}
onChange={(e) =>
handleInputChange('visibility', e.target.value as 'private')
}
className="text-primary-600"
/>
<EyeInvisibleOutlined className="text-neutral-600" />
<span className="text-sm">Private</span>
</label>
</div>
</div>
{/* Team Information (Read-only for existing teams) */}
{team && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Members
</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' : ''}
</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>
</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>
)}
{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>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-neutral-200">
<div>
{team && (
<Button
variant="danger"
size="md"
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-2"
>
<DeleteOutlined />
Delete Team
</Button>
)}
</div>
<div className="flex items-center gap-3">
<Button variant="secondary" size="md" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
size="md"
onClick={handleSave}
disabled={!canSave}
className="flex items-center gap-2"
>
<SaveOutlined />
{team ? 'Save Changes' : 'Create Team'}
</Button>
</div>
</div>
</div>
{/* 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>
<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>
</div>
)}
</div>
);
};
export default ModalTeamDetail;
@@ -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,82 +0,0 @@
import { FC } from 'react';
import { TeamOutlined } from '@ant-design/icons';
import { cn } from '@imphnen-frontend-service/utils';
interface TeamBannerPlaceholderProps {
banner?: string;
teamName: string;
className?: string;
showPlaceholder?: boolean;
}
const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
banner,
teamName,
className = '',
showPlaceholder = true,
}) => {
const aspectRatioClass = 'aspect-[3/1]'; // 3:1 aspect ratio
if (!banner && !showPlaceholder) {
return null;
}
if (banner) {
return (
<div
className={cn(
'w-full bg-gray-100 overflow-hidden relative',
aspectRatioClass,
className
)}
>
<img
src={banner}
alt={`${teamName} banner`}
className="w-full h-full object-cover"
onError={(e) => {
// Fallback to placeholder if image fails to load
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const placeholder = target.nextElementSibling as HTMLElement;
if (placeholder) {
placeholder.style.display = 'flex';
}
}}
/>
{/* Fallback placeholder (hidden by default, shown on image error) */}
<div
className={cn(
'absolute inset-0 bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
'hidden' // Hidden by default
)}
>
<div className="text-center">
<TeamOutlined className="text-4xl text-gray-400 mb-2" />
<p className="text-sm text-gray-500 font-medium">{teamName}</p>
<p className="text-xs text-gray-400">Team Banner</p>
</div>
</div>
</div>
);
}
// No banner - show placeholder
return (
<div
className={cn(
'w-full bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
aspectRatioClass,
className
)}
>
<div className="text-center">
<TeamOutlined className="text-4xl text-gray-400 mb-2" />
<p className="text-sm text-gray-500 font-medium">{teamName}</p>
<p className="text-xs text-gray-400">No Banner</p>
</div>
</div>
);
};
export default TeamBannerPlaceholder;
@@ -1,476 +1,179 @@
import { FC, ReactElement, useState, 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 { FC, ReactElement, useState } from 'react';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import {
EditOutlined,
TeamOutlined,
SearchOutlined,
FilterOutlined,
PlusOutlined,
UserOutlined,
ArrowRightOutlined,
} from '@ant-design/icons';
// 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,
};
});
import { useTeams } from '@imphnen-frontend-service/service';
import { EditOutlined } from '@ant-design/icons';
export const HackathonTeamsPage: FC = (): ReactElement => {
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('');
const { data: teamsData } = useTeams();
// Advanced filtering states
const [visibilityFilter, setVisibilityFilter] = useState('all');
const [cityFilter, setCityFilter] = useState('all');
const [submissionFilter, setSubmissionFilter] = useState('all');
const [memberCountFilter, setMemberCountFilter] = useState('all');
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
// Constants
const pageSize = 10;
// Memoize the callback to prevent recreation
const handleShowDetailModal = useCallback((team: TeamType) => {
setSelectedTeam(team);
setShowDetailModal(true);
}, []);
const handleCloseDetailModal = useCallback(() => {
setShowDetailModal(false);
setSelectedTeam(null);
}, []);
const handleShowNewTeamModal = useCallback(() => {
setShowNewTeamModal(true);
}, []);
const handleCloseNewTeamModal = useCallback(() => {
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
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,
]);
// Memoize columns to prevent recreation on every render
const columns: ColumnDef<TeamType>[] = useMemo(
() => [
{
accessorKey: 'name',
header: 'Team',
cell: ({ row }) => {
const team = row.original;
return (
<div className="flex items-center gap-3">
{/* Team Logo */}
<div className="w-10 h-10 rounded-full bg-neutral-100 flex items-center justify-center shrink-0 overflow-hidden">
{team.logo ? (
<img
src={team.logo}
alt={team.name}
className="w-full h-full object-cover"
/>
) : (
<TeamOutlined className="text-neutral-400 text-lg" />
)}
</div>
{/* Team Name & Description */}
<div className="min-w-0 flex-1">
<p className="font-medium text-neutral-900 truncate">
{team.name}
</p>
</div>
</div>
);
},
enableSorting: true,
const mockData: TeamType[] = Array.from({ length: 90 }, (_, i) => ({
id: `team-${i + 1}`,
name: `Team ${i + 1} - ${i % 3 === 0 ? 'Innovators' : 'Hackers'}`,
city: i % 2 === 0 ? 'Jakarta' : 'Bandung',
visibility: i % 4 === 0 ? 'private' : 'public',
member_count: Math.floor(Math.random() * 4) + 1,
has_submission: i % 3 !== 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
leader: {
user: {
fullname: `Leader User ${i}`,
email: `leader${i}@example.com`,
},
{
accessorKey: 'city',
header: 'City',
cell: ({ row }) => (
<span className="text-neutral-700">{row.original.city}</span>
),
enableSorting: true,
},
{
accessorKey: 'visibility',
header: 'Visibility',
cell: ({ row }) => {
const isPublic = row.original.visibility === 'public';
return (
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
isPublic
? 'bg-success-100 text-success-800'
: 'bg-neutral-100 text-neutral-700'
)}
>
{isPublic ? 'Public' : 'Private'}
</span>
);
},
enableSorting: true,
},
{
accessorKey: 'member_count',
header: 'Members',
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>
),
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',
cell: ({ row }) => (
<span className="text-neutral-900 text-sm">
{new Date(row.original.created_at).toLocaleDateString('en-UK', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
},
}));
interface TeamType {
id: string;
name: string;
city: string;
visibility: 'public' | 'private';
member_count: number;
has_submission: boolean;
created_at: string;
updated_at: string;
leader?: {
user: {
fullname: string;
email: string;
};
};
}
const columns: ColumnDef<TeamType>[] = [
{
accessorKey: 'id',
header: 'ID',
},
{
accessorKey: 'name',
header: 'Team Name',
},
{
accessorKey: 'city',
header: 'City',
},
{
accessorKey: 'visibility',
header: 'Visibility',
cell: ({ row }) => {
const isPublic = row.original.visibility === 'public';
return (
<span
className={cn(
'py-2 px-4 text-sm rounded-2xl text-center',
isPublic
? 'bg-info-200 text-info-700'
: 'bg-gray-200 text-gray-700'
)}
>
{isPublic ? 'Public' : 'Private'}
</span>
),
enableSorting: true,
sortingFn: 'datetime',
);
},
{
id: 'actions',
header: 'Actions',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 text-sm px-4 py-2"
onClick={() => handleShowDetailModal(row.original)}
>
<EditOutlined className="text-sm" />
Manage
</Button>
},
{
accessorKey: 'member_count',
header: 'Members',
},
{
id: 'leader',
header: 'Leader',
cell: ({ row }) => {
const leader = row.original.leader?.user;
return leader ? (
<div>
<div className="text-sm font-medium text-gray-900">
{leader.fullname}
</div>
<div className="text-xs text-gray-500">{leader.email}</div>
</div>
),
enableSorting: false,
) : (
<span className="text-gray-400 italic">-</span>
);
},
],
[handleShowDetailModal, handleShowSubmissionModal]
);
},
{
accessorKey: 'has_submission',
header: 'Submitted',
cell: ({ row }) => {
const hasSubmission = row.original.has_submission;
return (
<span
className={cn(
'py-2 px-4 text-sm rounded-2xl text-center',
hasSubmission
? 'bg-success-200 text-success-700'
: 'bg-danger-200 text-danger-700'
)}
>
{hasSubmission ? 'Yes' : 'No'}
</span>
);
},
},
{
accessorKey: 'updated_at',
header: 'Last Updated',
cell: ({ row }) => {
return new Date(row.original.updated_at).toLocaleDateString();
},
},
{
id: 'actions',
header: 'Action',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 w-max"
onClick={() => {
// View detail logic
}}
>
<EditOutlined className="text-base" /> View & Manage
</Button>
</div>
),
},
];
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
@@ -479,204 +182,27 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
</h1>
{/* Filters and actions */}
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
<div className="flex flex-wrap gap-3 items-center justify-between">
{/* Left side - Search & filters */}
<div className="flex flex-wrap gap-3 items-center">
{/* Search bar */}
<div className="relative">
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
<input
type="text"
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
placeholder="Search teams by name, city, or leader..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
/>
</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"
value={visibilityFilter}
onChange={(e) => setVisibilityFilter(e.target.value)}
>
<option value="all">All Visibility</option>
<option value="public">Public</option>
<option value="private">Private</option>
</select>
</div>
{/* City Filter */}
<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 */}
<div className="flex items-center gap-3">
<Button
variant="primary"
size="md"
className="flex items-center gap-2 px-4 py-2"
onClick={handleShowNewTeamModal}
>
<PlusOutlined className="text-sm" />
Add Team
</Button>
</div>
<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>
{/* Active filters display */}
{(visibilityFilter !== 'all' ||
cityFilter !== 'all' ||
submissionFilter !== 'all' ||
memberCountFilter !== 'all') && (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span>
{/* Visibility filter badge */}
{visibilityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
Visibility: {visibilityFilter}
<button
onClick={() => setVisibilityFilter('all')}
className="text-info-600 hover:text-info-800 cursor-pointer"
>
</button>
</span>
)}
{/* City filter badge */}
{cityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
City: {cityFilter}
<button
onClick={() => setCityFilter('all')}
className="text-green-600 hover:text-green-800 cursor-pointer"
>
</button>
</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"
size="sm"
onClick={() => {
setVisibilityFilter('all');
setCityFilter('all');
setSubmissionFilter('all');
setMemberCountFilter('all');
setGlobalFilter('');
}}
className="text-sm text-neutral-600"
>
Clear All
</Button>
</div>
)}
{/* Pagination-aware results display */}
{filteredData.length > 0 && (
<div className="text-sm text-neutral-600">
Showing {Math.min(pageSize, filteredData.length)} of{' '}
{filteredData.length} teams
{filteredData.length > pageSize}
</div>
)}
{/* Table */}
<DataTable data={filteredData} columns={columns} pageSize={10} />
<DataTable data={mockData} columns={columns} table={table} />
</section>
{/* Modals component */}
<ModalTeamDetail
isOpen={showDetailModal}
onClose={handleCloseDetailModal}
team={selectedTeam}
/>
{/* New Team Modal */}
<ModalTeamDetail
isOpen={showNewTeamModal}
onClose={handleCloseNewTeamModal}
team={null} // null indicates creating new team
/>
{/* Submission Modal */}
{selectedSubmissionTeam && (
<SubmissionModal
isOpen={showSubmissionModal}
onClose={handleCloseSubmissionModal}
teamId={selectedSubmissionTeam.id}
teamName={selectedSubmissionTeam.name}
/>
)}
{/* Modals extracted into shared backoffice components */}
</BackofficeWrapper>
);
};
@@ -14,7 +14,7 @@ import {
FilterOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { CityFilterSelect } from '../../../components/city-filter-select';
// Removed unused SearchOutlined icon after schema revision
// Define interface outside component
interface UserType {
@@ -89,7 +89,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
// Advanced filtering states
const [statusFilter, setStatusFilter] = useState('all');
const [cityFilter, setCityFilter] = useState('all');
const [locationFilter, setLocationFilter] = useState('all');
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
// Constants
@@ -123,8 +123,8 @@ export const HackathonUsersPage: FC = (): ReactElement => {
if (user.is_active !== isActive) return false;
}
// City filter
if (cityFilter !== 'all' && user.location !== cityFilter) {
// Location filter
if (locationFilter !== 'all' && user.location !== locationFilter) {
return false;
}
@@ -138,7 +138,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
return true;
});
}, [statusFilter, cityFilter, skillsFilter]);
}, [statusFilter, locationFilter, skillsFilter]);
// Memoize columns to prevent recreation on every render
const columns: ColumnDef<UserType>[] = useMemo(
@@ -315,25 +315,22 @@ export const HackathonUsersPage: FC = (): ReactElement => {
</select>
</div>
{/* City Filter */}
<CityFilterSelect
value={cityFilter}
onChange={setCityFilter}
className="w-full sm:w-44"
placeholder="Search cities..."
allOptionLabel="All Cities"
/>
{cityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
Location: {cityFilter}
<button
onClick={() => setCityFilter('all')}
className="text-green-600 hover:text-green-800 cursor-pointer"
>
</button>
</span>
)}
{/* Location Filter */}
<div className="relative">
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
<select
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={locationFilter}
onChange={(e) => setLocationFilter(e.target.value)}
>
<option value="all">All Locations</option>
{locations.map((location) => (
<option key={location} value={location}>
{location}
</option>
))}
</select>
</div>
{/* Skills Filter with Icon */}
<div className="relative">
@@ -381,7 +378,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
{/* Active filters display */}
{(skillsFilter.length > 0 ||
statusFilter !== 'all' ||
cityFilter !== 'all') && (
locationFilter !== 'all') && (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span>
@@ -399,11 +396,11 @@ export const HackathonUsersPage: FC = (): ReactElement => {
)}
{/* Location filter badge */}
{cityFilter !== 'all' && (
{locationFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
City: {cityFilter}
Location: {locationFilter}
<button
onClick={() => setCityFilter('all')}
onClick={() => setLocationFilter('all')}
className="text-green-600 hover:text-green-800 cursor-pointer"
>
@@ -435,7 +432,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
size="sm"
onClick={() => {
setStatusFilter('all');
setCityFilter('all');
setLocationFilter('all');
setSkillsFilter([]);
setGlobalFilter('');
}}
@@ -1,148 +0,0 @@
import { FC, useState, useRef, useEffect } from 'react';
import { FilterOutlined } from '@ant-design/icons';
import INDONESIAN_CITIES from '../constants/cities';
interface CityFilterSelectProps {
value: string;
onChange: (value: string) => void;
className?: string;
placeholder?: string;
allOptionLabel?: string;
filterIcon?: boolean;
}
export const CityFilterSelect: FC<CityFilterSelectProps> = ({
value,
onChange,
className = '',
placeholder = 'Search cities...',
allOptionLabel = 'All Cities',
filterIcon = true,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Filter cities based on search query
const filteredCities = INDONESIAN_CITIES.filter((city) =>
city.toLowerCase().includes(searchQuery.toLowerCase())
);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setSearchQuery('');
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelectCity = (city: string) => {
onChange(city);
setSearchQuery('');
setIsOpen(false);
};
const handleInputClick = () => {
setIsOpen(true);
setSearchQuery('');
};
const handleClearSelection = () => {
onChange('all');
setSearchQuery('');
setIsOpen(false);
};
const displayValue = value === 'all' ? allOptionLabel : value;
const showClearButton = value !== 'all' && !isOpen;
return (
<div className={`relative ${className}`} ref={dropdownRef}>
<div className="relative">
{filterIcon && (
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
)}
<input
ref={inputRef}
type="text"
value={isOpen ? searchQuery : displayValue}
onChange={(e) => {
setSearchQuery(e.target.value);
if (!isOpen) setIsOpen(true);
}}
onClick={handleInputClick}
onFocus={handleInputClick}
placeholder={isOpen ? placeholder : displayValue}
className={`border border-neutral-200 rounded-lg pr-10 py-2.5 text-sm w-full focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer ${
filterIcon ? ' pl-10' : 'pl-3'
}`}
/>
{showClearButton && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleClearSelection();
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 text-xs cursor-pointer z-20"
>
</button>
)}
</div>
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{/* All Cities Option */}
<div
onClick={() => handleSelectCity('all')}
className={`px-3 py-2 cursor-pointer hover:bg-neutral-50 border-b border-neutral-100 ${
value === 'all'
? 'bg-primary-50 text-primary-700 font-medium'
: 'text-neutral-900'
}`}
>
{allOptionLabel}
</div>
{/* Filtered Cities */}
{filteredCities.length > 0 ? (
<div className="py-1">
{filteredCities.slice(0, 100).map((city) => (
<div
key={city}
onClick={() => handleSelectCity(city)}
className={`px-3 py-2 cursor-pointer hover:bg-neutral-50 text-sm ${
value === city
? 'bg-primary-50 text-primary-700 font-medium'
: 'text-neutral-700'
}`}
>
{city}
</div>
))}
{filteredCities.length > 100 && (
<div className="px-3 py-2 text-xs text-neutral-500 border-t border-neutral-100">
Showing first 100 results. Continue typing to refine...
</div>
)}
</div>
) : searchQuery ? (
<div className="px-3 py-2 text-neutral-500 text-sm">
No cities found matching "{searchQuery}"
</div>
) : null}
</div>
)}
</div>
);
};
-518
View File
@@ -1,518 +0,0 @@
const INDONESIAN_CITIES: string[] = [
'Aceh Selatan',
'Aceh Tenggara',
'Aceh Timur',
'Aceh Tengah',
'Aceh Barat',
'Aceh Besar',
'Pidie',
'Aceh Utara',
'Simeulue',
'Aceh Singkil',
'Bireuen',
'Aceh Barat Daya',
'Gayo Lues',
'Aceh Jaya',
'Nagan Raya',
'Aceh Tamiang',
'Bener Meriah',
'Pidie Jaya',
'Kota Banda Aceh',
'Kota Sabang',
'Kota Lhokseumawe',
'Kota Langsa',
'Kota Subulussalam',
'Tapanuli Tengah',
'Tapanuli Utara',
'Tapanuli Selatan',
'Nias',
'Langkat',
'Karo',
'Deli Serdang',
'Simalungun',
'Asahan',
'Labuhanbatu',
'Dairi',
'Toba',
'Mandailing Natal',
'Nias Selatan',
'Pakpak Bharat',
'Humbang Hasundutan',
'Samosir',
'Serdang Bedagai',
'Batu Bara',
'Padang Lawas Utara',
'Padang Lawas',
'Labuhanbatu Selatan',
'Labuhanbatu Utara',
'Nias Utara',
'Nias Barat',
'Kota Medan',
'Kota Pematangsiantar',
'Kota Sibolga',
'Kota Tanjung Balai',
'Kota Binjai',
'Kota Tebing Tinggi',
'Kota Padangsidimpuan',
'Kota Gunungsitoli',
'Pesisir Selatan',
'Solok',
'Sijunjung',
'Tanah Datar',
'Padang Pariaman',
'Agam',
'Lima Puluh Kota',
'Pasaman',
'Kepulauan Mentawai',
'Dharmasraya',
'Solok Selatan',
'Pasaman Barat',
'Kota Padang',
'Kota Solok',
'Kota Sawahlunto',
'Kota Padang Panjang',
'Kota Bukittinggi',
'Kota Payakumbuh',
'Kota Pariaman',
'Kampar',
'Indragiri Hulu',
'Bengkalis',
'Indragiri Hilir',
'Pelalawan',
'Rokan Hulu',
'Rokan Hilir',
'Siak',
'Kuantan Singingi',
'Kepulauan Meranti',
'Kota Pekanbaru',
'Kota Dumai',
'Kerinci',
'Merangin',
'Sarolangun',
'Batanghari',
'Muaro Jambi',
'Tanjung Jabung Barat',
'Tanjung Jabung Timur',
'Bungo',
'Tebo',
'Kota Jambi',
'Kota Sungai Penuh',
'Ogan Komering Ulu',
'Ogan Komering Ilir',
'Muara Enim',
'Lahat',
'Musi Rawas',
'Musi Banyuasin',
'Banyuasin',
'Ogan Komering Ulu Timur',
'Ogan Komering Ulu Selatan',
'Ogan Ilir',
'Empat Lawang',
'Penukal Abab Lematang Ilir',
'Musi Rawas Utara',
'Kota Palembang',
'Kota Pagar Alam',
'Kota Lubuk Linggau',
'Kota Prabumulih',
'Bengkulu Selatan',
'Rejang Lebong',
'Bengkulu Utara',
'Kaur',
'Seluma',
'Muko Muko',
'Lebong',
'Kepahiang',
'Bengkulu Tengah',
'Kota Bengkulu',
'Lampung Selatan',
'Lampung Tengah',
'Lampung Utara',
'Lampung Barat',
'Tulang Bawang',
'Tanggamus',
'Lampung Timur',
'Way Kanan',
'Pesawaran',
'Pringsewu',
'Mesuji',
'Tulang Bawang Barat',
'Pesisir Barat',
'Kota Bandar Lampung',
'Kota Metro',
'Bangka',
'Belitung',
'Bangka Selatan',
'Bangka Tengah',
'Bangka Barat',
'Belitung Timur',
'Kota Pangkal Pinang',
'Bintan',
'Karimun',
'Natuna',
'Lingga',
'Kepulauan Anambas',
'Kota Batam',
'Kota Tanjung Pinang',
'Kepulauan Seribu',
'Kota Jakarta Pusat',
'Kota Jakarta Utara',
'Kota Jakarta Barat',
'Kota Jakarta Selatan',
'Kota Jakarta Timur',
'Bogor',
'Sukabumi',
'Cianjur',
'Bandung',
'Garut',
'Tasikmalaya',
'Ciamis',
'Kuningan',
'Cirebon',
'Majalengka',
'Sumedang',
'Indramayu',
'Subang',
'Purwakarta',
'Karawang',
'Bekasi',
'Bandung Barat',
'Pangandaran',
'Kota Bogor',
'Kota Sukabumi',
'Kota Bandung',
'Kota Cirebon',
'Kota Bekasi',
'Kota Depok',
'Kota Cimahi',
'Kota Tasikmalaya',
'Kota Banjar',
'Cilacap',
'Banyumas',
'Purbalingga',
'Banjarnegara',
'Kebumen',
'Purworejo',
'Wonosobo',
'Magelang',
'Boyolali',
'Klaten',
'Sukoharjo',
'Wonogiri',
'Karanganyar',
'Sragen',
'Grobogan',
'Blora',
'Rembang',
'Pati',
'Kudus',
'Jepara',
'Demak',
'Semarang',
'Temanggung',
'Kendal',
'Batang',
'Pekalongan',
'Pemalang',
'Tegal',
'Brebes',
'Kota Magelang',
'Kota Surakarta',
'Kota Salatiga',
'Kota Semarang',
'Kota Pekalongan',
'Kota Tegal',
'Kulon Progo',
'Bantul',
'Gunungkidul',
'Sleman',
'Kota Yogyakarta',
'Pacitan',
'Ponorogo',
'Trenggalek',
'Tulungagung',
'Blitar',
'Kediri',
'Malang',
'Lumajang',
'Jember',
'Banyuwangi',
'Bondowoso',
'Situbondo',
'Probolinggo',
'Pasuruan',
'Sidoarjo',
'Mojokerto',
'Jombang',
'Nganjuk',
'Madiun',
'Magetan',
'Ngawi',
'Bojonegoro',
'Tuban',
'Lamongan',
'Gresik',
'Bangkalan',
'Sampang',
'Pamekasan',
'Sumenep',
'Kota Kediri',
'Kota Blitar',
'Kota Malang',
'Kota Probolinggo',
'Kota Pasuruan',
'Kota Mojokerto',
'Kota Madiun',
'Kota Surabaya',
'Kota Batu',
'Pandeglang',
'Lebak',
'Tangerang',
'Serang',
'Kota Tangerang',
'Kota Cilegon',
'Kota Serang',
'Kota Tangerang Selatan',
'Jembrana',
'Tabanan',
'Badung',
'Gianyar',
'Klungkung',
'Bangli',
'Karangasem',
'Buleleng',
'Kota Denpasar',
'Lombok Barat',
'Lombok Tengah',
'Lombok Timur',
'Sumbawa',
'Dompu',
'Bima',
'Sumbawa Barat',
'Lombok Utara',
'Kota Mataram',
'Kota Bima',
'Kupang',
'Timor Tengah Selatan',
'Timor Tengah Utara',
'Belu',
'Alor',
'Flores Timur',
'Sikka',
'Ende',
'Ngada',
'Manggarai',
'Sumba Timur',
'Sumba Barat',
'Lembata',
'Rote Ndao',
'Manggarai Barat',
'Nagekeo',
'Sumba Tengah',
'Sumba Barat Daya',
'Manggarai Timur',
'Sabu Raijua',
'Malaka',
'Kota Kupang',
'Sambas',
'Mempawah',
'Sanggau',
'Ketapang',
'Sintang',
'Kapuas Hulu',
'Bengkayang',
'Landak',
'Sekadau',
'Melawi',
'Kayong Utara',
'Kubu Raya',
'Kota Pontianak',
'Kota Singkawang',
'Kotawaringin Barat',
'Kotawaringin Timur',
'Kapuas',
'Barito Selatan',
'Barito Utara',
'Katingan',
'Seruyan',
'Sukamara',
'Lamandau',
'Gunung Mas',
'Pulang Pisau',
'Murung Raya',
'Barito Timur',
'Kota Palangkaraya',
'Tanah Laut',
'Kotabaru',
'Banjar',
'Barito Kuala',
'Tapin',
'Hulu Sungai Selatan',
'Hulu Sungai Tengah',
'Hulu Sungai Utara',
'Tabalong',
'Tanah Bumbu',
'Balangan',
'Kota Banjarmasin',
'Kota Banjarbaru',
'Paser',
'Kutai Kartanegara',
'Berau',
'Kutai Barat',
'Kutai Timur',
'Penajam Paser Utara',
'Mahakam Ulu',
'Kota Balikpapan',
'Kota Samarinda',
'Kota Bontang',
'Bulungan',
'Malinau',
'Nunukan',
'Tana Tidung',
'Kota Tarakan',
'Bolaang Mongondow',
'Minahasa',
'Kepulauan Sangihe',
'Kepulauan Talaud',
'Minahasa Selatan',
'Minahasa Utara',
'Minahasa Tenggara',
'Bolaang Mongondow Utara',
'Kepulauan Siau Tagulandang Biaro (Sitaro)',
'Bolaang Mongondow Timur',
'Bolaang Mongondow Selatan',
'Kota Manado',
'Kota Bitung',
'Kota Tomohon',
'Kota Kotamobagu',
'Banggai',
'Poso',
'Donggala',
'Toli Toli',
'Buol',
'Morowali',
'Banggai Kepulauan',
'Parigi Moutong',
'Tojo Una Una',
'Sigi',
'Banggai Laut',
'Morowali Utara',
'Kota Palu',
'Kepulauan Selayar',
'Bulukumba',
'Bantaeng',
'Jeneponto',
'Takalar',
'Gowa',
'Sinjai',
'Bone',
'Maros',
'Pangkajene Kepulauan',
'Barru',
'Soppeng',
'Wajo',
'Sidenreng Rappang',
'Pinrang',
'Enrekang',
'Luwu',
'Tana Toraja',
'Luwu Utara',
'Luwu Timur',
'Toraja Utara',
'Kota Makassar',
'Kota Pare Pare',
'Kota Palopo',
'Kolaka',
'Konawe',
'Muna',
'Buton',
'Konawe Selatan',
'Bombana',
'Wakatobi',
'Kolaka Utara',
'Konawe Utara',
'Buton Utara',
'Kolaka Timur',
'Konawe Kepulauan',
'Muna Barat',
'Buton Tengah',
'Buton Selatan',
'Kota Kendari',
'Kota Bau Bau',
'Gorontalo',
'Boalemo',
'Bone Bolango',
'Pahuwato',
'Gorontalo Utara',
'Kota Gorontalo',
'Pasangkayu (Mamuju Utara)',
'Mamuju',
'Mamasa',
'Polewali Mandar',
'Majene',
'Mamuju Tengah',
'Maluku Tengah',
'Maluku Tenggara',
'Kepulauan Tanimbar (Maluku Tenggara Barat)',
'Buru',
'Seram Bagian Timur',
'Seram Bagian Barat',
'Kepulauan Aru',
'Maluku Barat Daya',
'Buru Selatan',
'Kota Ambon',
'Kota Tual',
'Halmahera Barat',
'Halmahera Tengah',
'Halmahera Utara',
'Halmahera Selatan',
'Kepulauan Sula',
'Halmahera Timur',
'Pulau Morotai',
'Pulau Taliabu',
'Kota Ternate',
'Kota Tidore Kepulauan',
'Jayapura',
'Kepulauan Yapen',
'Biak Numfor',
'Sarmi',
'Keerom',
'Waropen',
'Supiori',
'Mamberamo Raya',
'Kota Jayapura',
'Manokwari',
'Fak Fak',
'Teluk Bintuni',
'Teluk Wondama',
'Kaimana',
'Manokwari Selatan',
'Pegunungan Arfak',
'Merauke',
'Boven Digoel',
'Mappi',
'Asmat',
'Nabire',
'Puncak Jaya',
'Paniai',
'Mimika',
'Puncak',
'Dogiyai',
'Intan Jaya',
'Deiyai',
'Jayawijaya',
'Pegunungan Bintang',
'Yahukimo',
'Tolikara',
'Mamberamo Tengah',
'Yalimo',
'Lanny Jaya',
'Nduga',
'Sorong',
'Sorong Selatan',
'Raja Ampat',
'Tambrauw',
'Maybrat',
'Kota Sorong',
];
export default INDONESIAN_CITIES;
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');
};
+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.
+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;
},
});
};
+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",