chore: upgrade dependencies, restructure shared libs, and fix UI
- Upgrade Nx 22.1.1 → 22.6.3 and all patch/minor dependencies - Restructure shared libs: move business logic from utils to service - Consolidate shadcn-ui into ui lib with atomic design pattern - Fix container centering for landing app (Tailwind v4 compatibility) - Fix button styling by updating @source directive in globals.css - Fix SiCss3 → SiCss rename in react-icons 5.6 - Fix duplicate useSession export conflict - Remove dead code, comments, and unused files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
f68d97188c
commit
3f4461c65c
@@ -1 +0,0 @@
|
||||
Tue, Nov 25, 2025 4:21:27 PM
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Deployment trigger
|
||||
# Updated to deploy circular dependency fixes and auth hooks
|
||||
|
||||
@@ -28,7 +28,6 @@ interface Account {
|
||||
address: string;
|
||||
}
|
||||
|
||||
// Mock data for demonstration
|
||||
const mockData: Account[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
|
||||
@@ -136,13 +135,10 @@ export const Components: FC = (): ReactElement => {
|
||||
return (
|
||||
<Fragment>
|
||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||
{/* Header */}
|
||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||
<h1 className="text-p2 font-semibold">Data Akun</h1>
|
||||
</header>
|
||||
{/* Account Table Section */}
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
{/* Search and Filter */}
|
||||
<div className="flex justify-between items-center gap-8 mb-2">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
@@ -171,12 +167,10 @@ export const Components: FC = (): ReactElement => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Table */}
|
||||
<DataTable data={mockData} columns={columns} table={table} />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Modal Edit Account */}
|
||||
<ModalEditAccount
|
||||
currentStep={currentStep}
|
||||
isOpen={showModalEditAccount}
|
||||
|
||||
@@ -38,8 +38,6 @@ export const useConfirmItem = (
|
||||
) => {
|
||||
const onConfirm = async () => {
|
||||
try {
|
||||
// const result = await actionFunction?.();
|
||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||
toast.success(messages?.success);
|
||||
onClose();
|
||||
resetStep();
|
||||
|
||||
@@ -147,7 +147,6 @@ export const Components: FC = (): ReactElement => {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Right-side illustration */}
|
||||
<img
|
||||
src="gacha.webp"
|
||||
alt=""
|
||||
@@ -156,7 +155,6 @@ export const Components: FC = (): ReactElement => {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Modal Add Item */}
|
||||
<ModalAddItem
|
||||
currentStep={currentStep}
|
||||
isOpen={showModalAddItem}
|
||||
@@ -166,7 +164,6 @@ export const Components: FC = (): ReactElement => {
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
|
||||
{/* Modal Edit Item */}
|
||||
<ModalEditItem
|
||||
currentStep={currentStep}
|
||||
isOpen={showModalEditItem}
|
||||
@@ -176,7 +173,6 @@ export const Components: FC = (): ReactElement => {
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
|
||||
{/* Modal Delete Item */}
|
||||
<ModalDeleteItem
|
||||
isOpen={showModalDeleteItem}
|
||||
onClose={() => setShowModalDeleteItem(false)}
|
||||
|
||||
@@ -38,8 +38,6 @@ export const useConfirmItem = (
|
||||
) => {
|
||||
const onConfirm = async () => {
|
||||
try {
|
||||
// const result = await actionFunction?.();
|
||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||
toast.success(messages?.success);
|
||||
onClose();
|
||||
resetStep();
|
||||
|
||||
@@ -8,19 +8,16 @@ import {
|
||||
} from '@imphnen-frontend-service/service';
|
||||
|
||||
export const HackathonDashboardPage: FC = (): ReactElement => {
|
||||
// Fetch total participants
|
||||
const { data: usersData } = useQuery({
|
||||
queryKey: ['admin-users-count'],
|
||||
queryFn: () => getAdminUsers({ page: 1, per_page: 1 }),
|
||||
});
|
||||
|
||||
// Fetch total teams
|
||||
const { data: teamsData } = useQuery({
|
||||
queryKey: ['admin-teams-count'],
|
||||
queryFn: () => getAdminTeams({ page: 1, per_page: 1 }),
|
||||
});
|
||||
|
||||
// Fetch total submissions
|
||||
const { data: submissionsData } = useQuery({
|
||||
queryKey: ['admin-submissions-count'],
|
||||
queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }),
|
||||
@@ -35,21 +32,18 @@ export const HackathonDashboardPage: FC = (): ReactElement => {
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
|
||||
|
||||
<section className="grid grid-cols-5 gap-5">
|
||||
{/* Participant */}
|
||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||
{totalParticipants}
|
||||
</h3>
|
||||
<p className="text-neutral-400 text-p3">Total Participants</p>
|
||||
</div>
|
||||
{/* Team */}
|
||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||
{totalTeams}
|
||||
</h3>
|
||||
<p className="text-neutral-400 text-p3">Total Teams</p>
|
||||
</div>
|
||||
{/* Project Submitted */}
|
||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||
{totalSubmissions}
|
||||
|
||||
+3
-19
@@ -40,7 +40,7 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||
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">
|
||||
@@ -68,9 +68,8 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Submission Status */}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-4 border rounded-lg',
|
||||
@@ -96,7 +95,6 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Description */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-neutral-700 mb-2">
|
||||
Project Description
|
||||
@@ -106,13 +104,11 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Project Links */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-neutral-700">
|
||||
Project Links
|
||||
</h3>
|
||||
|
||||
{/* Repository URL */}
|
||||
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
||||
<LinkOutlined className="text-primary-500 mt-1" />
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -130,7 +126,6 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Demo URL */}
|
||||
{submission.demo_url && (
|
||||
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
||||
<LinkOutlined className="text-primary-500 mt-1" />
|
||||
@@ -150,7 +145,6 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Presentation URL */}
|
||||
{submission.presentation_url && (
|
||||
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
||||
<LinkOutlined className="text-primary-500 mt-1" />
|
||||
@@ -171,7 +165,6 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Screenshots */}
|
||||
{submission.screenshots && submission.screenshots.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-neutral-700 flex items-center gap-2">
|
||||
@@ -198,7 +191,6 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-neutral-200">
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-1">Created</p>
|
||||
@@ -227,19 +219,11 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-neutral-200 bg-neutral-50">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
{/* <Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
console.log('Edit submission:', submission.id);
|
||||
}}
|
||||
>
|
||||
Edit Status
|
||||
</Button> */}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,6 @@ export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
||||
useState<SubmissionType | null>(null);
|
||||
const [globalFilter, setGlobalFilter] = useState(searchQuery);
|
||||
|
||||
// Fetch submissions from API
|
||||
const {
|
||||
data: submissionsResponse,
|
||||
isLoading,
|
||||
@@ -64,14 +63,13 @@ export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
search: searchQuery || undefined,
|
||||
}),
|
||||
staleTime: 30000, // 30 seconds cache
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||
staleTime: 30000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const totalData = submissionsResponse?.meta?.total_data || 0;
|
||||
const totalPages = submissionsResponse?.meta?.total_page || 1;
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = useCallback(
|
||||
(newPage: number) => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -85,19 +83,16 @@ export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
||||
[setSearchParams, perPage, searchQuery, statusFilter]
|
||||
);
|
||||
|
||||
// Validate page number
|
||||
useEffect(() => {
|
||||
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||
setSearchParams({ page: totalPages.toString() });
|
||||
}
|
||||
}, [currentPage, totalPages, setSearchParams, isLoading]);
|
||||
|
||||
// Sync globalFilter with URL
|
||||
useEffect(() => {
|
||||
setGlobalFilter(searchQuery);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Handle search
|
||||
const handleSearch = useCallback(() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', '1');
|
||||
@@ -118,7 +113,6 @@ export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
||||
[handleSearch]
|
||||
);
|
||||
|
||||
// Handle per page change
|
||||
const handlePerPageChange = useCallback(
|
||||
(newPerPage: number) => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -131,20 +125,6 @@ export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
||||
[setSearchParams, searchQuery, statusFilter]
|
||||
);
|
||||
|
||||
// Handle status filter change
|
||||
// const handleStatusFilterChange = useCallback(
|
||||
// (newStatus: string) => {
|
||||
// const params = new URLSearchParams();
|
||||
// params.set('page', '1');
|
||||
// if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||
// if (searchQuery) params.set('search', searchQuery);
|
||||
// if (newStatus !== 'all') params.set('status', newStatus);
|
||||
// setSearchParams(params);
|
||||
// },
|
||||
// [setSearchParams, perPage, searchQuery]
|
||||
// );
|
||||
|
||||
// Handle modal
|
||||
const handleShowSubmissionModal = useCallback(
|
||||
(submission: SubmissionType) => {
|
||||
setSelectedSubmission(submission);
|
||||
@@ -158,12 +138,10 @@ export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
||||
setSelectedSubmission(null);
|
||||
}, []);
|
||||
|
||||
// Get submissions data
|
||||
const filteredData = useMemo(() => {
|
||||
return submissionsResponse?.data || [];
|
||||
}, [submissionsResponse]);
|
||||
|
||||
// Memoize columns
|
||||
const columns: ColumnDef<SubmissionType>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -252,7 +230,6 @@ export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||
<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
|
||||
@@ -265,7 +242,6 @@ export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Per Page Dropdown */}
|
||||
<div className="relative">
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
@@ -281,52 +257,16 @@ export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
{/* <div className="relative">
|
||||
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-40 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value={statusFilter}
|
||||
onChange={(e) => handleStatusFilterChange(e.target.value)}
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="submitted">Submitted</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</div> */}
|
||||
{}
|
||||
{
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active filters */}
|
||||
{/* {statusFilter !== 'all' && (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
||||
Status: {statusFilter}
|
||||
<button
|
||||
onClick={() => handleStatusFilterChange('all')}
|
||||
className="text-info-600 hover:text-info-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleStatusFilterChange('all');
|
||||
setGlobalFilter('');
|
||||
}}
|
||||
className="text-sm text-neutral-600"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
)} */}
|
||||
{}
|
||||
{
|
||||
}
|
||||
|
||||
{/* Loading & results */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
||||
@@ -360,7 +300,6 @@ export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Submission Modal */}
|
||||
{selectedSubmission && (
|
||||
<SubmissionModal
|
||||
isOpen={showSubmissionModal}
|
||||
|
||||
-13
@@ -33,7 +33,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
const bannerInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Initialize form data when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (team) {
|
||||
@@ -55,7 +54,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
}
|
||||
}, [isOpen, team]);
|
||||
|
||||
// Check if form has changes
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!formData || !team) return !!formData;
|
||||
return (
|
||||
@@ -68,7 +66,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
);
|
||||
}, [formData, team]);
|
||||
|
||||
// Check if required fields are filled
|
||||
const isFormValid = useMemo(() => {
|
||||
if (!formData) return false;
|
||||
return (
|
||||
@@ -144,7 +141,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
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">
|
||||
@@ -172,7 +168,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6" onClick={() => setShowLogoMenu(false)}>
|
||||
<input
|
||||
type="file"
|
||||
@@ -189,7 +184,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Banner Section */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-neutral-700">
|
||||
Team Banner{' '}
|
||||
@@ -234,7 +228,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logo & Name */}
|
||||
<div className="grid grid-cols-12 gap-4 items-start">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
@@ -305,7 +298,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-neutral-700">
|
||||
Description <span className="text-danger-500">*</span>
|
||||
@@ -319,7 +311,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* City */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-neutral-700">
|
||||
City <span className="text-danger-500">*</span>
|
||||
@@ -336,7 +327,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Visibility */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-neutral-700">
|
||||
Team Visibility
|
||||
@@ -373,7 +363,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Details */}
|
||||
{team && (
|
||||
<div className="space-y-4 border-t border-neutral-200 pt-4">
|
||||
<div className="space-y-2">
|
||||
@@ -429,7 +418,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-6 border-t border-neutral-200">
|
||||
<div>
|
||||
{team && (
|
||||
@@ -463,7 +451,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
||||
</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 p-6">
|
||||
|
||||
+2
-5
@@ -15,7 +15,7 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
|
||||
className = '',
|
||||
showPlaceholder = true,
|
||||
}) => {
|
||||
const aspectRatioClass = 'aspect-[3/1]'; // 3:1 aspect ratio
|
||||
const aspectRatioClass = 'aspect-[3/1]';
|
||||
|
||||
if (!banner && !showPlaceholder) {
|
||||
return null;
|
||||
@@ -35,7 +35,6 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
|
||||
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;
|
||||
@@ -44,11 +43,10 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* 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
|
||||
'hidden'
|
||||
)}
|
||||
>
|
||||
<div className="text-center">
|
||||
@@ -61,7 +59,6 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// No banner - show placeholder
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -46,11 +46,9 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||
useState<TeamType | null>(null);
|
||||
const [globalFilter, setGlobalFilter] = useState(searchQuery);
|
||||
|
||||
// Advanced filtering states
|
||||
const [visibilityFilter, setVisibilityFilter] = useState('all');
|
||||
const [cityFilter, setCityFilter] = useState('all');
|
||||
|
||||
// Fetch teams from API
|
||||
const {
|
||||
data: teamsResponse,
|
||||
isLoading,
|
||||
@@ -70,14 +68,13 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||
per_page: perPage,
|
||||
search: searchQuery || undefined,
|
||||
}),
|
||||
staleTime: 30000, // 30 seconds cache
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||
staleTime: 30000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const totalData = teamsResponse?.meta?.total_data || 0;
|
||||
const totalPages = teamsResponse?.meta?.total_page || 1;
|
||||
|
||||
// Handle page change - update URL query params
|
||||
const handlePageChange = useCallback(
|
||||
(newPage: number) => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -90,19 +87,16 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||
[setSearchParams, perPage, searchQuery]
|
||||
);
|
||||
|
||||
// Validate page number doesn't exceed total pages
|
||||
useEffect(() => {
|
||||
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||
setSearchParams({ page: totalPages.toString() });
|
||||
}
|
||||
}, [currentPage, totalPages, setSearchParams, isLoading]);
|
||||
|
||||
// Sync globalFilter with URL search param on mount
|
||||
useEffect(() => {
|
||||
setGlobalFilter(searchQuery);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Handle search teams
|
||||
const handleSearch = useCallback(() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', '1');
|
||||
@@ -113,7 +107,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||
setSearchParams(params);
|
||||
}, [globalFilter, setSearchParams, perPage]);
|
||||
|
||||
// Handle Enter key press in search input
|
||||
const handleSearchKeyPress = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -123,7 +116,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||
[handleSearch]
|
||||
);
|
||||
|
||||
// Handle per page change
|
||||
const handlePerPageChange = useCallback(
|
||||
(newPerPage: number) => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -135,7 +127,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||
[setSearchParams, searchQuery]
|
||||
);
|
||||
|
||||
// Memoize the callback to prevent recreation
|
||||
const handleShowDetailModal = useCallback((team: TeamType) => {
|
||||
setSelectedTeam(team);
|
||||
setShowDetailModal(true);
|
||||
@@ -154,12 +145,10 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||
setShowNewTeamModal(false);
|
||||
}, []);
|
||||
|
||||
// Get teams data from API response
|
||||
const filteredData = useMemo(() => {
|
||||
return teamsResponse?.data || [];
|
||||
}, [teamsResponse]);
|
||||
|
||||
// Memoize columns to prevent recreation on every render
|
||||
const columns: ColumnDef<TeamType>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -169,7 +158,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||
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
|
||||
@@ -181,7 +169,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||
<TeamOutlined className="text-neutral-400 text-lg" />
|
||||
)}
|
||||
</div>
|
||||
{/* Team Name */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className="font-medium text-neutral-900 truncate max-w-sm"
|
||||
@@ -276,12 +263,9 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||
Team Management
|
||||
</h1>
|
||||
{/* Filters and actions */}
|
||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-3 items-center 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
|
||||
@@ -294,7 +278,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Per Page Dropdown */}
|
||||
<div className="relative">
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
@@ -310,31 +293,15 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Visibility Filter */}
|
||||
{/* <div className="relative">
|
||||
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-40 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
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"
|
||||
/> */}
|
||||
{}
|
||||
{
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Right side - Add Team Button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -348,12 +315,10 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active filters display */}
|
||||
{(visibilityFilter !== 'all' || cityFilter !== '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}
|
||||
@@ -366,7 +331,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||
</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}
|
||||
@@ -379,7 +343,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Clear all filters */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -395,7 +358,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading & results display */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
||||
@@ -427,18 +389,16 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Modals component */}
|
||||
<ModalTeamDetail
|
||||
isOpen={showDetailModal}
|
||||
onClose={handleCloseDetailModal}
|
||||
team={selectedTeam}
|
||||
/>
|
||||
|
||||
{/* New Team Modal */}
|
||||
<ModalTeamDetail
|
||||
isOpen={showNewTeamModal}
|
||||
onClose={handleCloseNewTeamModal}
|
||||
team={null} // null indicates creating new team
|
||||
team={null}
|
||||
/>
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
|
||||
+4
-33
@@ -37,16 +37,13 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
const [showAvatarMenu, setShowAvatarMenu] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Initialize form data when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (user) {
|
||||
// Edit existing user
|
||||
setFormData({ ...user });
|
||||
} else {
|
||||
// Create new user
|
||||
setFormData({
|
||||
id: '', // Will be generated by backend
|
||||
id: '',
|
||||
fullname: '',
|
||||
bio: '',
|
||||
location: '',
|
||||
@@ -60,10 +57,9 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
}
|
||||
}, [isOpen, user]);
|
||||
|
||||
// Check if form has changes
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!formData) return false;
|
||||
if (!user) return true; // New user always has changes
|
||||
if (!user) return true;
|
||||
return (
|
||||
formData.fullname !== user.fullname ||
|
||||
formData.location !== user.location ||
|
||||
@@ -74,7 +70,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
);
|
||||
}, [formData, user]);
|
||||
|
||||
// Check if required fields are filled
|
||||
const isFormValid = useMemo(() => {
|
||||
if (!formData) return false;
|
||||
return formData.fullname?.trim() !== '' && formData.location?.trim() !== '';
|
||||
@@ -99,25 +94,22 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
if (!formData) return;
|
||||
|
||||
if (user) {
|
||||
// Update existing user
|
||||
console.log('Update user data:', formData);
|
||||
} else {
|
||||
// Create new user
|
||||
console.log('Create new user:', formData);
|
||||
}
|
||||
// Here you would typically make an API call to save the data
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (user) {
|
||||
setFormData({ ...user }); // Reset to original for edit mode
|
||||
setFormData({ ...user });
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
if (!user) return; // Can't delete new user
|
||||
if (!user) return;
|
||||
console.log('Delete user:', user.id);
|
||||
setShowDeleteConfirm(false);
|
||||
onClose();
|
||||
@@ -126,19 +118,16 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('Image size must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview URL
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const avatarUrl = e.target?.result as string;
|
||||
@@ -183,7 +172,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Hidden File Input */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
@@ -191,10 +179,8 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="border-b border-neutral-200 px-8 py-6 flex justify-between items-start">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Interactive User Avatar */}
|
||||
<div className="relative group ">
|
||||
<div className="w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden border-2 border-transparent group-hover:border-primary-300 transition-colors">
|
||||
{formData.avatar ? (
|
||||
@@ -208,7 +194,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Avatar Hover Overlay */}
|
||||
<button
|
||||
onClick={() => setShowAvatarMenu(!showAvatarMenu)}
|
||||
className="absolute inset-0 bg-neutral-400 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||
@@ -216,7 +201,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
<CameraOutlined className="text-white text-lg" />
|
||||
</button>
|
||||
|
||||
{/* Avatar Menu Dropdown */}
|
||||
{showAvatarMenu && (
|
||||
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10">
|
||||
<button
|
||||
@@ -269,17 +253,14 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-8" onClick={() => setShowAvatarMenu(false)}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Left Column - Basic Info */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||
Basic Information
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* Full Name - Required */}
|
||||
<div className="flex items-center gap-3">
|
||||
<UserOutlined className="text-neutral-400" />
|
||||
<div className="flex-1">
|
||||
@@ -310,7 +291,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location - Required */}
|
||||
<div className="flex items-center gap-3">
|
||||
<EnvironmentOutlined className="text-neutral-400" />
|
||||
<div className="flex-1">
|
||||
@@ -346,7 +326,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Joined Date - Read Only - Only show for existing users */}
|
||||
{user && (
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarOutlined className="text-neutral-400" />
|
||||
@@ -370,7 +349,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bio Section - Optional */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-3">
|
||||
Bio{' '}
|
||||
@@ -390,9 +368,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Skills & Status */}
|
||||
<div className="space-y-6">
|
||||
{/* Account Status - Enhanced Tab Design */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||
Account Status
|
||||
@@ -448,7 +424,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Skills Section - Optional */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||
Skills & Expertise{' '}
|
||||
@@ -510,7 +485,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Details - Read Only - Only show for existing users */}
|
||||
{user && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||
@@ -547,7 +521,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="border-t border-neutral-200 px-8 py-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -558,7 +531,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
? 'Please fill required fields'
|
||||
: 'No changes made'}
|
||||
</div>
|
||||
{/* Delete Account Button - Only show for existing users */}
|
||||
{user && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
@@ -595,7 +567,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 z-60">
|
||||
<div
|
||||
|
||||
@@ -32,7 +32,6 @@ import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
type UserType = TAdminUserItem;
|
||||
|
||||
// Skills options for filter
|
||||
const skillsOptions = [
|
||||
'Frontend Developer',
|
||||
'Backend Developer',
|
||||
@@ -57,12 +56,10 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
|
||||
const [globalFilter, setGlobalFilter] = useState(searchQuery);
|
||||
|
||||
// Advanced filtering states
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [cityFilter, setCityFilter] = useState('all');
|
||||
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
|
||||
|
||||
// Fetch users from API
|
||||
const {
|
||||
data: usersResponse,
|
||||
isLoading,
|
||||
@@ -82,14 +79,13 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
per_page: perPage,
|
||||
search: searchQuery || undefined,
|
||||
}),
|
||||
staleTime: 30000, // 30 seconds cache
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||
staleTime: 30000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const totalData = usersResponse?.meta?.total_data || 0;
|
||||
const totalPages = usersResponse?.meta?.total_page || 1;
|
||||
|
||||
// Handle page change - update URL query params
|
||||
const handlePageChange = useCallback(
|
||||
(newPage: number) => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -102,19 +98,16 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
[setSearchParams, perPage, searchQuery]
|
||||
);
|
||||
|
||||
// Validate page number doesn't exceed total pages
|
||||
useEffect(() => {
|
||||
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||
setSearchParams({ page: totalPages.toString() });
|
||||
}
|
||||
}, [currentPage, totalPages, setSearchParams, isLoading]);
|
||||
|
||||
// Sync globalFilter with URL search param on mount
|
||||
useEffect(() => {
|
||||
setGlobalFilter(searchQuery);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Handle search users
|
||||
const handleSearch = useCallback(() => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', '1');
|
||||
@@ -125,7 +118,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
setSearchParams(params);
|
||||
}, [globalFilter, setSearchParams, perPage]);
|
||||
|
||||
// Handle Enter key press in search input
|
||||
const handleSearchKeyPress = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -135,7 +127,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
[handleSearch]
|
||||
);
|
||||
|
||||
// Handle per page change
|
||||
const handlePerPageChange = useCallback(
|
||||
(newPerPage: number) => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -147,7 +138,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
[setSearchParams, searchQuery]
|
||||
);
|
||||
|
||||
// Memoize the callback to prevent recreation
|
||||
const handleShowDetailModal = useCallback((user: UserType) => {
|
||||
setSelectedUser(user);
|
||||
setShowDetailModal(true);
|
||||
@@ -166,17 +156,14 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
setShowNewUserModal(false);
|
||||
}, []);
|
||||
|
||||
// Filter data based on current filter states
|
||||
const filteredData = useMemo(() => {
|
||||
const usersData = usersResponse?.data || [];
|
||||
return usersData.filter((user: UserType) => {
|
||||
// Status filter
|
||||
if (statusFilter !== 'all') {
|
||||
const isActive = statusFilter === 'active';
|
||||
if (user.is_active !== isActive) return false;
|
||||
}
|
||||
|
||||
// Skills filter
|
||||
if (skillsFilter.length > 0) {
|
||||
const userSkills = user.skills || [];
|
||||
const hasMatchingSkill = skillsFilter.some((skill) =>
|
||||
@@ -189,7 +176,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
});
|
||||
}, [usersResponse, statusFilter, skillsFilter]);
|
||||
|
||||
// Memoize columns to prevent recreation on every render
|
||||
const columns: ColumnDef<UserType>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -197,7 +183,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
header: 'User',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Avatar */}
|
||||
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
|
||||
{row.original.avatar ? (
|
||||
<img
|
||||
@@ -209,7 +194,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
<UserOutlined className="text-neutral-500 text-lg" />
|
||||
)}
|
||||
</div>
|
||||
{/* Name only */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-neutral-900 truncate">
|
||||
{row.original.fullname}
|
||||
@@ -318,17 +302,8 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
<EditOutlined className="text-sm" />
|
||||
Manage
|
||||
</Button>
|
||||
{/* <Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-sm px-4 py-2"
|
||||
onClick={() => {
|
||||
// Toggle user status - implement later
|
||||
console.log(`Toggle status for ${row.original.fullname}`);
|
||||
}}
|
||||
>
|
||||
{row.original.is_active ? 'Deactivate' : 'Activate'}
|
||||
</Button> */}
|
||||
{
|
||||
}
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
@@ -342,12 +317,9 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||
User Management
|
||||
</h1>
|
||||
{/* Filters and actions */}
|
||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||
{/* Left side - Search & filters */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
{/* Search bar */}
|
||||
<div className="relative">
|
||||
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||
<input
|
||||
@@ -360,7 +332,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Per Page Dropdown */}
|
||||
<div className="relative">
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
@@ -376,70 +347,19 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
{/* <div className="relative">
|
||||
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-36 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div> */}
|
||||
{}
|
||||
{
|
||||
}
|
||||
|
||||
{/* 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">
|
||||
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (
|
||||
e.target.value &&
|
||||
!skillsFilter.includes(e.target.value)
|
||||
) {
|
||||
setSkillsFilter((prev) => [...prev, e.target.value]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">Add Skill Filter</option>
|
||||
{skillsOptions.map((skill) => (
|
||||
<option
|
||||
key={skill}
|
||||
value={skill}
|
||||
disabled={skillsFilter.includes(skill)}
|
||||
>
|
||||
{skill}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div> */}
|
||||
{}
|
||||
{
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Right side - Add User Button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -453,14 +373,12 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active filters display */}
|
||||
{(skillsFilter.length > 0 ||
|
||||
statusFilter !== 'all' ||
|
||||
cityFilter !== 'all') && (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||
|
||||
{/* Status filter badge */}
|
||||
{statusFilter !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
||||
Status: {statusFilter}
|
||||
@@ -473,7 +391,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Location 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}
|
||||
@@ -486,7 +403,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Skills filter badges */}
|
||||
{skillsFilter.map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
@@ -504,7 +420,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Clear all filters */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -521,7 +436,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading & results display */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
||||
@@ -553,18 +467,16 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Modals component */}
|
||||
<ModalUserDetail
|
||||
isOpen={showDetailModal}
|
||||
onClose={handleCloseDetailModal}
|
||||
user={selectedUser}
|
||||
/>
|
||||
|
||||
{/* New User Modal */}
|
||||
<ModalUserDetail
|
||||
isOpen={showNewUserModal}
|
||||
onClose={handleCloseNewUserModal}
|
||||
user={null} // null indicates creating new user
|
||||
user={null}
|
||||
/>
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
|
||||
@@ -13,14 +13,14 @@ export const AppLayout: FC = (): ReactElement => {
|
||||
onClose={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Sticky top header */}
|
||||
|
||||
<header
|
||||
className={
|
||||
'lg:hidden sticky top-0 bg-white border-b border-primary-200 px-4 py-3 flex items-center gap-3 ' +
|
||||
(mobileSidebarOpen ? 'z-0' : 'z-30')
|
||||
}
|
||||
>
|
||||
{/* Mobile menu button (shown on small screens) */}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700 cursor-pointer"
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
// import { zodResolver } from '@hookform/resolvers/zod';
|
||||
// import {
|
||||
// gachaRollItemSchema,
|
||||
// TGachaRollItem
|
||||
// } from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const useItem = (
|
||||
@@ -11,7 +6,6 @@ export const useItem = (
|
||||
initialValues?: any
|
||||
) => {
|
||||
const form = useForm<any>({
|
||||
// resolver: zodResolver(),
|
||||
mode: 'all',
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
@@ -38,8 +32,6 @@ export const useConfirmItem = (
|
||||
) => {
|
||||
const onConfirm = async () => {
|
||||
try {
|
||||
// const result = await actionFunction?.();
|
||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||
toast.success(messages?.success);
|
||||
onClose();
|
||||
resetStep();
|
||||
|
||||
@@ -40,7 +40,6 @@ const items = [
|
||||
'Gelang Karet',
|
||||
];
|
||||
|
||||
// Mock data for transactions
|
||||
const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: 'Nama Lengkap',
|
||||
@@ -190,13 +189,10 @@ export const Components: FC = (): ReactElement => {
|
||||
return (
|
||||
<Fragment>
|
||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||
{/* Header */}
|
||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||
<h1 className="text-p2 font-semibold">Data Pengiriman Hadiah</h1>
|
||||
</header>
|
||||
{/* Account Table Section */}
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
{/* Search and Filter */}
|
||||
<div className="flex justify-between items-center gap-8 mb-2">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
@@ -224,19 +220,16 @@ export const Components: FC = (): ReactElement => {
|
||||
onClose={() => setShowFilter(false)}
|
||||
onFilterChange={(value) => {
|
||||
console.log('Selected filter:', value);
|
||||
// Filter logic di sini
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Table */}
|
||||
<DataTable data={mockData} columns={columns} table={table} />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Modal Process Delivery */}
|
||||
<ModalProcessDelivery
|
||||
isOpen={showModalProcessDelivery}
|
||||
onClose={() => setShowModalProcessDelivery(false)}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
// import { zodResolver } from '@hookform/resolvers/zod';
|
||||
// import {
|
||||
// gachaRollItemSchema,
|
||||
// TGachaRollItem
|
||||
// } from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const useItem = (
|
||||
@@ -11,7 +6,6 @@ export const useItem = (
|
||||
initialValues?: any
|
||||
) => {
|
||||
const form = useForm<any>({
|
||||
// resolver: zodResolver(),
|
||||
mode: 'all',
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
@@ -38,8 +32,6 @@ export const useConfirmItem = (
|
||||
) => {
|
||||
const onConfirm = async () => {
|
||||
try {
|
||||
// const result = await actionFunction?.();
|
||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
||||
toast.success(messages?.success);
|
||||
onClose();
|
||||
resetStep();
|
||||
|
||||
@@ -27,7 +27,6 @@ interface Transaction {
|
||||
status: TransactionStatus;
|
||||
}
|
||||
|
||||
// Mock data for transactions
|
||||
const mockTransactions: Transaction[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
|
||||
@@ -149,14 +148,11 @@ export const Components: FC = (): ReactElement => {
|
||||
return (
|
||||
<Fragment>
|
||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||
{/* Header */}
|
||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||
<h1 className="text-p2 font-semibold">Validasi Transaksi</h1>
|
||||
</header>
|
||||
|
||||
{/* Account Table Section */}
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
{/* Search and Filter */}
|
||||
<div className="flex justify-between items-center gap-8 mb-2">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
@@ -185,7 +181,6 @@ export const Components: FC = (): ReactElement => {
|
||||
onClose={() => setShowFilter(false)}
|
||||
onFilterChange={(value) => {
|
||||
console.log('Selected filter:', value);
|
||||
// Filter logic di sini
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -193,7 +188,6 @@ export const Components: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<DataTable data={mockTransactions} columns={columns} table={table} />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -24,12 +24,10 @@ export const CityFilterSelect: FC<CityFilterSelectProps> = ({
|
||||
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 (
|
||||
@@ -102,7 +100,6 @@ export const CityFilterSelect: FC<CityFilterSelectProps> = ({
|
||||
|
||||
{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 ${
|
||||
@@ -114,7 +111,6 @@ export const CityFilterSelect: FC<CityFilterSelectProps> = ({
|
||||
{allOptionLabel}
|
||||
</div>
|
||||
|
||||
{/* Filtered Cities */}
|
||||
{filteredCities.length > 0 ? (
|
||||
<div className="py-1">
|
||||
{filteredCities.slice(0, 100).map((city) => (
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
PERMISSIONS,
|
||||
SessionToken,
|
||||
SessionUser,
|
||||
} from '@imphnen-frontend-service/utils';
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { LoaderFunctionArgs, redirect } from 'react-router';
|
||||
|
||||
const mappingPublicRoutes = [
|
||||
@@ -66,14 +66,6 @@ const mappingRoutePermissions = [
|
||||
},
|
||||
];
|
||||
|
||||
//TODO : Fix this later
|
||||
// const redirectToFirstAccessibleRoute = (userPermissions: string[]) => {
|
||||
// const fallback = mappingRoutePermissions.find((route) =>
|
||||
// route.permissions.some((perm) => userPermissions.includes(perm))
|
||||
// );
|
||||
// return redirect(fallback?.path ?? '/auth/login');
|
||||
// };
|
||||
|
||||
export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
const url = new URL(request.url);
|
||||
const pathname = url.pathname;
|
||||
|
||||
@@ -16,10 +16,6 @@ export default defineConfig(() => ({
|
||||
host: 'localhost',
|
||||
},
|
||||
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
build: {
|
||||
outDir: '../../dist/apps/backoffice',
|
||||
emptyOutDir: true,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Tue, Nov 25, 2025 4:21:27 PM
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Deployment trigger
|
||||
# Updated to deploy circular dependency fixes and auth hooks
|
||||
|
||||
@@ -49,7 +49,7 @@ export const CTASection: FC = () => {
|
||||
initial="hidden"
|
||||
animate={isInView ? "visible" : "hidden"}
|
||||
>
|
||||
{/* Background gradients and shapes */}
|
||||
|
||||
<div className="absolute inset-0">
|
||||
<motion.div
|
||||
className={cn("absolute top-8 -left-14 size-28 bg-gradient-to-tl from-primary-400 to-primary-300 rounded-full",
|
||||
|
||||
@@ -39,7 +39,7 @@ export const FAQSection: FC = () => {
|
||||
|
||||
return (
|
||||
<section ref={ref} className="relative mx-auto py-4 px-8 overflow-x-clip md:py-8 md:px-[60px] lg:px-20 md:pb-8">
|
||||
{/* Background shapes */}
|
||||
|
||||
<div className="absolute inset-0">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { motion } from 'framer-motion'
|
||||
export function HeroSection() {
|
||||
return (
|
||||
<section className="relative w-full z-0 py-20 md:py-28 lg:py-32">
|
||||
{/* Background gradients and shapes */}
|
||||
|
||||
<div className="absolute inset-0 -z-10 overflow-x-clip hidden md:block">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
@@ -70,4 +70,4 @@ export function HeroSection() {
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export const Footer: FC = () => {
|
||||
initial="hidden"
|
||||
animate={isInView ? "visible" : "hidden"}
|
||||
>
|
||||
{/* Mascot */}
|
||||
|
||||
<motion.div
|
||||
className="hidden absolute bottom-0 right-0 size-40 md:block lg:size-72"
|
||||
variants={mascotVariants}
|
||||
|
||||
@@ -75,7 +75,6 @@ const ProfileByIdContent: FC = (): ReactElement => {
|
||||
setIsEditProfileModalOpen(false);
|
||||
};
|
||||
|
||||
// Set isViewOnly to true for this page
|
||||
const isViewOnly = true;
|
||||
|
||||
if (isLoading) {
|
||||
@@ -151,7 +150,6 @@ const ProfileByIdContent: FC = (): ReactElement => {
|
||||
message={notification.message}
|
||||
header="Profile"
|
||||
/>
|
||||
{/* Only render EditProfileModal if not in view-only mode */}
|
||||
{!isViewOnly && (
|
||||
<EditProfileModal
|
||||
isOpen={isEditProfileModalOpen}
|
||||
@@ -163,4 +161,4 @@ const ProfileByIdContent: FC = (): ReactElement => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileByIdPage;
|
||||
export default ProfileByIdPage;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Guard } from '@imphnen-frontend-service/utils';
|
||||
|
||||
interface MentorGuardProps {
|
||||
children: React.ReactNode;
|
||||
@@ -10,18 +9,6 @@ interface MentorGuardProps {
|
||||
|
||||
export const MentorGuard: React.FC<MentorGuardProps> = ({
|
||||
children,
|
||||
fallback = (
|
||||
<div className="p-4 text-center text-red-500">
|
||||
<p>Akses ditolak: Anda tidak memiliki izin untuk mengakses fitur mentor.</p>
|
||||
</div>
|
||||
)
|
||||
}) => {
|
||||
return (
|
||||
<Guard
|
||||
permissions={['mentor', 'admin']}
|
||||
fallback={fallback}
|
||||
>
|
||||
{children}
|
||||
</Guard>
|
||||
);
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
@@ -26,7 +26,6 @@ export const EditProfileModal: FC<EditProfileModalProps> = ({ isOpen, onClose, s
|
||||
const fullname = profileData.fullname ||
|
||||
(profileType === 'mentor' && 'legal_name' in profileData ? profileData.legal_name : '') || '';
|
||||
|
||||
|
||||
const avatar = (profileType === 'user' && 'avatar' in profileData)
|
||||
? profileData.avatar || '/image/testimonial.webp'
|
||||
: '/image/testimonial.webp';
|
||||
@@ -39,7 +38,6 @@ export const EditProfileModal: FC<EditProfileModalProps> = ({ isOpen, onClose, s
|
||||
avatar: (profileType === 'user' && 'avatar' in profileData) ? profileData.avatar || '' : ''
|
||||
});
|
||||
|
||||
|
||||
setPreviewUrl(avatar);
|
||||
} else {
|
||||
|
||||
@@ -60,7 +58,6 @@ export const EditProfileModal: FC<EditProfileModalProps> = ({ isOpen, onClose, s
|
||||
try {
|
||||
setIsUploading(true);
|
||||
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
@@ -68,22 +65,18 @@ export const EditProfileModal: FC<EditProfileModalProps> = ({ isOpen, onClose, s
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
|
||||
const uploadResult = await uploadAvatarMutation.mutateAsync(file);
|
||||
|
||||
console.log('Avatar upload response:', uploadResult);
|
||||
|
||||
|
||||
interface UploadData {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const uploadData = ('data' in uploadResult ? (uploadResult as { data: UploadData }).data : uploadResult as UploadData);
|
||||
|
||||
|
||||
setFormData(prev => ({ ...prev, avatar: uploadData.url || '' }));
|
||||
|
||||
|
||||
setPreviewUrl(uploadData.url || '/image/testimonial.webp');
|
||||
|
||||
} catch (error) {
|
||||
@@ -101,7 +94,6 @@ export const EditProfileModal: FC<EditProfileModalProps> = ({ isOpen, onClose, s
|
||||
try {
|
||||
const updates: Record<string, string> = {};
|
||||
|
||||
|
||||
if (formData.fullname.trim() !== '') {
|
||||
if (profileType === 'user') {
|
||||
updates.fullname = formData.fullname;
|
||||
@@ -110,7 +102,6 @@ export const EditProfileModal: FC<EditProfileModalProps> = ({ isOpen, onClose, s
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (formData.avatar && formData.avatar !== (profileData && 'avatar' in profileData ? profileData.avatar : '')) {
|
||||
updates.avatar = formData.avatar;
|
||||
}
|
||||
@@ -141,7 +132,6 @@ export const EditProfileModal: FC<EditProfileModalProps> = ({ isOpen, onClose, s
|
||||
msg = parsed.message;
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,7 +175,6 @@ export const EditProfileModal: FC<EditProfileModalProps> = ({ isOpen, onClose, s
|
||||
onError={handleImageError}
|
||||
/>
|
||||
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 rounded-full bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-300 flex items-center justify-center">
|
||||
<span className="text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
Change Photo
|
||||
|
||||
@@ -25,7 +25,7 @@ interface Education {
|
||||
|
||||
interface ProfileFormProps {
|
||||
showNotification: (type: 'success' | 'error', title: string, message?: string) => void;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
isViewOnly?: boolean;
|
||||
}
|
||||
|
||||
export const ProfileForm: FC<ProfileFormProps> = ({ showNotification, isViewOnly = false }) => {
|
||||
@@ -161,7 +161,7 @@ export const ProfileForm: FC<ProfileFormProps> = ({ showNotification, isViewOnly
|
||||
}
|
||||
|
||||
const handleProfileUpdate = async (updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto>) => {
|
||||
if (isViewOnly) { // Prevent updates if in view-only mode
|
||||
if (isViewOnly) {
|
||||
showNotification('error', 'Akses Ditolak', 'Anda tidak memiliki izin untuk mengedit profil ini.');
|
||||
return;
|
||||
}
|
||||
@@ -189,7 +189,7 @@ export const ProfileForm: FC<ProfileFormProps> = ({ showNotification, isViewOnly
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
isLoading={isUpdating}
|
||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||
isViewOnly={isViewOnly}
|
||||
/>
|
||||
{}
|
||||
<CvResumeSection
|
||||
@@ -202,7 +202,7 @@ export const ProfileForm: FC<ProfileFormProps> = ({ showNotification, isViewOnly
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
isLoading={isUpdating}
|
||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||
isViewOnly={isViewOnly}
|
||||
/>
|
||||
{}
|
||||
<ExperiencesSection
|
||||
@@ -218,7 +218,7 @@ export const ProfileForm: FC<ProfileFormProps> = ({ showNotification, isViewOnly
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
isLoading={isUpdating}
|
||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||
isViewOnly={isViewOnly}
|
||||
/>
|
||||
|
||||
{}
|
||||
@@ -235,7 +235,7 @@ export const ProfileForm: FC<ProfileFormProps> = ({ showNotification, isViewOnly
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
isLoading={isUpdating}
|
||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||
isViewOnly={isViewOnly}
|
||||
/>
|
||||
|
||||
<NotificationModal
|
||||
@@ -248,4 +248,4 @@ export const ProfileForm: FC<ProfileFormProps> = ({ showNotification, isViewOnly
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useProfile } from '../contexts/profile-context';
|
||||
|
||||
interface ProfileHeaderProps {
|
||||
onEditProfileClick: () => void;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
isViewOnly?: boolean;
|
||||
}
|
||||
|
||||
export const ProfileHeader: FC<ProfileHeaderProps> = ({ onEditProfileClick, isViewOnly = false }) => {
|
||||
@@ -17,7 +17,6 @@ export const ProfileHeader: FC<ProfileHeaderProps> = ({ onEditProfileClick, isVi
|
||||
? profileData.avatar || "/image/testimonial.webp"
|
||||
: "/image/testimonial.webp";
|
||||
|
||||
|
||||
const displayFullname = profileData?.fullname ||
|
||||
(profileType === 'mentor' && profileData && 'legal_name' in profileData
|
||||
? profileData.legal_name
|
||||
@@ -34,7 +33,6 @@ export const ProfileHeader: FC<ProfileHeaderProps> = ({ onEditProfileClick, isVi
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const joinDate = profileData && 'created_at' in profileData
|
||||
? new Date(profileData.created_at).toLocaleDateString('id-ID', {
|
||||
year: 'numeric',
|
||||
@@ -73,7 +71,7 @@ export const ProfileHeader: FC<ProfileHeaderProps> = ({ onEditProfileClick, isVi
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isViewOnly && ( // Conditionally render the button
|
||||
{!isViewOnly && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -112,4 +110,4 @@ export const ProfileHeader: FC<ProfileHeaderProps> = ({ onEditProfileClick, isVi
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,13 +9,12 @@ import { useProfile } from '../contexts/profile-context';
|
||||
|
||||
interface ProfileSidebarProps {
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
isViewOnly?: boolean;
|
||||
}
|
||||
|
||||
export const ProfileSidebar: FC<ProfileSidebarProps> = ({ showNotification, isViewOnly = false }) => {
|
||||
const { profileData, updateProfile, profileType, isLoading, isUpdating } = useProfile();
|
||||
|
||||
|
||||
const getCareerStatus = useCallback(() => {
|
||||
if (!profileData) {
|
||||
return 'Career Status';
|
||||
@@ -114,7 +113,6 @@ export const ProfileSidebar: FC<ProfileSidebarProps> = ({ showNotification, isVi
|
||||
}
|
||||
}, [profileData, profileType, isLoading, isInitialized, getCareerStatus, getEmail, getPhone, getLocation, getSkills, isUpdatingCareerStatus]);
|
||||
|
||||
|
||||
const tryParseJsonMessage = (msg: string): string => {
|
||||
if (msg.trim().startsWith('{') && msg.trim().endsWith('}')) {
|
||||
try {
|
||||
@@ -129,7 +127,6 @@ export const ProfileSidebar: FC<ProfileSidebarProps> = ({ showNotification, isVi
|
||||
return msg;
|
||||
};
|
||||
|
||||
|
||||
const extractApiMessage = (err: unknown): string => {
|
||||
if (typeof err !== 'object' || err === null) return '';
|
||||
|
||||
@@ -147,7 +144,7 @@ export const ProfileSidebar: FC<ProfileSidebarProps> = ({ showNotification, isVi
|
||||
};
|
||||
|
||||
const handleProfileUpdate = async (updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto>) => {
|
||||
if (isViewOnly) { // Prevent updates if in view-only mode
|
||||
if (isViewOnly) {
|
||||
showNotification('error', 'Akses Ditolak', 'Anda tidak memiliki izin untuk mengedit profil ini.');
|
||||
return;
|
||||
}
|
||||
@@ -167,7 +164,7 @@ export const ProfileSidebar: FC<ProfileSidebarProps> = ({ showNotification, isVi
|
||||
<Select
|
||||
value={careerStatus}
|
||||
onChange={async (e) => {
|
||||
if (isUpdatingCareerStatus) return; // Prevent multiple clicks
|
||||
if (isUpdatingCareerStatus) return;
|
||||
const newStatus = e.target.value;
|
||||
console.log('ProfileSidebar: User selected career status:', newStatus);
|
||||
setCareerStatus(newStatus);
|
||||
@@ -190,7 +187,7 @@ export const ProfileSidebar: FC<ProfileSidebarProps> = ({ showNotification, isVi
|
||||
}
|
||||
}}
|
||||
className="w-full min-w-[200px]"
|
||||
disabled={isUpdatingCareerStatus || isViewOnly} // Disable if in view-only mode
|
||||
disabled={isUpdatingCareerStatus || isViewOnly}
|
||||
>
|
||||
<option value="Career Status">Career Status</option>
|
||||
<option value="Student">Student</option>
|
||||
@@ -214,7 +211,7 @@ export const ProfileSidebar: FC<ProfileSidebarProps> = ({ showNotification, isVi
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
isLoading={isUpdating}
|
||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||
isViewOnly={isViewOnly}
|
||||
/>
|
||||
|
||||
<SkillsSection
|
||||
@@ -232,8 +229,8 @@ export const ProfileSidebar: FC<ProfileSidebarProps> = ({ showNotification, isVi
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
isLoading={isUpdating}
|
||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||
isViewOnly={isViewOnly}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,14 +6,13 @@ import { SectionWrapper } from '../shared/section-wrapper';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||
|
||||
|
||||
interface CvResumeSectionProps {
|
||||
initialFileName: string;
|
||||
fullname: string;
|
||||
onSave: (cvData: { fileName: string; fileUrl?: string }) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
isViewOnly?: boolean;
|
||||
}
|
||||
|
||||
export const CvResumeSection: FC<CvResumeSectionProps> = ({
|
||||
@@ -22,7 +21,7 @@ export const CvResumeSection: FC<CvResumeSectionProps> = ({
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading = false,
|
||||
isViewOnly = false, // Default to false
|
||||
isViewOnly = false,
|
||||
}) => {
|
||||
const [isCVModalOpen, setIsCVModalOpen] = useState(false);
|
||||
const [fileName, setFileName] = useState(initialFileName);
|
||||
@@ -33,11 +32,10 @@ export const CvResumeSection: FC<CvResumeSectionProps> = ({
|
||||
}, [initialFileName]);
|
||||
|
||||
const handleSave = async (cvData: { fileName: string; fileUrl?: string }) => {
|
||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||
if (isViewOnly) return;
|
||||
await onSave(cvData);
|
||||
};
|
||||
|
||||
|
||||
let displayFileName = 'Belum ada CV';
|
||||
let fileUrl = '';
|
||||
if (fileName) {
|
||||
@@ -65,7 +63,7 @@ export const CvResumeSection: FC<CvResumeSectionProps> = ({
|
||||
<SectionWrapper
|
||||
title="CV/Resume"
|
||||
editButton={
|
||||
!isViewOnly ? ( // Conditionally render the edit button
|
||||
!isViewOnly ? (
|
||||
<EditSectionButton
|
||||
onClick={() => setIsCVModalOpen(true)}
|
||||
disabled={isLoading}
|
||||
@@ -94,7 +92,7 @@ export const CvResumeSection: FC<CvResumeSectionProps> = ({
|
||||
</div>
|
||||
|
||||
<CVModal
|
||||
isOpen={isCVModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||
isOpen={isCVModalOpen && !isViewOnly}
|
||||
onClose={() => setIsCVModalOpen(false)}
|
||||
initialValue={{ fileName, fileUrl: fileName }}
|
||||
onSave={handleSave}
|
||||
@@ -102,4 +100,4 @@ export const CvResumeSection: FC<CvResumeSectionProps> = ({
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
+6
-6
@@ -9,7 +9,7 @@ interface DescriptionSectionProps {
|
||||
onSave: (newDescription: string) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
isViewOnly?: boolean;
|
||||
}
|
||||
|
||||
export const DescriptionSection: FC<DescriptionSectionProps> = ({
|
||||
@@ -17,7 +17,7 @@ export const DescriptionSection: FC<DescriptionSectionProps> = ({
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading = false,
|
||||
isViewOnly = false, // Default to false
|
||||
isViewOnly = false,
|
||||
}) => {
|
||||
const [isDescriptionModalOpen, setIsDescriptionModalOpen] = useState(false);
|
||||
const [description, setDescription] = useState(initialDescription);
|
||||
@@ -28,7 +28,7 @@ export const DescriptionSection: FC<DescriptionSectionProps> = ({
|
||||
}, [initialDescription]);
|
||||
|
||||
const handleSave = async (newDescription: string) => {
|
||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||
if (isViewOnly) return;
|
||||
await onSave(newDescription);
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ export const DescriptionSection: FC<DescriptionSectionProps> = ({
|
||||
<SectionWrapper
|
||||
title="Description"
|
||||
editButton={
|
||||
!isViewOnly ? ( // Conditionally render the edit button
|
||||
!isViewOnly ? (
|
||||
<EditSectionButton
|
||||
onClick={() => setIsDescriptionModalOpen(true)}
|
||||
disabled={isLoading}
|
||||
@@ -50,7 +50,7 @@ export const DescriptionSection: FC<DescriptionSectionProps> = ({
|
||||
</div>
|
||||
|
||||
<DescriptionModal
|
||||
isOpen={isDescriptionModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||
isOpen={isDescriptionModalOpen && !isViewOnly}
|
||||
onClose={() => setIsDescriptionModalOpen(false)}
|
||||
initialValue={description}
|
||||
onSave={handleSave}
|
||||
@@ -58,4 +58,4 @@ export const DescriptionSection: FC<DescriptionSectionProps> = ({
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ interface EducationSectionProps {
|
||||
onSave: (newEducation: Education[]) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
isViewOnly?: boolean;
|
||||
}
|
||||
|
||||
export const EducationSection: FC<EducationSectionProps> = ({
|
||||
@@ -25,7 +25,7 @@ export const EducationSection: FC<EducationSectionProps> = ({
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading = false,
|
||||
isViewOnly = false, // Default to false
|
||||
isViewOnly = false,
|
||||
}) => {
|
||||
const [isEducationModalOpen, setIsEducationModalOpen] = useState(false);
|
||||
const [education, setEducation] = useState<Education[]>(initialEducation);
|
||||
@@ -36,7 +36,7 @@ export const EducationSection: FC<EducationSectionProps> = ({
|
||||
}, [initialEducation]);
|
||||
|
||||
const handleSave = async (newEducation: Education[]) => {
|
||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||
if (isViewOnly) return;
|
||||
await onSave(newEducation);
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ export const EducationSection: FC<EducationSectionProps> = ({
|
||||
<SectionWrapper
|
||||
title="Education"
|
||||
editButton={
|
||||
!isViewOnly ? ( // Conditionally render the edit button
|
||||
!isViewOnly ? (
|
||||
<div className="flex gap-2">
|
||||
<EditSectionButton
|
||||
onClick={() => setIsEducationModalOpen(true)}
|
||||
@@ -73,7 +73,7 @@ export const EducationSection: FC<EducationSectionProps> = ({
|
||||
</div>
|
||||
|
||||
<EducationModal
|
||||
isOpen={isEducationModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||
isOpen={isEducationModalOpen && !isViewOnly}
|
||||
onClose={() => setIsEducationModalOpen(false)}
|
||||
initialValue={education}
|
||||
onSave={handleSave}
|
||||
@@ -82,4 +82,4 @@ export const EducationSection: FC<EducationSectionProps> = ({
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
+6
-6
@@ -17,7 +17,7 @@ interface ExperiencesSectionProps {
|
||||
onSave: (newExperiences: Experience[]) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
isViewOnly?: boolean;
|
||||
}
|
||||
|
||||
export const ExperiencesSection: FC<ExperiencesSectionProps> = ({
|
||||
@@ -25,7 +25,7 @@ export const ExperiencesSection: FC<ExperiencesSectionProps> = ({
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading = false,
|
||||
isViewOnly = false, // Default to false
|
||||
isViewOnly = false,
|
||||
}) => {
|
||||
const [isExperienceModalOpen, setIsExperienceModalOpen] = useState(false);
|
||||
const [experiences, setExperiences] = useState<Experience[]>(initialExperiences);
|
||||
@@ -36,7 +36,7 @@ export const ExperiencesSection: FC<ExperiencesSectionProps> = ({
|
||||
}, [initialExperiences]);
|
||||
|
||||
const handleSave = async (newExperiences: Experience[]) => {
|
||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||
if (isViewOnly) return;
|
||||
await onSave(newExperiences);
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ export const ExperiencesSection: FC<ExperiencesSectionProps> = ({
|
||||
<SectionWrapper
|
||||
title="Experiences"
|
||||
editButton={
|
||||
!isViewOnly ? ( // Conditionally render the edit button
|
||||
!isViewOnly ? (
|
||||
<div className="flex gap-2">
|
||||
<EditSectionButton
|
||||
onClick={() => setIsExperienceModalOpen(true)}
|
||||
@@ -73,7 +73,7 @@ export const ExperiencesSection: FC<ExperiencesSectionProps> = ({
|
||||
</div>
|
||||
|
||||
<ExperienceModal
|
||||
isOpen={isExperienceModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||
isOpen={isExperienceModalOpen && !isViewOnly}
|
||||
onClose={() => setIsExperienceModalOpen(false)}
|
||||
initialValue={experiences}
|
||||
onSave={handleSave}
|
||||
@@ -82,4 +82,4 @@ export const ExperiencesSection: FC<ExperiencesSectionProps> = ({
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
+6
-7
@@ -16,7 +16,7 @@ interface PersonalInfoSectionProps {
|
||||
onSave: (newContactInfo: PersonalInfo) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
isViewOnly?: boolean;
|
||||
}
|
||||
|
||||
export const PersonalInfoSection: FC<PersonalInfoSectionProps> = ({
|
||||
@@ -24,18 +24,17 @@ export const PersonalInfoSection: FC<PersonalInfoSectionProps> = ({
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading = false,
|
||||
isViewOnly = false, // Default to false
|
||||
isViewOnly = false,
|
||||
}) => {
|
||||
const [isPersonalInfoModalOpen, setIsPersonalInfoModalOpen] = useState(false);
|
||||
const [personalInfo, setPersonalInfo] = useState<PersonalInfo>(initialContactInfo);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setPersonalInfo(initialContactInfo);
|
||||
}, [initialContactInfo]);
|
||||
|
||||
const handleSave = async (newInfo: { email: string; phone: string; location: string }) => {
|
||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||
if (isViewOnly) return;
|
||||
const newPersonalInfo = { email: newInfo.email, phone: newInfo.phone, location: newInfo.location };
|
||||
|
||||
await onSave(newPersonalInfo);
|
||||
@@ -45,7 +44,7 @@ export const PersonalInfoSection: FC<PersonalInfoSectionProps> = ({
|
||||
<SectionWrapper
|
||||
title="Personal Informations"
|
||||
editButton={
|
||||
!isViewOnly ? ( // Conditionally render the edit button
|
||||
!isViewOnly ? (
|
||||
<EditSectionButton
|
||||
onClick={() => setIsPersonalInfoModalOpen(true)}
|
||||
disabled={isLoading}
|
||||
@@ -87,7 +86,7 @@ export const PersonalInfoSection: FC<PersonalInfoSectionProps> = ({
|
||||
</div>
|
||||
|
||||
<PersonalInfoModal
|
||||
isOpen={isPersonalInfoModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||
isOpen={isPersonalInfoModalOpen && !isViewOnly}
|
||||
onClose={() => setIsPersonalInfoModalOpen(false)}
|
||||
initialValue={personalInfo}
|
||||
onSave={handleSave}
|
||||
@@ -95,4 +94,4 @@ export const PersonalInfoSection: FC<PersonalInfoSectionProps> = ({
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ interface SkillsSectionProps {
|
||||
onSave: (newSkills: string[]) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
isViewOnly?: boolean;
|
||||
}
|
||||
|
||||
export const SkillsSection: FC<SkillsSectionProps> = ({
|
||||
@@ -22,18 +22,17 @@ export const SkillsSection: FC<SkillsSectionProps> = ({
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading = false,
|
||||
isViewOnly = false, // Default to false
|
||||
isViewOnly = false,
|
||||
}) => {
|
||||
const [isSkillsModalOpen, setIsSkillsModalOpen] = useState(false);
|
||||
const [skills, setSkills] = useState<string[]>(initialSkills);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setSkills(initialSkills);
|
||||
}, [initialSkills]);
|
||||
|
||||
const handleSave = async (newSkills: Skill[]) => {
|
||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||
if (isViewOnly) return;
|
||||
const stringSkills = newSkills.map(skill => skill.name);
|
||||
|
||||
await onSave(stringSkills);
|
||||
@@ -43,7 +42,7 @@ export const SkillsSection: FC<SkillsSectionProps> = ({
|
||||
<SectionWrapper
|
||||
title="Skills"
|
||||
editButton={
|
||||
!isViewOnly ? ( // Conditionally render the edit button
|
||||
!isViewOnly ? (
|
||||
<EditSectionButton
|
||||
onClick={() => setIsSkillsModalOpen(true)}
|
||||
disabled={isLoading}
|
||||
@@ -64,7 +63,7 @@ export const SkillsSection: FC<SkillsSectionProps> = ({
|
||||
</div>
|
||||
|
||||
<SkillsModal
|
||||
isOpen={isSkillsModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||
isOpen={isSkillsModalOpen && !isViewOnly}
|
||||
onClose={() => setIsSkillsModalOpen(false)}
|
||||
initialValue={skills.map(skill => ({ id: skill, name: skill }))}
|
||||
onSave={handleSave}
|
||||
@@ -72,4 +71,4 @@ export const SkillsSection: FC<SkillsSectionProps> = ({
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -78,7 +78,6 @@ export const useGoogleLogin = () => {
|
||||
const user = payload.user;
|
||||
|
||||
if (accessToken && refreshToken && user && typeof accessToken === 'string' && typeof refreshToken === 'string') {
|
||||
// Convert Google user data to match TUserItem structure
|
||||
const convertedUser = {
|
||||
id: user.id,
|
||||
avatar: user.avatar || '',
|
||||
@@ -97,7 +96,6 @@ export const useGoogleLogin = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Use setSession like credential login does
|
||||
setSession({
|
||||
token: {
|
||||
access_token: accessToken,
|
||||
@@ -107,7 +105,7 @@ export const useGoogleLogin = () => {
|
||||
});
|
||||
|
||||
toast.success('Login berhasil!');
|
||||
navigate(0); // Same as credential login
|
||||
navigate(0);
|
||||
} else {
|
||||
toast.error('Data login tidak lengkap');
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
authLoginSchema,
|
||||
TLoginRequest,
|
||||
usePostLogin,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useSession } from '@imphnen-frontend-service/utils';
|
||||
|
||||
export const useLogin = () => {
|
||||
const form = useForm<TLoginRequest>({
|
||||
@@ -12,13 +12,13 @@ export const useLogin = () => {
|
||||
mode: 'all',
|
||||
});
|
||||
|
||||
const { signIn, isLoading } = useSession();
|
||||
const loginMutation = usePostLogin();
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => signIn(data));
|
||||
const onSubmit = form.handleSubmit((data) => loginMutation.mutate(data));
|
||||
|
||||
return {
|
||||
form,
|
||||
onSubmit,
|
||||
isLoading
|
||||
isLoading: loginMutation.isPending,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
TVerifyOtpRequest,
|
||||
verifyEmailSchema,
|
||||
usePostVerifyEmail,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useOtp } from '@imphnen-frontend-service/utils';
|
||||
|
||||
export const useOtpHook = () => {
|
||||
const form = useForm<TVerifyOtpRequest>({
|
||||
@@ -12,13 +12,13 @@ export const useOtpHook = () => {
|
||||
mode: 'all',
|
||||
});
|
||||
|
||||
const { otp, isLoading } = useOtp();
|
||||
const { mutate, isPending: isLoading } = usePostVerifyEmail();
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => otp(data));
|
||||
const onSubmit = form.handleSubmit(() => mutate());
|
||||
|
||||
return {
|
||||
form,
|
||||
onSubmit,
|
||||
isLoading
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
authRegisterSchema,
|
||||
TRegisterRequest,
|
||||
usePostRegister,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useRegister } from '@imphnen-frontend-service/utils';
|
||||
|
||||
export const useRegisterHook = () => {
|
||||
const form = useForm<TRegisterRequest>({
|
||||
@@ -12,13 +12,19 @@ export const useRegisterHook = () => {
|
||||
mode: 'all',
|
||||
});
|
||||
|
||||
const { register, isLoading } = useRegister();
|
||||
const { mutate: register, isPending: isLoading } = usePostRegister();
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => register(data));
|
||||
const onSubmit = form.handleSubmit((data) =>
|
||||
register({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
fullname: data.fullname,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
form,
|
||||
onSubmit,
|
||||
isLoading
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useSendOTP } from '@imphnen-frontend-service/utils';
|
||||
import { usePostSendOtp } from '@imphnen-frontend-service/service';
|
||||
|
||||
export const useResendOtpHook = () => {
|
||||
|
||||
const { resendOTP, isLoading } = useSendOTP();
|
||||
const { mutate, isPending: isLoading } = usePostSendOtp();
|
||||
|
||||
const resendOTP = (_data?: { email: string }) => {
|
||||
mutate();
|
||||
};
|
||||
|
||||
return {
|
||||
resendOTP
|
||||
resendOTP,
|
||||
isLoading
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -179,7 +179,6 @@ export const GoogleOAuthPopupPage: FC = (): ReactElement => {
|
||||
setTimeout(checkForJson, 1000);
|
||||
};
|
||||
|
||||
// Small delay to ensure DOM is ready
|
||||
setTimeout(handleOAuthResponse, 100);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
PERMISSIONS,
|
||||
SessionToken,
|
||||
SessionUser,
|
||||
} from '@imphnen-frontend-service/utils';
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { LoaderFunctionArgs, redirect } from 'react-router';
|
||||
|
||||
const mappingPublicRoutes = [
|
||||
@@ -80,14 +80,6 @@ const mappingPublicPrefixRoutes = [
|
||||
'/articles',
|
||||
]
|
||||
|
||||
//TODO : Fix this later
|
||||
// const redirectToFirstAccessibleRoute = (userPermissions: string[]) => {
|
||||
// const fallback = mappingRoutePermissions.find((route) =>
|
||||
// route.permissions.some((perm) => userPermissions.includes(perm))
|
||||
// );
|
||||
// return redirect(fallback?.path ?? '/auth/login');
|
||||
// };
|
||||
|
||||
export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
const url = new URL(request.url);
|
||||
const pathname = url.pathname;
|
||||
@@ -97,9 +89,6 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
const userPermissions =
|
||||
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
||||
|
||||
// Allow to access the landing page without authentication
|
||||
// So, if the route prefix is in the mappingPublicPrefixRoutes, we return null
|
||||
// to indicate that we don't need to authenticate the user
|
||||
if (mappingPublicPrefixRoutes.some((prefix) => pathname.startsWith(prefix))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -16,10 +16,6 @@ export default defineConfig(() => ({
|
||||
host: 'localhost',
|
||||
},
|
||||
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
build: {
|
||||
outDir: '../../dist/apps/dimentorin',
|
||||
emptyOutDir: true,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Tue, Nov 25, 2025 4:21:27 PM
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Deployment trigger
|
||||
# Updated to deploy circular dependency fixes and auth hooks
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
// import { SessionToken, SessionUser } from '@imphnen-frontend-service/utils';
|
||||
import { LoaderFunctionArgs } from 'react-router';
|
||||
|
||||
// const mappingPublicRoutes = ['/'];
|
||||
|
||||
// const mappingRoutePermissions = [
|
||||
// {
|
||||
// path: '/',
|
||||
// permissions: [],
|
||||
// },
|
||||
// ];
|
||||
|
||||
export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -16,10 +16,6 @@ export default defineConfig(() => ({
|
||||
host: 'localhost',
|
||||
},
|
||||
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
build: {
|
||||
outDir: '../../dist/apps/gacha',
|
||||
emptyOutDir: true,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Tue, Nov 25, 2025 16:42:27 PM
|
||||
|
||||
@@ -18,56 +18,45 @@ const CallbackPage: FC = (): ReactElement => {
|
||||
hasRunRef.current = true;
|
||||
|
||||
try {
|
||||
// Check URL hash for Supabase email confirmation callback
|
||||
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
|
||||
const urlParams = new URLSearchParams(globalThis.location.search);
|
||||
|
||||
const type = hashParams.get('type') || urlParams.get('type');
|
||||
const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
|
||||
|
||||
// Debug: log what we received
|
||||
console.log('[Callback] Params:', { type, accessToken: !!accessToken, hash: globalThis.location.hash, search: globalThis.location.search });
|
||||
|
||||
// Handle Supabase email callbacks (has access_token in hash or query)
|
||||
// This includes: signup confirmation, email confirmation, password recovery
|
||||
if (accessToken) {
|
||||
setIsProcessing(false);
|
||||
|
||||
// Password recovery - type is 'recovery' or we have access_token from reset email
|
||||
if (type === 'recovery' || type === 'magiclink') {
|
||||
toast.success('Email verified! Please set your new password.');
|
||||
navigate('/auth/reset-password?access_token=' + accessToken);
|
||||
return;
|
||||
}
|
||||
|
||||
// Signup/Email confirmation
|
||||
if (type === 'signup' || type === 'email_confirmation') {
|
||||
toast.success('Email verified successfully! Please log in to continue.');
|
||||
navigate('/auth/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have access_token but unknown type, assume it's password recovery
|
||||
// (Supabase sometimes sends without explicit type)
|
||||
toast.success('Email verified! Please set your new password.');
|
||||
navigate('/auth/reset-password?access_token=' + accessToken);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the code from URL query params (GitHub OAuth)
|
||||
const code = urlParams.get('code');
|
||||
|
||||
if (!code) {
|
||||
throw new Error('No authorization code received');
|
||||
}
|
||||
|
||||
// Exchange the code for tokens using backend API (GitHub OAuth)
|
||||
const result = await exchangeGitHubCode({ code });
|
||||
|
||||
toast.success('Login successful!');
|
||||
setIsProcessing(false);
|
||||
|
||||
// Check if user has completed onboarding (has location)
|
||||
if (result.user.location) {
|
||||
globalThis.location.replace('/dashboard');
|
||||
} else {
|
||||
@@ -90,7 +79,6 @@ const CallbackPage: FC = (): ReactElement => {
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
// Check if error is related to private email
|
||||
const isPrivateEmailError =
|
||||
error.toLowerCase().includes('failed to create user') ||
|
||||
error.toLowerCase().includes('email') ||
|
||||
|
||||
@@ -19,7 +19,6 @@ export default function LoginPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// Check for password reset tokens in URL and redirect to reset-password page
|
||||
useEffect(() => {
|
||||
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
|
||||
const urlParams = new URLSearchParams(globalThis.location.search);
|
||||
@@ -27,11 +26,9 @@ export default function LoginPage() {
|
||||
const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
|
||||
const type = hashParams.get('type') || urlParams.get('type');
|
||||
|
||||
// If we have an access_token, this is likely a password reset redirect that landed on the wrong page
|
||||
if (accessToken) {
|
||||
console.log('[Login] Detected access_token, redirecting to reset-password page');
|
||||
|
||||
// Check if it's a password recovery
|
||||
if (type === 'recovery' || type === 'magiclink' || !type) {
|
||||
toast.info('Redirecting to password reset...');
|
||||
navigate('/auth/reset-password?access_token=' + accessToken);
|
||||
@@ -53,7 +50,6 @@ export default function LoginPage() {
|
||||
|
||||
toast.success('Login successful!');
|
||||
|
||||
// Redirect based on onboarding status
|
||||
if (result.user.location) {
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
@@ -71,7 +67,6 @@ export default function LoginPage() {
|
||||
|
||||
const result = await signInWithGitHub();
|
||||
|
||||
// Check if we got a redirect URL
|
||||
if (result?.url) {
|
||||
globalThis.location.href = result.url;
|
||||
} else {
|
||||
|
||||
@@ -15,8 +15,6 @@ export default function ResetPasswordPage() {
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Get the access_token from URL hash (Supabase sends it as hash fragment)
|
||||
// or from query params (when redirected from callback page)
|
||||
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
|
||||
const queryParams = new URLSearchParams(globalThis.location.search);
|
||||
const token = hashParams.get('access_token') || queryParams.get('access_token');
|
||||
@@ -55,7 +53,6 @@ export default function ResetPasswordPage() {
|
||||
|
||||
toast.success('Password updated successfully!');
|
||||
|
||||
// Clear session and redirect to login
|
||||
clearSession();
|
||||
navigate('/auth/login');
|
||||
} catch (err) {
|
||||
|
||||
@@ -32,13 +32,11 @@ const signupSchema = z
|
||||
|
||||
type SignupFormData = z.infer<typeof signupSchema>;
|
||||
|
||||
// Registration deadline: 2025-11-30 23:29:00 WIB (UTC+7)
|
||||
const REGISTRATION_DEADLINE = new Date('2025-11-30T16:29:00Z');
|
||||
|
||||
export default function SignupPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Check if registration is closed
|
||||
const isRegistrationClosed = new Date() >= REGISTRATION_DEADLINE;
|
||||
const { signInWithGitHub } = useGitHubAuth();
|
||||
const signupMutation = useSignup();
|
||||
@@ -76,7 +74,6 @@ export default function SignupPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Show closed registration screen
|
||||
if (isRegistrationClosed) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
@@ -119,7 +116,6 @@ export default function SignupPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Show success screen after registration
|
||||
if (registrationSuccess) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
|
||||
@@ -41,14 +41,11 @@ const CertificatePage: FC = (): ReactElement => {
|
||||
}
|
||||
}, [certId]);
|
||||
|
||||
// Fetch certificate data using the new endpoint
|
||||
const { data: certificateData, isLoading: isLoadingCertificate } =
|
||||
useCertificatePublicData(decodedInfo?.userId || '', !!decodedInfo?.userId);
|
||||
|
||||
// Generate QR Code
|
||||
useEffect(() => {
|
||||
if (certId) {
|
||||
// Use encodeURIComponent to properly encode the certId for the URL
|
||||
const encodedCertId = encodeURIComponent(certId);
|
||||
const certificateUrl = `${window.location.origin}/certificate/${encodedCertId}`;
|
||||
QRCode.toDataURL(certificateUrl, {
|
||||
@@ -71,13 +68,10 @@ const CertificatePage: FC = (): ReactElement => {
|
||||
|
||||
const isLoading = (!decodedInfo && !error) || isLoadingCertificate;
|
||||
|
||||
// Certificate name from the user data
|
||||
const certificateName = certificateUser?.fullname;
|
||||
|
||||
// Check if current user is viewing their own certificate (team member)
|
||||
const isTeamMember = session?.user?.id === decodedInfo?.userId;
|
||||
|
||||
// Dynamic font sizing: shrink by 2px if height exceeds 80px
|
||||
useEffect(() => {
|
||||
const adjustFontSize = (
|
||||
element: HTMLElement | null,
|
||||
@@ -106,14 +100,12 @@ const CertificatePage: FC = (): ReactElement => {
|
||||
return () => clearTimeout(timer);
|
||||
}, [team?.name, certificateName]);
|
||||
|
||||
// Generate certificate canvas screenshot
|
||||
useEffect(() => {
|
||||
const generateCertificate = async () => {
|
||||
if (!certificateRef.current || !team || !submission || !qrCodeUrl) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
// Wait longer for fonts and images to load properly
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
const canvas = await html2canvas(certificateRef.current, {
|
||||
@@ -141,7 +133,6 @@ const CertificatePage: FC = (): ReactElement => {
|
||||
generateCertificate();
|
||||
}, [team, submission, qrCodeUrl]);
|
||||
|
||||
// Download certificate
|
||||
const handleDownloadCertificate = () => {
|
||||
if (!certificateImage) return;
|
||||
|
||||
@@ -151,7 +142,6 @@ const CertificatePage: FC = (): ReactElement => {
|
||||
link.click();
|
||||
};
|
||||
|
||||
// Print certificate
|
||||
const handlePrintCertificate = () => {
|
||||
if (!certificateImage) return;
|
||||
|
||||
@@ -228,7 +218,6 @@ const CertificatePage: FC = (): ReactElement => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Print Styles */}
|
||||
<style>{`
|
||||
@media print {
|
||||
@page {
|
||||
@@ -267,7 +256,6 @@ const CertificatePage: FC = (): ReactElement => {
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile responsive - zoom out to fit */
|
||||
@media (max-width: 768px) {
|
||||
#certificate-container {
|
||||
transform-origin: top center;
|
||||
@@ -275,7 +263,6 @@ const CertificatePage: FC = (): ReactElement => {
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 no-print">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -299,12 +286,10 @@ const CertificatePage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Certificate Content */}
|
||||
<div
|
||||
className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"
|
||||
id="certificate-wrapper"
|
||||
>
|
||||
{/* Hidden Template for Canvas Generation */}
|
||||
<div
|
||||
className={showTemplate ? 'block' : 'hidden'}
|
||||
style={{ position: 'absolute', left: '-9999px' }}
|
||||
@@ -321,7 +306,6 @@ const CertificatePage: FC = (): ReactElement => {
|
||||
height: `${(1000 * 2480) / 3508}px`,
|
||||
}}
|
||||
>
|
||||
{/* Team Name - positioned in middle between "Diberikan Kepada" and "Telah Berpartisipasi" */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -347,7 +331,6 @@ const CertificatePage: FC = (): ReactElement => {
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* User Name - positioned below team name */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -373,7 +356,6 @@ const CertificatePage: FC = (): ReactElement => {
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* QR Code - positioned in the white box area */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -397,7 +379,6 @@ const CertificatePage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Certificate Image */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 overflow-hidden p-2">
|
||||
{isGenerating && (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
@@ -419,7 +400,6 @@ const CertificatePage: FC = (): ReactElement => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{isTeamMember && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
|
||||
<Button
|
||||
@@ -451,7 +431,6 @@ const CertificatePage: FC = (): ReactElement => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 no-print">
|
||||
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
|
||||
Certificate Information
|
||||
|
||||
@@ -38,17 +38,11 @@ const formatOrdinalRank = (rank: number): string => {
|
||||
}
|
||||
};
|
||||
|
||||
// Keep the template size aligned with the SVG native size (842x595) using an integer multiplier.
|
||||
// This reduces sub-pixel scaling artifacts (blur) on thin lines when rasterizing with html2canvas.
|
||||
const CERT_WIDTH = 842 * 2;
|
||||
const CERT_HEIGHT = 595 * 2;
|
||||
|
||||
// Balance between output sharpness and file size.
|
||||
// Output resolution will be (CERT_WIDTH * EXPORT_SCALE) x (CERT_HEIGHT * EXPORT_SCALE).
|
||||
const EXPORT_SCALE = 2;
|
||||
|
||||
// The original layout was tuned around a ~1000px-wide template.
|
||||
// We keep the same visual proportions by scaling fixed pixel values.
|
||||
const LAYOUT_BASE_WIDTH = 1000;
|
||||
const LAYOUT_SCALE = CERT_WIDTH / LAYOUT_BASE_WIDTH;
|
||||
const s = (px: number) => Math.round(px * LAYOUT_SCALE);
|
||||
@@ -122,7 +116,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
||||
(t) => (t as { id?: string } | null | undefined)?.id === decodedTeamId
|
||||
);
|
||||
|
||||
// Generate QR Code (public link)
|
||||
useEffect(() => {
|
||||
if (!certId) return;
|
||||
|
||||
@@ -141,7 +134,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
||||
.catch((err) => console.error('QR Code generation failed:', err));
|
||||
}, [certId]);
|
||||
|
||||
// Generate certificate canvas screenshot
|
||||
useEffect(() => {
|
||||
const generateCertificate = async () => {
|
||||
if (!certificateRef.current) return;
|
||||
@@ -311,7 +303,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 no-print">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -339,7 +330,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
||||
className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"
|
||||
id="certificate-wrapper"
|
||||
>
|
||||
{/* Hidden Template for Canvas Generation */}
|
||||
<div
|
||||
className={showTemplate ? 'block' : 'hidden'}
|
||||
style={{ position: 'absolute', left: '-9999px' }}
|
||||
@@ -363,7 +353,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Team Name */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -388,7 +377,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Members list */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -416,7 +404,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Award text */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -445,7 +432,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Rank badge */}
|
||||
{!!rankLabel && (
|
||||
<div
|
||||
style={{
|
||||
@@ -476,7 +462,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR Code (same placement as existing certificate page) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -504,7 +489,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Certificate Image */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 overflow-hidden p-2">
|
||||
{isGenerating && (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
@@ -526,7 +510,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{isTeamMember && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
|
||||
<Button
|
||||
@@ -588,7 +571,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 no-print">
|
||||
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
|
||||
Certificate Information
|
||||
|
||||
@@ -10,7 +10,7 @@ const DashboardLayout: FC = (): ReactElement => {
|
||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Mobile Header with Hamburger */}
|
||||
|
||||
<div className="lg:hidden sticky top-0 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 flex items-center">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
|
||||
@@ -13,10 +13,8 @@ import { Icon } from '@iconify/react';
|
||||
import ProfilePage from '../profile/page';
|
||||
import { encodeWinnerCertificateId } from '../../utils/certificate';
|
||||
|
||||
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
||||
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
||||
|
||||
// Submission deadline: 2025-12-07 23:59:00 WIB (UTC+7)
|
||||
const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z');
|
||||
|
||||
type Invitation = {
|
||||
@@ -50,13 +48,10 @@ const DashboardPage: FC = (): ReactElement => {
|
||||
seconds: number;
|
||||
} | null>(null);
|
||||
|
||||
// Check if team features are closed
|
||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||
|
||||
// Check if submission deadline passed
|
||||
const isSubmissionDeadlinePassed = new Date() >= SUBMISSION_DEADLINE;
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
if (isSubmissionDeadlinePassed) return;
|
||||
|
||||
@@ -83,7 +78,6 @@ const DashboardPage: FC = (): ReactElement => {
|
||||
return () => clearInterval(timer);
|
||||
}, [isSubmissionDeadlinePassed]);
|
||||
|
||||
// Lock background scroll when profile modal is open
|
||||
useEffect(() => {
|
||||
if (showProfileModal) {
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
@@ -145,7 +139,6 @@ const DashboardPage: FC = (): ReactElement => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Winner Banner */}
|
||||
{winnerEntry && myTeams.length > 0 && (
|
||||
<div className="mb-8 bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
@@ -176,7 +169,6 @@ const DashboardPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Countdown Timer */}
|
||||
{timeLeft && !isSubmissionDeadlinePassed && (
|
||||
<div className="mb-8 bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuthStore, useUserMe } from '@imphnen-frontend-service/service';
|
||||
|
||||
// Define onboarding routes
|
||||
const ONBOARDING_ROUTES = new Set(['/onboarding/user']);
|
||||
|
||||
export default function RootLayout() {
|
||||
@@ -21,56 +20,46 @@ export default function RootLayout() {
|
||||
const checkAuth = async () => {
|
||||
const pathname = location.pathname;
|
||||
|
||||
// Allow hackathon pages without checks
|
||||
if (pathname.startsWith('/hackathons')) {
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow auth callback without checks
|
||||
if (pathname === '/auth/callback') {
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Public auth pages - allow unauthenticated access
|
||||
if (pathname.startsWith('/auth')) {
|
||||
// If already authenticated and not on password reset pages, redirect to dashboard
|
||||
if (session && pathname !== '/auth/reset-password') {
|
||||
navigate('/dashboard', { replace: true });
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
// Allow unauthenticated access to auth pages
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Home page - allow everyone to view the landing page
|
||||
if (pathname === '/') {
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Certificate page - allow public access
|
||||
if (pathname.startsWith('/certificate/')) {
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Require authentication for all other routes
|
||||
if (!session) {
|
||||
navigate('/auth/login', { replace: true });
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for user data to load before checking onboarding
|
||||
if (isUserLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has completed onboarding (skip for onboarding routes)
|
||||
if (!ONBOARDING_ROUTES.has(pathname)) {
|
||||
const hasLocation = !!userData?.data?.location || !!session?.user?.location;
|
||||
|
||||
@@ -87,7 +76,6 @@ export default function RootLayout() {
|
||||
checkAuth();
|
||||
}, [location.pathname, navigate, session, userData, isUserLoading]);
|
||||
|
||||
// Show loading state while checking auth
|
||||
if (isChecking) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-neutral-950">
|
||||
|
||||
@@ -50,7 +50,6 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
},
|
||||
});
|
||||
|
||||
// Set initial avatar preview from GitHub avatar if available
|
||||
useEffect(() => {
|
||||
if (session?.user?.avatar && !avatarPreview) {
|
||||
setAvatarPreview(session.user.avatar);
|
||||
@@ -60,14 +59,12 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file size (max 2MB)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error('The file is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('The file must be an image');
|
||||
e.target.value = '';
|
||||
@@ -87,13 +84,11 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
try {
|
||||
let avatarUrl = session?.user?.avatar || null;
|
||||
|
||||
// Upload avatar if a new file was selected
|
||||
if (avatarFile) {
|
||||
const uploadResult = await uploadAvatar(avatarFile);
|
||||
avatarUrl = uploadResult.data.url;
|
||||
}
|
||||
|
||||
// Update user in Supabase
|
||||
await updateUser({
|
||||
fullname: data.fullname,
|
||||
avatar: avatarUrl,
|
||||
@@ -102,11 +97,8 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
skills: data.skills,
|
||||
});
|
||||
|
||||
// Wait a bit for the onSuccess handler to update localStorage
|
||||
// The updateUser mutation's onSuccess handler updates the Zustand store and localStorage
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Use window.location for a full page reload to ensure middleware sees updated localStorage
|
||||
globalThis.location.href = '/dashboard';
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
@@ -130,7 +122,6 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
{/* Avatar Upload */}
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="relative">
|
||||
{avatarPreview ? (
|
||||
@@ -169,7 +160,6 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full Name */}
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Full Name"
|
||||
@@ -180,7 +170,6 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
isRequired={true}
|
||||
/>
|
||||
|
||||
{/* City */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-base font-medium text-gray-700 dark:text-gray-300">
|
||||
City <span className="text-red-500">*</span>
|
||||
@@ -199,7 +188,6 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role/Skills */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-base font-medium text-gray-700 dark:text-gray-300">
|
||||
Role / Skills
|
||||
@@ -237,7 +225,6 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bio */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-base font-medium text-gray-700 dark:text-gray-300">
|
||||
Bio{' '}
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function HomePage() {
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-white dark:bg-gray-950">
|
||||
{/* Navigation */}
|
||||
|
||||
<div id="#top" className="hidden"></div>
|
||||
<nav className="border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 sticky top-0 z-50">
|
||||
<div className="flex items-center justify-between max-w-7xl mx-auto px-4 md:px-8 py-4">
|
||||
@@ -82,7 +82,7 @@ export default function HomePage() {
|
||||
Hackathon
|
||||
</a>
|
||||
</div>
|
||||
{/* Desktop Menu */}
|
||||
|
||||
<div className="hidden md:flex text-label1 items-center gap-4 lg:gap-8">
|
||||
<a
|
||||
href="#timeline"
|
||||
@@ -131,7 +131,7 @@ export default function HomePage() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Mobile Menu Button */}
|
||||
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<ThemeToggle />
|
||||
<button
|
||||
@@ -156,7 +156,6 @@ export default function HomePage() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{mobileMenuOpen && (
|
||||
<nav className="md:hidden sticky top-18 border-b border-gray-200 dark:border-gray-800 bg-white z-40 dark:bg-gray-900">
|
||||
<div className="flex flex-col items-start gap-4 px-4 py-4">
|
||||
@@ -205,7 +204,6 @@ export default function HomePage() {
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative w-full overflow-hidden py-20">
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
@@ -219,7 +217,7 @@ export default function HomePage() {
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(59,130,246,0.05)_1px,transparent_1px),linear-gradient(to_right,rgba(59,130,246,0.05)_1px,transparent_1px)] dark:bg-[linear-gradient(rgba(59,130,246,0.1)_1px,transparent_1px),linear-gradient(to_right,rgba(59,130,246,0.1)_1px,transparent_1px)] bg-size-[40px_40px]"></div>
|
||||
</div>
|
||||
<div className="mx-auto container px-4 relative flex flex-col items-center">
|
||||
{/* Logos */}
|
||||
|
||||
<div className="flex items-center gap-4 md:gap-8 lg:gap-12 mb-8 md:mb-12 lg:mb-16 flex-wrap justify-center">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
@@ -239,20 +237,20 @@ export default function HomePage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Title */}
|
||||
|
||||
<h1 className="text-h1 font-bold text-gray-900 dark:text-white mb-4 text-center">
|
||||
Hackathon
|
||||
</h1>
|
||||
{/* Subtitle */}
|
||||
|
||||
<p className="text-p1 text-primary-500 font-semibold mb-6 md:mb-8 text-center px-4">
|
||||
"Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif"
|
||||
</p>
|
||||
{/* Description */}
|
||||
|
||||
<p className="text-p3 text-gray-600 dark:text-gray-200 max-w-lg md:max-w-xl text-center mb-8 md:mb-12 px-4 font-sans">
|
||||
Kompetisi pengembangan teknologi untuk menciptakan solusi inovatif
|
||||
yang menghadirkan dampak nyata
|
||||
</p>
|
||||
{/* Status */}
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 md:gap-8 mb-10 md:mb-16 text-base text-gray-600 dark:text-gray-200">
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<Icon icon="streamline-plump:web" className="w-4 h-4" />
|
||||
@@ -263,7 +261,7 @@ export default function HomePage() {
|
||||
<span>Pendaftaran hingga 30 November 2025</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* CTA Buttons */}
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 px-4">
|
||||
<Button
|
||||
onClick={() => navigate('/auth/signup')}
|
||||
@@ -280,7 +278,7 @@ export default function HomePage() {
|
||||
Gabung Grup WA Hackathon
|
||||
</a>
|
||||
</div>
|
||||
{/* Scroll indicator */}
|
||||
|
||||
<div className="mt-12 md:mt-20">
|
||||
<svg
|
||||
className="w-6 h-6 text-gray-400 dark:text-gray-500 animate-bounce"
|
||||
@@ -299,7 +297,6 @@ export default function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* About Section */}
|
||||
<section className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-gray-950">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h2 className="text-3xl md:text-5xl font-bold mb-10 dark:text-white">
|
||||
@@ -322,7 +319,6 @@ export default function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Prizes Section */}
|
||||
<section
|
||||
id="hadiah"
|
||||
className="py-16 md:py-24 px-4 md:px-8 bg-gray-50 dark:bg-linear-to-b dark:from-gray-950 dark:to-gray-900"
|
||||
@@ -338,7 +334,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{/* Prize 1 */}
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-primary-500 dark:hover:border-primary-500 transition-colors">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="w-16 h-16 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
|
||||
@@ -356,7 +352,6 @@ export default function HomePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Prize 2 */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-gray-500 dark:hover:border-gray-500 transition-colors">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center">
|
||||
@@ -374,7 +369,6 @@ export default function HomePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Prize 3 */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-orange-300 dark:hover:border-orange-500 transition-colors">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="w-16 h-16 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center">
|
||||
@@ -392,7 +386,6 @@ export default function HomePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Special Prize */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-purple-300 dark:hover:border-purple-500 transition-colors">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="w-16 h-16 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
|
||||
@@ -413,7 +406,6 @@ export default function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Timeline Section */}
|
||||
<section
|
||||
id="timeline"
|
||||
className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-gray-950"
|
||||
@@ -429,7 +421,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/* Timeline Item 1 */}
|
||||
|
||||
<div className="flex gap-6">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
|
||||
@@ -448,7 +440,6 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline Item 2 */}
|
||||
<div className="flex gap-6">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
|
||||
@@ -468,7 +459,6 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline Item 3 */}
|
||||
<div className="flex gap-6">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
|
||||
@@ -487,7 +477,6 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline Item 4 */}
|
||||
<div className="flex gap-6">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
|
||||
@@ -506,7 +495,6 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline Item 5 */}
|
||||
<div className="flex gap-6">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
|
||||
@@ -527,7 +515,6 @@ export default function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Judges Section */}
|
||||
<section className="py-16 md:py-24 px-4 md:px-8 bg-gray-50 dark:bg-gray-900 font-sans">
|
||||
<div className="md:max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12 font-bai-jamjuree">
|
||||
@@ -540,7 +527,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md md:max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* Judge 1 */}
|
||||
|
||||
<div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
|
||||
<h3 className="text-p3 font-bold mb-1 dark:text-white">
|
||||
Alifais Farrel Ramdhani
|
||||
@@ -551,7 +538,6 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Judge 2 */}
|
||||
<div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
|
||||
<h3 className="text-p3 font-bold mb-1 dark:text-white">
|
||||
Muhammad Alif Ramadhan
|
||||
@@ -562,7 +548,6 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Judge 3 */}
|
||||
<div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
|
||||
<h3 className="text-p3 font-bold mb-1 dark:text-white">
|
||||
Hafid Nur
|
||||
@@ -576,7 +561,6 @@ export default function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<section
|
||||
id="faq"
|
||||
className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-gray-950"
|
||||
@@ -631,7 +615,6 @@ export default function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Sponsors Section */}
|
||||
<section className="py-20 md:py-28 px-4 md:px-8 bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-w-6xl mx-auto text-center">
|
||||
<h2 className="text-3xl md:text-5xl font-bold mb-4 dark:text-white">
|
||||
@@ -658,7 +641,6 @@ export default function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section
|
||||
id="masuk"
|
||||
className="py-20 md:py-28 px-4 md:px-8 bg-linear-to-b from-white to-blue-50 dark:from-gray-950 dark:to-gray-900"
|
||||
@@ -700,11 +682,10 @@ export default function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-950 text-white py-12 px-4 md:px-8 font-sans">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
|
||||
{/* Brand */}
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4 font-bai-jamjuree">
|
||||
<span className="text-2xl font-bold">IMPHNEN</span>
|
||||
@@ -768,7 +749,6 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div>
|
||||
<h3 className="font-bold text-lg mb-4 font-bai-jamjuree">
|
||||
Quick Links
|
||||
@@ -806,7 +786,6 @@ export default function HomePage() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div>
|
||||
<h3 className="font-bold text-lg mb-4 font-bai-jamjuree">
|
||||
Contact
|
||||
|
||||
@@ -54,7 +54,6 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
||||
},
|
||||
});
|
||||
|
||||
// Set initial avatar preview from current user avatar
|
||||
useEffect(() => {
|
||||
if (session?.user?.avatar && !avatarPreview) {
|
||||
setAvatarPreview(session.user.avatar);
|
||||
@@ -84,14 +83,12 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file size (max 2MB)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error('The file is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('The file must be an image');
|
||||
e.target.value = '';
|
||||
@@ -111,13 +108,11 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
||||
try {
|
||||
let avatarUrl = session?.user?.avatar || null;
|
||||
|
||||
// Upload avatar if a new file was selected
|
||||
if (avatarFile) {
|
||||
const uploadResult = await uploadAvatar(avatarFile);
|
||||
avatarUrl = uploadResult.data.url;
|
||||
}
|
||||
|
||||
// Update user profile
|
||||
await updateUser({
|
||||
fullname: data.fullname,
|
||||
avatar: avatarUrl,
|
||||
@@ -128,10 +123,8 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
||||
|
||||
toast.success('Profile updated successfully!');
|
||||
|
||||
// Wait a bit for the onSuccess handler to update localStorage
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Close modal
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Profile update failed:', error);
|
||||
@@ -181,7 +174,6 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
{/* Avatar Upload */}
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="relative group">
|
||||
{avatarPreview ? (
|
||||
@@ -200,7 +192,6 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Camera overlay */}
|
||||
<label
|
||||
htmlFor="avatar"
|
||||
className="absolute bottom-0 right-0 bg-primary-500 text-white p-2 rounded-full cursor-pointer hover:bg-primary-600 transition-colors shadow-lg"
|
||||
@@ -241,7 +232,6 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Full Name */}
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Full Name"
|
||||
@@ -250,7 +240,6 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{/* City */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-label1 font-medium text-neutral-800 dark:text-neutral-300">
|
||||
City
|
||||
@@ -269,7 +258,6 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role/Skills */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">
|
||||
Role / Skills
|
||||
@@ -307,7 +295,6 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bio */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">
|
||||
Bio{' '}
|
||||
@@ -337,7 +324,6 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -28,7 +28,6 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
const team = teamData?.data;
|
||||
const currentUserId = session?.user?.id;
|
||||
|
||||
// Message type used for UI rendering
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
user_id: string;
|
||||
@@ -40,10 +39,8 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Inline delete confirmation UI state
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
|
||||
// Prepare messages: de-duplicate by id and sort by created_at
|
||||
const displayMessages = useMemo(() => {
|
||||
const raw: ChatMessage[] = Array.isArray(messages)
|
||||
? (messages as ChatMessage[])
|
||||
@@ -64,7 +61,6 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
return dedup;
|
||||
}, [messages]);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [displayMessages]);
|
||||
@@ -108,7 +104,6 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 shadow-sm dark:shadow-gray-950/50">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -130,7 +125,6 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Container */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{isLoading ? (
|
||||
@@ -156,7 +150,6 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
isOwnMessage ? 'flex-row-reverse' : 'flex-row'
|
||||
}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="shrink-0">
|
||||
{msg.user?.avatar ? (
|
||||
<img
|
||||
@@ -171,7 +164,6 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message Bubble */}
|
||||
<div className={`flex-1 text-left`}>
|
||||
<div
|
||||
className={`inline-block ${
|
||||
@@ -197,7 +189,6 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
{msg.message}
|
||||
</p>
|
||||
|
||||
{/* Delete button */}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={() =>
|
||||
@@ -214,7 +205,6 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
}`}
|
||||
title="Delete message"
|
||||
>
|
||||
{/* Icon trash */}
|
||||
<Icon
|
||||
icon="mdi:trash-can-outline"
|
||||
className="w-4 h-4"
|
||||
@@ -267,7 +257,6 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Input */}
|
||||
<div className="bg-white dark:bg-gray-900 border-t dark:border-gray-700 shadow-lg dark:shadow-gray-950/50">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<form onSubmit={handleSendMessage} className="flex gap-3">
|
||||
|
||||
@@ -18,7 +18,7 @@ import { CitySelect } from '../../../../components/city-select';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024;
|
||||
|
||||
const EditTeamPage: FC = (): ReactElement => {
|
||||
const { teamId } = useParams<{ teamId: string }>();
|
||||
@@ -162,7 +162,6 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-8">
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
{/* Banner Upload */}
|
||||
<div>
|
||||
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Team Banner
|
||||
@@ -209,7 +208,6 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logo Upload */}
|
||||
<div>
|
||||
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Team Logo
|
||||
@@ -261,7 +259,6 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Name */}
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Team Name"
|
||||
@@ -271,7 +268,6 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
isRequired={true}
|
||||
/>
|
||||
|
||||
{/* City */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
City
|
||||
@@ -290,7 +286,6 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300">
|
||||
Description
|
||||
@@ -318,7 +313,6 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Visibility */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300">
|
||||
Team Visibility
|
||||
@@ -367,7 +361,6 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -10,7 +10,7 @@ const TeamDetailLayout: FC = (): ReactElement => {
|
||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Mobile Header with Hamburger */}
|
||||
|
||||
<div className="lg:hidden sticky top-0 z-30 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 flex items-center">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
|
||||
@@ -54,7 +54,6 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
mode: 'all',
|
||||
});
|
||||
|
||||
// Show loading state while fetching team data
|
||||
if (isLoadingTeam) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
@@ -153,7 +152,6 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
{/* Submission Lock Warning */}
|
||||
{hasSubmission && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
@@ -170,7 +168,6 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Join Requests */}
|
||||
{joinRequests.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
@@ -239,7 +236,6 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Members */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Current Members ({members.length})
|
||||
@@ -311,7 +307,6 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
{!hasSubmission && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
@@ -322,7 +317,6 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invite Member Modal */}
|
||||
{showInviteModal && (
|
||||
<div className="fixed inset-0 bg-black/20 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 max-w-md w-full p-6">
|
||||
|
||||
@@ -17,10 +17,8 @@ import { Icon } from '@iconify/react';
|
||||
|
||||
const MAX_TEAM_MEMBERS = 5;
|
||||
|
||||
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
||||
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
||||
|
||||
// Image component with loading state
|
||||
const ImageWithLoader: FC<{
|
||||
src: string;
|
||||
alt: string;
|
||||
@@ -56,7 +54,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
// Check if team features are closed
|
||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||
const [showJoinRequestsModal, setShowJoinRequestsModal] = useState(false);
|
||||
@@ -67,8 +64,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
const [imagesLoaded, setImagesLoaded] = useState(false);
|
||||
const [imageLoadCount, setImageLoadCount] = useState(0);
|
||||
|
||||
// Calculate total images to load (banner + logo + member avatars)
|
||||
const totalImagesToLoad = 0; // Simplified - disable image loading overlay
|
||||
const totalImagesToLoad = 0;
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setImageLoadCount((prev) => {
|
||||
@@ -81,11 +77,9 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
};
|
||||
|
||||
const handleImageError = () => {
|
||||
// Treat error as loaded to not block the UI
|
||||
handleImageLoad();
|
||||
};
|
||||
|
||||
// Set images as loaded immediately since we disabled the loading overlay
|
||||
useEffect(() => {
|
||||
setImagesLoaded(true);
|
||||
}, []);
|
||||
@@ -102,7 +96,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
const currentUserId = session?.user?.id;
|
||||
const isLeader = currentUserId === team?.leader_id;
|
||||
|
||||
// Only fetch join requests if user is the team leader
|
||||
const { data: joinRequestsData } = useTeamJoinRequests(
|
||||
teamId || '',
|
||||
!!teamId && isLeader
|
||||
@@ -183,7 +176,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
if (isLoadingTeam || isLoadingMembers) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Skeleton Header */}
|
||||
<div className="bg-white dark:bg-gray-950 border-b">
|
||||
<div className="w-full h-48 bg-gray-200 dark:bg-gray-800 animate-pulse" />
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
@@ -202,7 +194,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main Content Skeleton */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-6">
|
||||
<div className="h-6 w-32 bg-gray-300 dark:bg-gray-700 rounded animate-pulse mb-4" />
|
||||
@@ -214,7 +205,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Skeleton */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-6">
|
||||
<div className="h-6 w-40 bg-gray-300 dark:bg-gray-700 rounded animate-pulse mb-4" />
|
||||
@@ -234,7 +224,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
<div className="fixed inset-0 bg-white/60 dark:bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent"></div>
|
||||
@@ -262,7 +251,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 relative dark:bg-gray-950">
|
||||
{/* Loading overlay while images are loading */}
|
||||
{!imagesLoaded && totalImagesToLoad > 0 && (
|
||||
<div className="fixed inset-0 bg-white/80 flex items-center justify-center z-50">
|
||||
<div className="text-center">
|
||||
@@ -275,7 +263,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header with Banner */}
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||
{team.banner && (
|
||||
<div className="relative w-full aspect-3/1 overflow-hidden">
|
||||
@@ -356,9 +343,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-8">
|
||||
<div className="grid gap-4 md:gap-6 xl:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-4 md:space-y-6">
|
||||
{/* Team Description */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
About Team
|
||||
@@ -368,14 +353,12 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Team Actions - Only for Leader */}
|
||||
{isLeader && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Team Management
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{/* Hide invite/join management after submission or deadline */}
|
||||
{!team.has_submission && !isTeamFeaturesClosed && (
|
||||
<>
|
||||
<Button
|
||||
@@ -447,7 +430,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
Maximum team size reached ({MAX_TEAM_MEMBERS} members)
|
||||
</p>
|
||||
)}
|
||||
{/* Danger Zone - Only show when leader is alone, no submission, and features not closed */}
|
||||
{members.length === 1 && !team.has_submission && !isTeamFeaturesClosed && (
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-red-600 dark:text-red-400 mb-3">
|
||||
@@ -465,7 +447,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions for Members (non-leaders) */}
|
||||
{!isLeader && isMember && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
@@ -499,7 +480,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning for single member teams */}
|
||||
{members.length === 1 && !team.has_submission && isMember && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 md:p-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
@@ -516,7 +496,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submission Status - Only show to members */}
|
||||
{team.has_submission && isMember && (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 md:p-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
@@ -539,9 +518,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* Team Leader */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
|
||||
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Team Leader
|
||||
@@ -576,7 +553,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Team Members */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
|
||||
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Members ({members.length})
|
||||
@@ -621,7 +597,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invite Member Modal */}
|
||||
{showInviteModal && (
|
||||
<div className="fixed inset-0 bg-black/20 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
@@ -684,7 +659,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Join Requests Modal */}
|
||||
{showJoinRequestsModal && (
|
||||
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[80vh] overflow-y-auto">
|
||||
@@ -797,7 +771,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Leave Team Confirmation Modal */}
|
||||
{showLeaveModal && (
|
||||
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
@@ -841,7 +814,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Team Confirmation Modal */}
|
||||
{showDeleteModal && (
|
||||
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
|
||||
@@ -61,7 +61,7 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Status Banner */}
|
||||
|
||||
{submission.status === 'submitted' ? (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
@@ -103,7 +103,6 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Certificate Banner - Only show when submission is submitted */}
|
||||
{submission.status === 'submitted' && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
@@ -136,15 +135,14 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 overflow-hidden">
|
||||
{/* Project Header */}
|
||||
|
||||
<div className="bg-linear-to-r from-blue-600 to-blue-800 text-white p-8">
|
||||
<h2 className="text-3xl font-bold mb-2">{submission.project_name}</h2>
|
||||
<p className="text-blue-100">Team: {team?.name}</p>
|
||||
</div>
|
||||
|
||||
{/* Project Details */}
|
||||
<div className="p-8 space-y-6">
|
||||
{/* Description */}
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Project Description</h3>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||
@@ -152,9 +150,8 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Repository */}
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Repository</h3>
|
||||
<a
|
||||
@@ -168,7 +165,6 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Demo URL */}
|
||||
{submission.demo_url && (
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Live Demo</h3>
|
||||
@@ -186,7 +182,6 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
|
||||
</div>
|
||||
|
||||
{/* Screenshots */}
|
||||
{submission.screenshots && submission.screenshots.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">
|
||||
@@ -212,7 +207,6 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submission Info */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border-t-4 border-blue-600 dark:border-primary-500">
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-white mb-2">Submission Information</h3>
|
||||
<div className="grid gap-2 text-sm">
|
||||
@@ -245,7 +239,6 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Read-only Notice */}
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<strong>Note:</strong> This submission is now locked and cannot be edited or deleted.
|
||||
|
||||
@@ -16,10 +16,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
const MIN_TEAM_MEMBERS = 2; // Minimum members required to submit (including leader)
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
const MIN_TEAM_MEMBERS = 2;
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024;
|
||||
|
||||
// Submission deadline: 2025-12-07 23:59:00 WIB (UTC+7)
|
||||
const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z');
|
||||
|
||||
const SubmitProjectPage: FC = (): ReactElement => {
|
||||
@@ -36,10 +35,8 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
seconds: number;
|
||||
} | null>(null);
|
||||
|
||||
// Check if deadline passed
|
||||
const isDeadlinePassed = new Date() >= SUBMISSION_DEADLINE;
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
if (isDeadlinePassed) return;
|
||||
|
||||
@@ -125,7 +122,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
);
|
||||
}
|
||||
|
||||
// Show deadline passed screen
|
||||
if (isDeadlinePassed) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
@@ -175,7 +171,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
|
||||
// Validate file sizes
|
||||
const oversizedFiles = Array.from(files).filter(file => file.size > MAX_FILE_SIZE);
|
||||
if (oversizedFiles.length > 0) {
|
||||
toast.error(`${oversizedFiles.length} file(s) are too large. Maximum size is 2MB per file.`);
|
||||
@@ -222,7 +217,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Countdown Timer */}
|
||||
{timeLeft && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
@@ -273,7 +267,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Minimum Members Warning */}
|
||||
{!hasEnoughMembers && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
@@ -292,7 +285,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning Banner */}
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border-2 border-red-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-3xl">⚠️</span>
|
||||
@@ -320,7 +312,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
}}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Project Name */}
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Project Name"
|
||||
@@ -330,7 +321,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
isRequired={true}
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[15px] font-medium text-gray-700 dark:text-gray-300">
|
||||
Project Description <span className="text-red-500">*</span>
|
||||
@@ -360,7 +350,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Repository URL */}
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Repository URL (GitHub, GitLab, etc.)"
|
||||
@@ -371,7 +360,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
isRequired={true}
|
||||
/>
|
||||
|
||||
{/* Demo URL */}
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Demo URL (Optional)"
|
||||
@@ -381,7 +369,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{/* Screenshots */}
|
||||
<div>
|
||||
<label className="block text-label1 font-medium text-neutral-800 dark:text-gray-300">
|
||||
Project Screenshots (Optional)
|
||||
@@ -424,7 +411,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -451,7 +437,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{showConfirmModal && (
|
||||
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 max-w-md w-full p-6">
|
||||
|
||||
@@ -17,10 +17,8 @@ import { Icon } from '@iconify/react';
|
||||
const DEFAULT_PER_PAGE = 12;
|
||||
const PER_PAGE_OPTIONS = [6, 12, 24, 48];
|
||||
|
||||
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
||||
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
||||
|
||||
// Member filter options
|
||||
const MEMBER_FILTER_OPTIONS = [
|
||||
{ label: 'All Teams', value: '', minMembers: undefined, maxMembers: undefined },
|
||||
{ label: 'Looking for Members (1-4)', value: 'looking', minMembers: 1, maxMembers: 4 },
|
||||
@@ -31,14 +29,12 @@ const MEMBER_FILTER_OPTIONS = [
|
||||
{ label: '5 Members (Full)', value: '5', minMembers: 5, maxMembers: 5 },
|
||||
];
|
||||
|
||||
// Submission status filter options
|
||||
const SUBMISSION_FILTER_OPTIONS = [
|
||||
{ label: 'All Teams', value: '' },
|
||||
{ label: 'Submitted', value: 'true' },
|
||||
{ label: 'Not Submitted', value: 'false' },
|
||||
];
|
||||
|
||||
// Skeleton card component for loading state
|
||||
const TeamCardSkeleton: FC = () => (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden flex flex-col border dark:border-gray-800 animate-pulse">
|
||||
<div className="w-full aspect-3/1 bg-gray-200 dark:bg-gray-700" />
|
||||
@@ -67,10 +63,8 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Check if team features are closed
|
||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||
|
||||
// Initialize state from URL params
|
||||
const initialPage = parseInt(searchParams.get('page') || '1', 10);
|
||||
const initialPerPage = parseInt(
|
||||
searchParams.get('per_page') || String(DEFAULT_PER_PAGE),
|
||||
@@ -95,7 +89,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
PER_PAGE_OPTIONS.includes(initialPerPage) ? initialPerPage : DEFAULT_PER_PAGE
|
||||
);
|
||||
|
||||
// Update URL when pagination state changes
|
||||
const updateUrlParams = useCallback(
|
||||
(params: {
|
||||
page?: number;
|
||||
@@ -160,7 +153,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
[searchParams, setSearchParams]
|
||||
);
|
||||
|
||||
// Debounce search term and reset page
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(searchTerm);
|
||||
@@ -172,7 +164,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm]);
|
||||
|
||||
// Update URL when city filter changes
|
||||
useEffect(() => {
|
||||
if (selectedCity !== initialCity) {
|
||||
setCurrentPage(1);
|
||||
@@ -180,12 +171,10 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
}
|
||||
}, [selectedCity]);
|
||||
|
||||
// Get member filter values
|
||||
const memberFilter = MEMBER_FILTER_OPTIONS.find(
|
||||
(opt) => opt.value === selectedMembers
|
||||
);
|
||||
|
||||
// Convert submission filter value to boolean
|
||||
const hasSubmissionFilter = selectedSubmission === 'true' ? true : selectedSubmission === 'false' ? false : undefined;
|
||||
|
||||
const {
|
||||
@@ -216,7 +205,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
const total = teamsData?.total || 0;
|
||||
const myTeams = myTeamsData?.data || [];
|
||||
|
||||
// Helper function to check if user is a member of a team
|
||||
const isMyTeam = (teamId: string) => {
|
||||
return myTeams.some((team: any) => team.id === teamId);
|
||||
};
|
||||
@@ -239,25 +227,21 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
}
|
||||
});
|
||||
|
||||
// Generate page numbers to display
|
||||
const getPageNumbers = () => {
|
||||
const pages: (number | string)[] = [];
|
||||
const maxVisible = 5;
|
||||
|
||||
if (totalPages <= maxVisible + 2) {
|
||||
// Show all pages if total is small
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
// Always show first page
|
||||
pages.push(1);
|
||||
|
||||
if (currentPage > 3) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Show pages around current
|
||||
const start = Math.max(2, currentPage - 1);
|
||||
const end = Math.min(totalPages - 1, currentPage + 1);
|
||||
|
||||
@@ -269,7 +253,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
@@ -278,7 +261,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-800">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -297,7 +279,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="bg-white dark:bg-gray-900 p-6 rounded-lg shadow-sm mb-6 border dark:border-gray-800">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
@@ -366,7 +347,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Teams Count */}
|
||||
{!isLoading && total > 0 && (
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing {(currentPage - 1) * perPage + 1}-
|
||||
@@ -374,7 +354,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Teams List */}
|
||||
{isLoading || isFetching ? (
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: perPage }).map((_, index) => (
|
||||
@@ -488,7 +467,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{(totalPages > 1 || total > 6) && (
|
||||
<div className="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
{totalPages > 1 && (
|
||||
@@ -583,7 +561,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Join Request Modal */}
|
||||
{showJoinModal && (
|
||||
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
|
||||
@@ -10,15 +10,13 @@ import { Icon } from '@iconify/react';
|
||||
|
||||
import { CitySelect } from '../../../components/city-select';
|
||||
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024;
|
||||
|
||||
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
||||
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
||||
|
||||
const CreateTeamPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Check if team features are closed
|
||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
@@ -99,7 +97,6 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create team:', error);
|
||||
|
||||
// Handle specific error messages
|
||||
const message = error?.message || '';
|
||||
if (message.includes('413') || message.includes('length limit') || message.includes('too large')) {
|
||||
toast.error('Image file is too large. Please use smaller images (max 2MB each).');
|
||||
@@ -111,7 +108,6 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
}
|
||||
});
|
||||
|
||||
// Show closed screen if team features are closed
|
||||
if (isTeamFeaturesClosed) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
@@ -157,7 +153,6 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950">
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Create Your Team</h1>
|
||||
@@ -168,7 +163,6 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-900/50 p-8">
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
{/* Banner Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-2">
|
||||
Team Banner <span className="text-gray-400 dark:text-neutral-500">(Optional)</span>
|
||||
@@ -207,7 +201,6 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logo Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-2">
|
||||
Team Logo <span className="text-gray-400 dark:text-neutral-500">(Optional, but highly recommended)</span>
|
||||
@@ -256,7 +249,6 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Name */}
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Team Name"
|
||||
@@ -264,7 +256,6 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
name="name"
|
||||
/>
|
||||
|
||||
{/* City */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300">
|
||||
City <span className="text-red-500">*</span>
|
||||
@@ -283,7 +274,6 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300">
|
||||
Description <span className="text-red-500">*</span>
|
||||
@@ -307,7 +297,6 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Visibility */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300">
|
||||
Team Visibility <span className="text-red-500">*</span>
|
||||
@@ -352,14 +341,12 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<strong>Note:</strong> As team leader, you cannot leave or join another team after creating this team.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -10,7 +10,7 @@ const TeamsLayout: FC = (): ReactElement => {
|
||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Mobile Header with Hamburger */}
|
||||
|
||||
<div className="lg:hidden sticky top-0 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 z-10">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
|
||||
@@ -10,7 +10,7 @@ const UserDetailLayout: FC = (): ReactElement => {
|
||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-auto">
|
||||
{/* Mobile Header with Hamburger */}
|
||||
|
||||
<div className="lg:hidden bg-white dark:bg-gray-900 border-b dark:border-neutral-700 px-4 py-3 flex items-center">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
|
||||
@@ -44,7 +44,7 @@ const UserProfilePage: FC = (): ReactElement => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Header */}
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-6">
|
||||
<div className="flex items-start justify-between">
|
||||
@@ -91,9 +91,9 @@ const UserProfilePage: FC = (): ReactElement => {
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-8">
|
||||
<div className="grid gap-4 md:gap-6 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
|
||||
<div className="lg:col-span-2 space-y-4 md:space-y-6">
|
||||
{/* About Section */}
|
||||
|
||||
{user.bio && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6 overflow-hidden">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
@@ -105,7 +105,6 @@ const UserProfilePage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills Section */}
|
||||
{user.skills && user.skills.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
@@ -124,7 +123,6 @@ const UserProfilePage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team Section */}
|
||||
{userTeams.length > 0 ? (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
@@ -202,9 +200,8 @@ const UserProfilePage: FC = (): ReactElement => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* Contact Info */}
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
||||
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Contact Information
|
||||
|
||||
@@ -35,10 +35,8 @@ const WinnerPage: FC = (): ReactElement => {
|
||||
);
|
||||
}
|
||||
|
||||
// Sort winners by rank
|
||||
const sortedWinners = [...winners].sort((a, b) => a.rank - b.rank);
|
||||
|
||||
// Medal emojis for top 3
|
||||
const getMedalEmoji = (rank: number) => {
|
||||
switch (rank) {
|
||||
case 1:
|
||||
@@ -52,7 +50,6 @@ const WinnerPage: FC = (): ReactElement => {
|
||||
}
|
||||
};
|
||||
|
||||
// Get rank color
|
||||
const getRankColor = (rank: number) => {
|
||||
switch (rank) {
|
||||
case 1:
|
||||
@@ -68,7 +65,6 @@ const WinnerPage: FC = (): ReactElement => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="text-center">
|
||||
@@ -83,7 +79,6 @@ const WinnerPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Winners List */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{winners.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
@@ -97,7 +92,6 @@ const WinnerPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* Top 3 Winners - Mobile View */}
|
||||
<div className="md:hidden space-y-6">
|
||||
{[1, 2, 3].map((position) => {
|
||||
const winner = sortedWinners[position - 1];
|
||||
@@ -148,7 +142,6 @@ const WinnerPage: FC = (): ReactElement => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Top 3 Winners - Tablet/Desktop Podium View */}
|
||||
<div className="hidden md:flex gap-8 items-end justify-center w-full">
|
||||
{[2, 1, 3].map((position) => {
|
||||
const winner = sortedWinners[position - 1];
|
||||
@@ -160,7 +153,6 @@ const WinnerPage: FC = (): ReactElement => {
|
||||
>
|
||||
<div className="text-5xl">{getMedalEmoji(winner.rank)}</div>
|
||||
|
||||
{/* Team Logo */}
|
||||
{winner.team.logo && (
|
||||
<img
|
||||
src={winner.team.logo}
|
||||
@@ -178,7 +170,6 @@ const WinnerPage: FC = (): ReactElement => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Podium */}
|
||||
<div
|
||||
className={`${
|
||||
winner.rank === 1
|
||||
@@ -197,7 +188,6 @@ const WinnerPage: FC = (): ReactElement => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Ranks 4-23: Prize Winners */}
|
||||
{sortedWinners.filter((w) => w.rank >= 4 && w.rank <= 23).length >
|
||||
0 && (
|
||||
<div className="mt-12">
|
||||
@@ -247,7 +237,6 @@ const WinnerPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rank 24+: Remaining Participants */}
|
||||
{sortedWinners.filter((w) => w.rank >= 24).length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">
|
||||
@@ -296,7 +285,6 @@ const WinnerPage: FC = (): ReactElement => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
{winners.length > 0 && (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
|
||||
|
||||
@@ -19,12 +19,10 @@ export const CitySelect: FC<CitySelectProps> = ({
|
||||
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 (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { useUserMe, useMyTeams } from '@imphnen-frontend-service/service';
|
||||
import { useSession } from '@imphnen-frontend-service/utils';
|
||||
import { useSession } from '@imphnen-frontend-service/service';
|
||||
|
||||
export const Navigation: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -23,13 +23,12 @@ export const Navigation: FC = () => {
|
||||
<nav className="bg-white border-b shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
{/* Logo */}
|
||||
|
||||
<Link to="/dashboard" className="flex items-center space-x-2">
|
||||
<span className="text-2xl">🏆</span>
|
||||
<span className="text-xl font-bold text-gray-900">Hackathon</span>
|
||||
</Link>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="hidden md:flex items-center space-x-6">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
@@ -53,7 +52,6 @@ export const Navigation: FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Menu */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
@@ -88,7 +86,6 @@ export const Navigation: FC = () => {
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{showUserMenu && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
|
||||
<div className="px-4 py-2 border-b border-gray-200">
|
||||
@@ -132,7 +129,6 @@ export const Navigation: FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Close dropdown when clicking outside */}
|
||||
{showUserMenu && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
|
||||
@@ -32,7 +32,6 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
|
||||
const myTeams = teamsData?.data || [];
|
||||
const hasTeam = myTeams.length > 0;
|
||||
|
||||
// Close sidebar on route change (mobile)
|
||||
useEffect(() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
@@ -159,7 +158,6 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
|
||||
|
||||
const sidebarContent = (
|
||||
<div className="w-64 bg-white dark:bg-gray-900 border-r dark:border-gray-800 min-h-screen flex flex-col">
|
||||
{/* Logo / Brand with Close Button */}
|
||||
<div className="p-6 flex items-center justify-between border-b dark:border-gray-800">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Hackathon
|
||||
@@ -186,7 +184,6 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="p-4 border-b dark:border-gray-800">
|
||||
<div className="flex items-center space-x-3">
|
||||
{user?.avatar ? (
|
||||
@@ -216,7 +213,6 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-4">
|
||||
<ul className="space-y-2">
|
||||
{navItems
|
||||
@@ -242,7 +238,6 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Theme Toggle & Logout */}
|
||||
<div className="p-4 border-t dark:border-gray-800 space-y-2">
|
||||
<button
|
||||
onClick={cycleTheme}
|
||||
@@ -267,20 +262,16 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Sidebar - Always visible on lg+, sticky position */}
|
||||
<div className="hidden lg:block sticky top-0 h-screen overflow-y-auto">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
|
||||
{/* Mobile Sidebar - Overlay */}
|
||||
{isOpen && (
|
||||
<div className="lg:hidden fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Sidebar */}
|
||||
<div className="fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
|
||||
@@ -48,11 +48,9 @@ export const ThemeProvider: FC<ThemeProviderProps> = ({ children, defaultTheme =
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Calculate the resolved theme
|
||||
const resolved = theme === 'system' ? getSystemTheme() : theme;
|
||||
setResolvedTheme(resolved);
|
||||
|
||||
// Apply dark class to root
|
||||
if (resolved === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
@@ -60,7 +58,6 @@ export const ThemeProvider: FC<ThemeProviderProps> = ({ children, defaultTheme =
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
// Listen for system theme changes
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return;
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ interface ThemeToggleProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Sun icon for light mode
|
||||
const SunIcon = () => (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
@@ -22,7 +21,6 @@ const SunIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Moon icon for dark mode
|
||||
const MoonIcon = () => (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
@@ -39,7 +37,6 @@ const MoonIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
// System icon (monitor)
|
||||
const SystemIcon = () => (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useTeamById, useTeamMembers, useUserMe, useTeamSubmission } from '@imphnen-frontend-service/service';
|
||||
|
||||
/**
|
||||
* Hook to check if current user is a member of the team
|
||||
*/
|
||||
export const useTeamMembership = (teamId: string) => {
|
||||
const { data: userData } = useUserMe();
|
||||
const { data: membersData } = useTeamMembers(teamId);
|
||||
@@ -21,9 +18,6 @@ export const useTeamMembership = (teamId: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check if current user is the team leader
|
||||
*/
|
||||
export const useIsTeamLeader = (teamId: string) => {
|
||||
const { data: teamData } = useTeamById(teamId);
|
||||
const { data: userData } = useUserMe();
|
||||
@@ -39,9 +33,6 @@ export const useIsTeamLeader = (teamId: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get team submission status
|
||||
*/
|
||||
export const useTeamSubmissionStatus = (teamId: string) => {
|
||||
const { data: submissionData, isLoading } = useTeamSubmission(teamId, !!teamId);
|
||||
|
||||
@@ -57,9 +48,6 @@ export const useTeamSubmissionStatus = (teamId: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check if current user can perform team actions
|
||||
*/
|
||||
export const useTeamPermissions = (teamId: string) => {
|
||||
const { isLeader } = useIsTeamLeader(teamId);
|
||||
const { isMember } = useTeamMembership(teamId);
|
||||
|
||||
@@ -24,10 +24,6 @@ add404PageToRoutesChildren(notFoundFiles, routes);
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
...routes,
|
||||
// MIDDLEWARE TEMPORARILY DISABLED - causing infinite loops with React Router v7
|
||||
// TODO: Implement auth checks at component level or use different pattern
|
||||
// loader: middleware,
|
||||
// shouldRevalidate: () => false,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SessionUser } from '@imphnen-frontend-service/utils';
|
||||
import { SessionUser } from '@imphnen-frontend-service/service';
|
||||
import { hackathonApi, SessionToken } from '@imphnen-frontend-service/service';
|
||||
import { LoaderFunctionArgs, redirect } from 'react-router';
|
||||
|
||||
@@ -29,47 +29,38 @@ const mappingPublicPrefixRoutes = [
|
||||
'/hackathons',
|
||||
];
|
||||
|
||||
// Cache to prevent redundant checks (cache for 5 seconds)
|
||||
const onboardingCache = new Map<string, { hasLocation: boolean; timestamp: number }>();
|
||||
const CACHE_DURATION = 5000; // 5 seconds
|
||||
const CACHE_DURATION = 5000;
|
||||
|
||||
export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
const url = new URL(request.url);
|
||||
const pathname = url.pathname;
|
||||
|
||||
// Get token from cookies and user from local storage
|
||||
const tokenData = SessionToken.get();
|
||||
const user = SessionUser.get();
|
||||
const isAuthenticated = !!tokenData?.token?.access_token;
|
||||
|
||||
// Allow to access the hackathon pages without authentication
|
||||
if (mappingPublicPrefixRoutes.some((prefix) => pathname.startsWith(prefix))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Public routes - allow everyone to view the landing page
|
||||
if (mappingPublicRoutes.includes(pathname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Auth callback - allow without authentication check (for OAuth callback)
|
||||
if (pathname === '/auth/callback') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Auth routes (all /auth/* paths) - redirect to dashboard if already authenticated
|
||||
if (pathname.startsWith('/auth')) {
|
||||
if (isAuthenticated) return redirect('/dashboard');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Require authentication for all other routes
|
||||
if (!isAuthenticated) {
|
||||
return redirect('/auth/login');
|
||||
}
|
||||
|
||||
// Check if user has completed onboarding
|
||||
// Skip onboarding check for onboarding routes themselves
|
||||
if (!mappingOnboardingRoutes.includes(pathname)) {
|
||||
try {
|
||||
const userId = user?.id;
|
||||
@@ -79,28 +70,23 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Check cache first
|
||||
const cached = onboardingCache.get(userId);
|
||||
let hasLocation = false;
|
||||
|
||||
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
|
||||
hasLocation = cached.hasLocation;
|
||||
} else {
|
||||
// First check user data (faster)
|
||||
if (user?.location) {
|
||||
hasLocation = true;
|
||||
} else {
|
||||
// Fetch from backend API
|
||||
try {
|
||||
const response = await hackathonApi.get('/users/me');
|
||||
hasLocation = !!response.data?.data?.location;
|
||||
} catch {
|
||||
// If API fails, check user data as fallback
|
||||
hasLocation = !!user?.location;
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache
|
||||
onboardingCache.set(userId, { hasLocation, timestamp: now });
|
||||
}
|
||||
|
||||
@@ -109,12 +95,10 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Middleware] Unexpected error checking onboarding:', error);
|
||||
// On error, allow access (fail open)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check route permissions using user data
|
||||
const userPermissions =
|
||||
user?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
||||
|
||||
|
||||
@@ -44,12 +44,9 @@ async function deriveKeyFromPassphrase(passphrase: string, salt: Uint8Array, ite
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts plaintext with passphrase -> returns base64(salt||iv||ciphertext)
|
||||
*/
|
||||
export async function encryptText(plaintext: string, passphrase: string) {
|
||||
const salt = randBytes(16); // 128-bit salt
|
||||
const iv = randBytes(12); // 96-bit IV recommended for GCM
|
||||
const salt = randBytes(16);
|
||||
const iv = randBytes(12);
|
||||
const key = await deriveKeyFromPassphrase(passphrase, salt);
|
||||
|
||||
const cipher = await crypto.subtle.encrypt(
|
||||
@@ -58,7 +55,6 @@ export async function encryptText(plaintext: string, passphrase: string) {
|
||||
enc.encode(plaintext)
|
||||
);
|
||||
|
||||
// concat salt + iv + ciphertext
|
||||
const out = new Uint8Array(salt.length + iv.length + cipher.byteLength);
|
||||
out.set(salt, 0);
|
||||
out.set(iv, salt.length);
|
||||
@@ -67,9 +63,6 @@ export async function encryptText(plaintext: string, passphrase: string) {
|
||||
return bufToBase64(out.buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts base64(salt||iv||ciphertext) with passphrase -> plaintext
|
||||
*/
|
||||
export async function decryptText(b64combined: string, passphrase: string) {
|
||||
const combined = new Uint8Array(base64ToBuf(b64combined));
|
||||
const salt = combined.slice(0, 16);
|
||||
|
||||
@@ -2,40 +2,21 @@ import { decryptText, encryptText } from "./aesclient";
|
||||
|
||||
const SECRET_KEY = 'imphnen-hackathon-2025';
|
||||
|
||||
/**
|
||||
* Encode teamId, submissionId, and userId into a certificate ID
|
||||
* Uses AES encryption for secure encoding
|
||||
* @param teamId - The team ID
|
||||
* @param submissionId - The submission ID
|
||||
* @param userId - The user ID (team member)
|
||||
* @returns Encoded certificate ID
|
||||
*/
|
||||
export const encodeCertificateId = async (teamId: string, submissionId: string, userId: string): Promise<string> => {
|
||||
const combined = `${teamId}::${submissionId}::${userId}`;
|
||||
return encryptText(combined, SECRET_KEY);
|
||||
};
|
||||
|
||||
/**
|
||||
* Encode winner certificate ID (team-based)
|
||||
* @param teamId - Winner team ID
|
||||
* @returns Encoded winner certificate ID
|
||||
*/
|
||||
export const encodeWinnerCertificateId = async (teamId: string): Promise<string> => {
|
||||
const combined = `winner::${teamId}`;
|
||||
return encryptText(combined, SECRET_KEY);
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode certificate ID back to teamId, submissionId, and userId
|
||||
* @param certId - The encoded certificate ID
|
||||
* @returns Object containing teamId, submissionId, and userId
|
||||
*/
|
||||
export const decodeCertificateId = async (certId: string): Promise<{ teamId: string; submissionId: string; userId: string }> => {
|
||||
try {
|
||||
const decoded = await decryptText(certId, SECRET_KEY);
|
||||
const parts = decoded.split('::');
|
||||
|
||||
// Handle both old format (teamId::submissionId) and new format (teamId::submissionId::userId)
|
||||
if (parts.length === 2) {
|
||||
const [teamId, submissionId] = parts;
|
||||
return { teamId, submissionId, userId: '' };
|
||||
@@ -50,17 +31,11 @@ export const decodeCertificateId = async (certId: string): Promise<{ teamId: str
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode winner certificate ID back to teamId
|
||||
* @param certId - The encoded winner certificate ID
|
||||
* @returns Object containing teamId
|
||||
*/
|
||||
export const decodeWinnerCertificateId = async (certId: string): Promise<{ teamId: string }> => {
|
||||
try {
|
||||
const decoded = await decryptText(certId, SECRET_KEY);
|
||||
const parts = decoded.split('::');
|
||||
|
||||
// winner::teamId
|
||||
if (parts.length === 2 && parts[0] === 'winner') {
|
||||
return { teamId: parts[1] };
|
||||
}
|
||||
@@ -71,12 +46,6 @@ export const decodeWinnerCertificateId = async (certId: string): Promise<{ teamI
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* For development: Create a certId using created_at timestamp
|
||||
* @param teamId - The team ID
|
||||
* @param createdAt - The creation timestamp
|
||||
* @returns Encoded certificate ID
|
||||
*/
|
||||
export const encodeCertificateIdWithTimestamp = (teamId: string, createdAt: string): string => {
|
||||
const combined = `${teamId}::${createdAt}`;
|
||||
return Buffer.from(combined).toString('base64');
|
||||
|
||||
@@ -16,10 +16,6 @@ export default defineConfig(() => ({
|
||||
host: 'localhost',
|
||||
},
|
||||
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
build: {
|
||||
outDir: '../../dist/apps/hackathon',
|
||||
emptyOutDir: true,
|
||||
|
||||
@@ -24,7 +24,7 @@ const techStack = [
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a]">
|
||||
{/* Header */}
|
||||
|
||||
<header className="border-b border-zinc-800 bg-[#0a0a0a]/80 backdrop-blur-sm sticky top-0 z-50">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -47,7 +47,7 @@ export default function App() {
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto px-6 py-12">
|
||||
{/* Hero */}
|
||||
|
||||
<section className="text-center mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||
Infrastructure Overview
|
||||
@@ -58,33 +58,29 @@ export default function App() {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Architecture Diagram */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-2xl font-semibold mb-6">Architecture</h2>
|
||||
<div className="card bg-[#141414] border-zinc-800 p-8">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
{/* Internet */}
|
||||
|
||||
<div className="flex items-center gap-2 text-zinc-400">
|
||||
<span className="text-2xl">🌐</span>
|
||||
<span>Internet</span>
|
||||
</div>
|
||||
<div className="w-px h-8 bg-zinc-700" />
|
||||
|
||||
{/* Cloudflare */}
|
||||
<div className="card bg-orange-500/10 border-orange-500/30 px-6 py-3">
|
||||
<span className="badge badge-orange">Cloudflare</span>
|
||||
<p className="text-sm text-zinc-400 mt-1">DNS + CDN + DDoS Protection</p>
|
||||
</div>
|
||||
<div className="w-px h-8 bg-zinc-700" />
|
||||
|
||||
{/* Hetzner VPS */}
|
||||
<div className="card bg-[#1a1a1a] border-zinc-700 w-full max-w-3xl">
|
||||
<div className="text-center mb-4">
|
||||
<span className="badge badge-purple">Hetzner Cloud VPS</span>
|
||||
<p className="text-sm text-zinc-400 mt-1">NixOS | 167.235.70.37</p>
|
||||
</div>
|
||||
|
||||
{/* Nginx */}
|
||||
<div className="card bg-green-500/10 border-green-500/30 mb-4">
|
||||
<div className="text-center">
|
||||
<span className="badge badge-green">Nginx</span>
|
||||
@@ -92,7 +88,6 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Apps Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{apps.map((app) => (
|
||||
<div
|
||||
@@ -112,7 +107,6 @@ export default function App() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Deployed Apps */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-2xl font-semibold mb-6">Deployed Applications</h2>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@@ -138,7 +132,6 @@ export default function App() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tech Stack */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-2xl font-semibold mb-6">Technology Stack</h2>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@@ -159,7 +152,6 @@ export default function App() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Deployment Flow */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-2xl font-semibold mb-6">Deployment Flow</h2>
|
||||
<div className="card bg-[#141414] border-zinc-800">
|
||||
@@ -207,7 +199,6 @@ export default function App() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Repositories */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-6">Repositories</h2>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
@@ -237,7 +228,6 @@ export default function App() {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-zinc-800 mt-16">
|
||||
<div className="container mx-auto px-6 py-8 text-center text-zinc-500 text-sm">
|
||||
<p>IMPHNEN Infrastructure © {new Date().getFullYear()}</p>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Tue, Nov 25, 2025 4:21:27 PM
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Deployment trigger
|
||||
# Updated to deploy circular dependency fixes and auth hooks
|
||||
|
||||
@@ -4,7 +4,7 @@ import { motion } from 'framer-motion';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
SiCplusplus,
|
||||
SiCss3,
|
||||
SiCss,
|
||||
SiGo,
|
||||
SiHtml5,
|
||||
SiJavascript,
|
||||
@@ -35,7 +35,7 @@ export function AnimatedBackground() {
|
||||
{ Icon: SiGo, color: '#00ADD8' },
|
||||
{ Icon: SiPhp, color: '#777BB4' },
|
||||
{ Icon: SiHtml5, color: '#E34F26' },
|
||||
{ Icon: SiCss3, color: '#1572B6' },
|
||||
{ Icon: SiCss, color: '#1572B6' },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ export function CTASection() {
|
||||
|
||||
return (
|
||||
<section className="w-full py-20 md:py-32 bg-gradient-to-br from-primary-500 to-primary-600 relative overflow-hidden">
|
||||
{/* Background pattern */}
|
||||
|
||||
<div
|
||||
className="absolute inset-0 opacity-10"
|
||||
style={{
|
||||
@@ -21,7 +21,7 @@ export function CTASection() {
|
||||
|
||||
<div className="container relative" ref={ref}>
|
||||
<div className="flex flex-col lg:flex-row items-center justify-between gap-12">
|
||||
{/* Text Content */}
|
||||
|
||||
<div className="flex-1 text-center lg:text-left space-y-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
@@ -65,7 +65,6 @@ export function CTASection() {
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Illustration */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function HeroSection() {
|
||||
|
||||
return (
|
||||
<section className="relative w-full py-20 md:py-32 lg:py-40 overflow-hidden">
|
||||
{/* Background gradients and motion */}
|
||||
|
||||
<div className="absolute inset-0 -z-10 overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-b from-background to-background/50" />
|
||||
<div
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function EventsPage() {
|
||||
return (
|
||||
<section className="min-h-screen bg-background container py-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{/* Featured Card */}
|
||||
|
||||
<div className="col-span-full">
|
||||
<div className="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-200 overflow-hidden bg-card">
|
||||
<div className="grid md:grid-cols-2">
|
||||
@@ -119,7 +119,6 @@ export default function EventsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Regular Cards */}
|
||||
{sortedEvents.slice(1).map((event) => (
|
||||
<div
|
||||
key={event.name}
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function ProjectsVote() {
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 max-w-7xl mx-auto">
|
||||
{/* Vote Now Column */}
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3 px-2">
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-amber-100 text-amber-600 rounded-lg">
|
||||
@@ -144,7 +144,6 @@ export default function ProjectsVote() {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* In Progress Column */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3 px-2">
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-primary-100 text-primary-600 rounded-lg">
|
||||
@@ -189,7 +188,6 @@ export default function ProjectsVote() {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Completed Column */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3 px-2">
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-green-100 text-green-600 rounded-lg">
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
// 1) Always default to `false`—this guarantees SSR and the very first client render match.
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -13,10 +12,8 @@ export function useMediaQuery(query: string): boolean {
|
||||
|
||||
const mediaQueryList = window.matchMedia(query);
|
||||
|
||||
// 2) On mount, immediately set to the real match value (true or false).
|
||||
setMatches(mediaQueryList.matches);
|
||||
|
||||
// 3) Subscribe to changes.
|
||||
const listener = (event: MediaQueryListEvent) => {
|
||||
setMatches(event.matches);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ export async function getRemoteIp() {
|
||||
const xff = hdrs.get('x-forwarded-for');
|
||||
if (!xff) return undefined;
|
||||
|
||||
// 'x-forwarded-for' can be a comma-separated list of IPs
|
||||
const ips = xff.split(',').map((ip) => ip.trim());
|
||||
return ips[0] || undefined;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user