Compare commits

..
13 Commits
Author SHA1 Message Date
Hafid Nur 53c45da00a feat(backoffice): improve hackathon team modal UI and add API contract
- Add feature to select city in team detail modal using CityFilterSelect component
- Add feature to change team logo and banner
- Add API contract documentation for hackathon teams in backoffice
2025-12-02 23:14:40 +07:00
Hafid Nur 136af9caf2 feat(backoffice): add searchable city filter
- add component for city filter
- apply to user management and team management pages
2025-12-02 22:49:08 +07:00
Hafid Nur fbc8e0b16a feat(backoffice): update hackathon team management page
- add modal for manage team, add new team, and view project submission
- reorganize the table column and data table
2025-12-02 22:45:35 +07:00
Hafid Nur aa4686254b Merge branch 'develop' of github.com:IMPHNEN/imphnen-frontend-service into backoffice/for-hackathon 2025-12-02 18:13:03 +07:00
Hafid Nur 2d80ed05db feat(backoffice): little adjustment in hackathon users management UI and API contract 2025-12-02 18:11:34 +07:00
Hafid Nur d927b95457 feat(backoffice): add API contract for hackathon users 2025-12-01 11:41:56 +07:00
Hafid Nur 2dc9dd985a 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
2025-12-01 11:41:18 +07:00
Hafid Nur 80a6aa9fb5 update endpoint 2025-11-30 18:57:04 +07:00
Hafid Nur 1343404e01 feat(hackathon): draft data table column & API Contract 2025-11-30 18:54:34 +07:00
Hafid Nur 5faba43728 base UI for Hackathon backoffice
TODO:
- Organize table schema for users, teams, and submissions management
- Create API Contract for additional back-end endpoint
2025-11-30 17:22:47 +07:00
Hafid Nur a20f5eff85 test datatable with mock data 2025-11-30 16:55:11 +07:00
Hafid Nur 18e835450a 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
2025-11-30 12:59:18 +07:00
Hafid Nur f5ad55235c feat(backoffice): create a boilerplate page for Hackathon dashboard
- Create an empty page for Hackathon dashboard
- Comment out and hide the existing backoffice sidebar
2025-11-30 12:29:58 +07:00
17 changed files with 2953 additions and 5254 deletions
+2 -5
View File
@@ -1,6 +1,3 @@
VITE_API_URL=https://forsomereason.dev/api/v1
VITE_GITHUB_CLIENT_ID=SuperSecretClientID12345
VITE_SUPABASE_URL=https://mamahakumaurtx5090ti.supabase.co
VITE_SUPABASE_ANON_KEY=anonkeykekgimanasihbang
SUPABASE_SERVICE_ROLE_KEY=service.role.keyisahsecret
VITE_SUPABASE_URL=https://your-project-id.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key-here
-13
View File
@@ -1,13 +0,0 @@
<!-- nx configuration start-->
<!-- Leave the start & end comments to automatically receive updates. -->
# General Guidelines for working with Nx
- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
- You have access to the Nx MCP server and its tools, use them to help the user
- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable.
- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies
- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration
- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors
<!-- nx configuration end-->
@@ -0,0 +1,822 @@
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;
@@ -0,0 +1,216 @@
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;
@@ -0,0 +1,82 @@
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,179 +1,476 @@
import { FC, ReactElement, useState } from 'react';
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 {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import { useTeams } from '@imphnen-frontend-service/service';
import { EditOutlined } from '@ant-design/icons';
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,
};
});
export const HackathonTeamsPage: FC = (): ReactElement => {
const { data: teamsData } = useTeams();
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 [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
// Advanced filtering states
const [visibilityFilter, setVisibilityFilter] = useState('all');
const [cityFilter, setCityFilter] = useState('all');
const [submissionFilter, setSubmissionFilter] = useState('all');
const [memberCountFilter, setMemberCountFilter] = useState('all');
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`,
},
},
}));
// Constants
const pageSize = 10;
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;
};
};
}
// Memoize the callback to prevent recreation
const handleShowDetailModal = useCallback((team: TeamType) => {
setSelectedTeam(team);
setShowDetailModal(true);
}, []);
const columns: ColumnDef<TeamType>[] = [
{
accessorKey: 'id',
header: 'ID',
},
{
accessorKey: 'name',
header: 'Team Name',
},
{
accessorKey: 'city',
header: 'City',
},
{
accessorKey: 'visibility',
header: 'Visibility',
cell: ({ row }) => {
const isPublic = row.original.visibility === 'public';
return (
<span
className={cn(
'py-2 px-4 text-sm rounded-2xl text-center',
isPublic
? 'bg-info-200 text-info-700'
: 'bg-gray-200 text-gray-700'
)}
>
{isPublic ? 'Public' : 'Private'}
</span>
);
},
},
{
accessorKey: 'member_count',
header: 'Members',
},
{
id: 'leader',
header: 'Leader',
cell: ({ row }) => {
const leader = row.original.leader?.user;
return leader ? (
<div>
<div className="text-sm font-medium text-gray-900">
{leader.fullname}
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>
<div className="text-xs text-gray-500">{leader.email}</div>
);
},
enableSorting: true,
},
{
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>
) : (
<span className="text-gray-400 italic">-</span>
);
),
enableSorting: true,
},
},
{
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'}
{
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',
})}
</span>
);
),
enableSorting: true,
sortingFn: 'datetime',
},
},
{
accessorKey: 'updated_at',
header: 'Last Updated',
cell: ({ row }) => {
return new Date(row.original.updated_at).toLocaleDateString();
{
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>
</div>
),
enableSorting: false,
},
},
{
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,
});
],
[handleShowDetailModal, handleShowSubmissionModal]
);
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
@@ -182,27 +479,204 @@ 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">
<input
type="text"
className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-64"
placeholder="Search name or email"
/>
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
</select>
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
<option value="all">All City</option>
<option value="jakarta">Jakarta</option>
<option value="bandung">Bandung</option>
</select>
<div className="flex flex-wrap gap-3 items-center justify-between">
{/* 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>
{/* 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={mockData} columns={columns} table={table} />
<DataTable data={filteredData} columns={columns} pageSize={10} />
</section>
{/* Modals extracted into shared backoffice components */}
{/* 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}
/>
)}
</BackofficeWrapper>
);
};
@@ -14,7 +14,7 @@ import {
FilterOutlined,
PlusOutlined,
} from '@ant-design/icons';
// Removed unused SearchOutlined icon after schema revision
import { CityFilterSelect } from '../../../components/city-filter-select';
// Define interface outside component
interface UserType {
@@ -89,7 +89,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
// Advanced filtering states
const [statusFilter, setStatusFilter] = useState('all');
const [locationFilter, setLocationFilter] = useState('all');
const [cityFilter, setCityFilter] = useState('all');
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
// Constants
@@ -123,8 +123,8 @@ export const HackathonUsersPage: FC = (): ReactElement => {
if (user.is_active !== isActive) return false;
}
// Location filter
if (locationFilter !== 'all' && user.location !== locationFilter) {
// City filter
if (cityFilter !== 'all' && user.location !== cityFilter) {
return false;
}
@@ -138,7 +138,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
return true;
});
}, [statusFilter, locationFilter, skillsFilter]);
}, [statusFilter, cityFilter, skillsFilter]);
// Memoize columns to prevent recreation on every render
const columns: ColumnDef<UserType>[] = useMemo(
@@ -315,22 +315,25 @@ export const HackathonUsersPage: FC = (): ReactElement => {
</select>
</div>
{/* Location Filter */}
<div className="relative">
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
<select
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={locationFilter}
onChange={(e) => setLocationFilter(e.target.value)}
>
<option value="all">All Locations</option>
{locations.map((location) => (
<option key={location} value={location}>
{location}
</option>
))}
</select>
</div>
{/* 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>
)}
{/* Skills Filter with Icon */}
<div className="relative">
@@ -378,7 +381,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
{/* Active filters display */}
{(skillsFilter.length > 0 ||
statusFilter !== 'all' ||
locationFilter !== 'all') && (
cityFilter !== 'all') && (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span>
@@ -396,11 +399,11 @@ export const HackathonUsersPage: FC = (): ReactElement => {
)}
{/* Location filter badge */}
{locationFilter !== 'all' && (
{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: {locationFilter}
City: {cityFilter}
<button
onClick={() => setLocationFilter('all')}
onClick={() => setCityFilter('all')}
className="text-green-600 hover:text-green-800 cursor-pointer"
>
@@ -432,7 +435,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
size="sm"
onClick={() => {
setStatusFilter('all');
setLocationFilter('all');
setCityFilter('all');
setSkillsFilter([]);
setGlobalFilter('');
}}
@@ -0,0 +1,148 @@
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
@@ -0,0 +1,518 @@
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;
@@ -1,297 +0,0 @@
'use client';
import { FC, ReactElement, useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router';
import {
useUserMe,
useMyTeams,
} from '@imphnen-frontend-service/service';
import { generateCertificateHash } from '@imphnen-frontend-service/utils';
import { Icon } from '@iconify/react';
interface Team {
id: string;
name: string;
city: string;
created_at: string;
}
const CertificatePage: FC = (): ReactElement => {
const { certId } = useParams<{ certId: string }>();
const navigate = useNavigate();
const { data: userData } = useUserMe();
const { data: teamsData } = useMyTeams();
const [isValid, setIsValid] = useState<boolean>(false);
const [validTeam, setValidTeam] = useState<Team | null>(null);
const [loading, setLoading] = useState(true);
const user = userData?.data;
// Add print styles
useEffect(() => {
const style = document.createElement('style');
style.textContent = `
@media print {
@page {
size: landscape;
margin: 0;
}
body {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
}
`;
document.head.appendChild(style);
return () => {
document.head.removeChild(style);
};
}, []);
// Verify certificate
useEffect(() => {
const teams = teamsData?.data || [];
if (!user || !teams.length || !certId) {
setLoading(true);
return;
}
const matchingTeam = teams.find((team: Team) => {
const generatedHash = generateCertificateHash(user.id, team.id);
return generatedHash === certId;
});
if (matchingTeam) {
setIsValid(true);
setValidTeam(matchingTeam as Team);
} else {
setIsValid(false);
setValidTeam(null);
}
setLoading(false);
}, [user, teamsData, certId]);
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric'
});
};
const handlePrint = () => {
window.print();
};
const handleDownload = () => {
window.print(); // Yoloooo
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-linear-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800">
<div className="text-center">
<Icon icon="eos-icons:loading" className="text-6xl text-primary-600 mb-4 mx-auto" />
<p className="text-gray-600 dark:text-gray-400">Validating certificate...</p>
</div>
</div>
);
}
if (!isValid || !validTeam || !user) {
return (
<div className="min-h-screen flex items-center justify-center bg-linear-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-4">
<div className="text-center max-w-md">
<Icon icon="heroicons:x-circle-solid" className="text-8xl text-red-500 mb-6 mx-auto" />
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
Invalid Certificate
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-8">
The certificate ID you provided is not valid or you don't have access to view this certificate.
</p>
<button
onClick={() => navigate('/dashboard')}
className="px-6 py-3 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors"
>
Go to Dashboard
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-linear-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-4 md:p-8">
{/* Action buttons - hidden on print */}
<div className="max-w-7xl mx-auto mb-6 print:hidden">
<div className="flex justify-between items-center">
<button
onClick={() => navigate('/dashboard')}
className="flex items-center gap-2 px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
>
<Icon icon="heroicons:arrow-left" />
<span>Back to Dashboard</span>
</button>
<div className="flex gap-3">
<button
onClick={handleDownload}
className="flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
>
<Icon icon="heroicons:arrow-down-tray" />
<span>Download</span>
</button>
<button
onClick={handlePrint}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
<Icon icon="heroicons:printer" />
<span>Print</span>
</button>
</div>
</div>
</div>
{/* Certificate */}
<div className="max-w-5xl mx-auto">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl overflow-hidden print:shadow-none print:rounded-none">
{/* Decorative header pattern */}
<div className="h-3 bg-linear-to-r from-primary-400 via-primary-600 to-primary-400" />
<div className="p-12 md:p-16 relative">
{/* Background pattern */}
<div className="absolute inset-0 opacity-5 dark:opacity-10">
<div className="absolute inset-0" style={{
backgroundImage: `repeating-linear-gradient(
45deg,
transparent,
transparent 35px,
currentColor 35px,
currentColor 70px
)`
}} />
</div>
{/* Content */}
<div className="relative z-10">
{/* Header */}
<div className="flex justify-between items-start mb-12">
<div>
<h1 className="text-2xl md:text-3xl font-bold text-primary-600 dark:text-primary-400 mb-2">
IMPHNEN x KOLOSAL
</h1>
<p className="text-lg text-gray-600 dark:text-gray-400">
Hackathon 2025
</p>
</div>
<div className="text-right">
<div className="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-1">
PARTICIPANT
</div>
<div className="text-lg font-bold text-gray-700 dark:text-gray-300">
Certificate of Completion
</div>
</div>
</div>
{/* Badge/Icon */}
<div className="flex justify-center mb-8">
<div className="relative">
<div className="w-32 h-32 rounded-full bg-linear-to-br from-primary-400 to-primary-600 flex items-center justify-center shadow-lg">
<div className="bg-white dark:bg-slate-800 w-28 h-28 rounded-full flex items-center justify-center">
<Icon icon="heroicons:academic-cap-solid" className="text-6xl text-primary-600 dark:text-primary-400" />
</div>
</div>
{/* Laurel wreath effect */}
<div className="absolute -top-2 -left-2 text-yellow-500">
<Icon icon="game-icons:laurel-crown" className="text-4xl" />
</div>
<div className="absolute -bottom-2 -right-2 text-yellow-500">
<Icon icon="game-icons:laurel-crown" className="text-4xl transform rotate-180" />
</div>
</div>
</div>
{/* Certificate text */}
<div className="text-center mb-12">
<p className="text-lg text-gray-600 dark:text-gray-400 mb-6">
This award is presented to
</p>
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-8 tracking-wide">
{user.fullname}
</h2>
<div className="max-w-2xl mx-auto">
<p className="text-lg text-gray-700 dark:text-gray-300 leading-relaxed">
for active participation and successful completion in the IMPHNEN x KOLOSAL Hackathon 2025
as a member of team <span className="font-semibold text-primary-600 dark:text-primary-400">"{validTeam.name}"</span> from{' '}
<span className="font-semibold">{validTeam.city}</span>.
</p>
</div>
</div>
{/* Signature and date */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-8 mt-12">
<div className="flex justify-between items-end">
<div>
<div className="mb-8">
<div className="font-signature text-3xl text-gray-700 dark:text-gray-300 mb-2" style={{ fontFamily: 'cursive' }}>
IMPHNEN Team
</div>
</div>
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300">
IMPHNEN Team
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Hackathon Organizer
</div>
</div>
<div className="text-right">
<div className="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-1">
{formatDate(validTeam.created_at)}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Completion Date
</div>
</div>
</div>
</div>
{/* Certificate ID footer */}
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs text-center text-gray-500 dark:text-gray-400 font-mono break-all">
Certificate ID: {certId}
</p>
</div>
</div>
</div>
{/* Decorative footer pattern */}
<div className="h-3 bg-linear-to-r from-primary-400 via-primary-600 to-primary-400" />
</div>
</div>
{/* Certificate verification info - hidden on print */}
<div className="max-w-5xl mx-auto mt-8 print:hidden">
<div className="bg-white dark:bg-slate-800 rounded-lg p-6 shadow-md">
<div className="flex items-start gap-3">
<Icon icon="heroicons:information-circle" className="text-2xl text-primary-600 mt-1 shrink-0" />
<div className="text-sm text-gray-600 dark:text-gray-400">
<p className="font-semibold mb-2">Certificate Verification</p>
<p>
This certificate is cryptographically verified using HMAC-SHA256.
The certificate ID is generated from your user ID and team ID, ensuring authenticity and uniqueness.
</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default CertificatePage;
-4512
View File
File diff suppressed because it is too large Load Diff
@@ -391,7 +391,364 @@ 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.
-2
View File
@@ -7,5 +7,3 @@ export * from './cookies';
export * from './session';
export * from './constants';
export * from './logic';
export * from './lib/crypto';
export * from './lib/certificate';
-18
View File
@@ -1,18 +0,0 @@
import { generateCertificateHash } from './crypto';
/**
* Generate a certificate URL for a user and team
* @param userId - User ID
* @param teamId - Team ID
* @param baseUrl - Base URL (optional, defaults to current origin)
* @returns Certificate URL
*/
export const generateCertificateUrl = (
userId: string,
teamId: string,
baseUrl?: string
): string => {
const certId = generateCertificateHash(userId, teamId);
const base = baseUrl || (typeof window !== 'undefined' ? window.location.origin : '');
return `${base}/certificate/${certId}`;
};
-31
View File
@@ -1,31 +0,0 @@
import CryptoJS from 'crypto-js';
/**
* Generate certificate hash using HMAC-SHA256
* @param userId - User ID
* @param teamId - Team ID
* @returns Hash string
*/
export const generateCertificateHash = (userId: string, teamId: string): string => {
const key = 'imphnenxkolosal';
const message = userId + teamId;
// Repeat key to match required length (32 bytes for SHA256)
const repeatedKey = key.repeat(Math.ceil(32 / key.length)).substring(0, 32);
// Create HMAC-SHA256 hash
const hash = CryptoJS.HmacSHA256(message, repeatedKey);
return hash.toString(CryptoJS.enc.Hex);
};
/**
* Verify if a certificate hash is valid for a given user and team
* @param certId - Certificate ID to verify
* @param userId - User ID
* @param teamId - Team ID
* @returns True if the certificate is valid
*/
export const verifyCertificateHash = (certId: string, userId: string, teamId: string): boolean => {
const generatedHash = generateCertificateHash(userId, teamId);
return generatedHash === certId;
};
+128 -171
View File
@@ -78,7 +78,7 @@
"@vitejs/plugin-react": "^4.2.0",
"@vitest/coverage-v8": "^3.0.5",
"@vitest/ui": "^3.0.0",
"ajv": "^8.13.0",
"ajv": "^6.12.6",
"eslint": "^9.8.0",
"eslint-config-next": "^15.2.4",
"eslint-config-prettier": "10.0.0",
@@ -216,7 +216,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -2689,28 +2688,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint/eslintrc/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"license": "MIT"
},
"node_modules/@eslint/js": {
"version": "9.39.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
@@ -4142,7 +4119,6 @@
"integrity": "sha512-fnP+ZOZTFeBGiTAnxve+axGmiYn2D60h86nUISXjXClK3LUY1krUfPgf6MaD4YDJ4i51OGXZWPekeMe16pkd8Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@module-federation/runtime": "0.21.6",
"@module-federation/webpack-bundler-runtime": "0.21.6"
@@ -5275,6 +5251,23 @@
"vitest": "^1.3.1 || ^2.0.0 || ^3.0.0 || ^4.0.0"
}
},
"node_modules/@nx/vite/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@nx/vite/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -5387,6 +5380,23 @@
"webpack-subresource-integrity": "^5.1.0"
}
},
"node_modules/@nx/webpack/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@nx/workspace": {
"version": "22.1.1",
"resolved": "https://registry.npmjs.org/@nx/workspace/-/workspace-22.1.1.tgz",
@@ -5779,7 +5789,6 @@
"integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright": "1.56.1"
},
@@ -7121,7 +7130,6 @@
"integrity": "sha512-5F1+MQD8rfbFbUHnaiZe4jqOu9pnSb+PliqQvi0lj+uvpMpcS3sJDIs/mz6P1u87lfkfBXChIT4zSLAzeOgMWw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@module-federation/runtime-tools": "0.21.4",
"@rspack/binding": "1.6.4",
@@ -7250,7 +7258,6 @@
"integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"json-schema-traverse": "^1.0.0",
@@ -7732,7 +7739,6 @@
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
@@ -7999,7 +8005,6 @@
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.8"
@@ -8215,7 +8220,6 @@
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
@@ -8226,7 +8230,6 @@
"integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3"
}
@@ -8540,7 +8543,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.10.tgz",
"integrity": "sha512-BKLss9Y8PQ9IUjPYQiv3/Zmlx92uxffUOX8ZZNoQlCIZBJPT5M+GOMQj7xislvVQ6l1BstBjcX0XB/aHfFYVNw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.10"
},
@@ -8619,7 +8621,6 @@
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -8808,7 +8809,6 @@
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.20.7",
"@babel/types": "^7.20.7",
@@ -9136,7 +9136,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz",
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -9184,7 +9183,6 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -9195,7 +9193,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -9362,7 +9359,6 @@
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.48.0",
"@typescript-eslint/types": "8.48.0",
@@ -9947,6 +9943,23 @@
"url": "https://opencollective.com/verdaccio"
}
},
"node_modules/@verdaccio/core/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@verdaccio/core/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
@@ -10089,6 +10102,23 @@
"url": "https://opencollective.com/verdaccio"
}
},
"node_modules/@verdaccio/local-storage-legacy/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@verdaccio/local-storage-legacy/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
@@ -10559,7 +10589,6 @@
"integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/utils": "3.2.4",
"fflate": "^0.8.2",
@@ -11104,7 +11133,6 @@
"integrity": "sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"argparse": "^2.0.1"
},
@@ -11152,7 +11180,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -11229,17 +11256,15 @@
}
},
"node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
@@ -11264,6 +11289,39 @@
}
}
},
"node_modules/ajv-formats/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-keywords": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"ajv": "^6.9.1"
}
},
"node_modules/ajv/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"license": "MIT"
},
"node_modules/alien-signals": {
"version": "0.4.14",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-0.4.14.tgz",
@@ -12388,7 +12446,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -12720,7 +12777,6 @@
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
@@ -14906,7 +14962,6 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -15057,7 +15112,6 @@
"deprecated": "wrong version missing main field",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "build/bin/cli.js"
},
@@ -15156,7 +15210,6 @@
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.8",
@@ -15420,22 +15473,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/eslint/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
@@ -15448,12 +15485,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"license": "MIT"
},
"node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
@@ -15940,41 +15971,6 @@
"webpack": "^4.0.0 || ^5.0.0"
}
},
"node_modules/file-loader/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/file-loader/node_modules/ajv-keywords": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"ajv": "^6.9.1"
}
},
"node_modules/file-loader/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/file-loader/node_modules/schema-utils": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
@@ -16339,34 +16335,6 @@
}
}
},
"node_modules/fork-ts-checker-webpack-plugin/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/fork-ts-checker-webpack-plugin/node_modules/ajv-keywords": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"ajv": "^6.9.1"
}
},
"node_modules/fork-ts-checker-webpack-plugin/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -16437,13 +16405,6 @@
"node": ">= 6"
}
},
"node_modules/fork-ts-checker-webpack-plugin/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/fork-ts-checker-webpack-plugin/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@@ -19547,7 +19508,6 @@
"integrity": "sha512-j1n1IuTX1VQjIy3tT7cyGbX7nvQOsFLoIqobZv4ttI5axP923gA44zUj6miiA6R5Aoms4sEGVIIcucXUbRI14g==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"copy-anything": "^2.0.1",
"parse-node-version": "^1.0.1",
@@ -21029,7 +20989,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@napi-rs/wasm-runtime": "0.2.4",
"@yarnpkg/lockfile": "^1.1.0",
@@ -21396,7 +21355,6 @@
"resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.15.0.tgz",
"integrity": "sha512-OjQUdi61WO4HYhr9+byCPMj0+bgste/LtSBEcV6FzDdONTs7x0fWn8/ndoYwzqCsKWIxEZwo4FN/TG1c1rI8IQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"openapi-typescript-helpers": "^0.0.15"
}
@@ -22269,7 +22227,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -23337,7 +23294,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -23347,7 +23303,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -23360,7 +23315,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz",
"integrity": "sha512-2KnjpgG2Rhbi+CIiIBQQ9Df6sMGH5ExNyFl4Hw9qO7pIqMBR8Bvu9RQyjl3JM4vehzCh9soiNUM/xYMswb2EiA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -23393,7 +23347,6 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -23642,8 +23595,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -23944,7 +23896,6 @@
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -25229,7 +25180,6 @@
"integrity": "sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
@@ -25251,7 +25201,6 @@
"integrity": "sha512-+VUy01yfDqNmIVMd/LLKl2TTtY0ovZN0rTonh+FhKr65mFwIYgU9WzgIZKS7U9/SPCQvWTsTGx9jyt+qRm/XFw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@bufbuild/protobuf": "^2.5.0",
"buffer-builder": "^0.2.0",
@@ -25743,6 +25692,23 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/schema-utils/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/schema-utils/node_modules/ajv-keywords": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
@@ -27848,7 +27814,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -27929,8 +27894,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/tsscmp": {
"version": "1.0.6",
@@ -28112,7 +28076,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -28181,7 +28144,6 @@
"integrity": "sha512-jCNyAuXx8dr5KJMkecGmZ8KI61KBUhkCob+SD+C+I5+Y1FWI2Y3QmY4/cxMCC5WAsZqoEtEETVhUiUMIGCf6Bw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.40.0",
"@typescript-eslint/types": "8.40.0",
@@ -28867,7 +28829,6 @@
"integrity": "sha512-hMIcPES81Cx+9xMJtBrPvLDODV433CRcen+akIu4NRQEb6rBHuZfWMhPZi1J7Ri3wSKLgp3ahexVKrkb8tTbjQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cypress/request": "3.0.9",
"@verdaccio/auth": "8.0.0-next-8.27",
@@ -29051,7 +29012,6 @@
"integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -29190,7 +29150,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -29341,7 +29300,6 @@
"integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@@ -30053,7 +30011,6 @@
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
+1 -3
View File
@@ -46,7 +46,6 @@
"axios": "^1.8.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.13",
"framer-motion": "^12.9.2",
"graphql": "^16.11.0",
@@ -95,7 +94,6 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.6.1",
"@types/crypto-js": "^4.2.2",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.12.0",
"@types/react": "^19.1.2",
@@ -103,7 +101,7 @@
"@vitejs/plugin-react": "^4.2.0",
"@vitest/coverage-v8": "^3.0.5",
"@vitest/ui": "^3.0.0",
"ajv": "^8.13.0",
"ajv": "^6.12.6",
"eslint": "^9.8.0",
"eslint-config-next": "^15.2.4",
"eslint-config-prettier": "10.0.0",