Compare commits

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

* feat: blank cert

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

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

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

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

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

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

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

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

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

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

* test datatable with mock data

* base UI for Hackathon backoffice

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

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

* update endpoint

* feat(backoffice): update page hackathon user management

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

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

* feat(backoffice): little adjustment in hackathon users management UI and API contract
2025-12-05 11:55:28 +07:00
Hafid Nur 53c45da00a feat(backoffice): improve hackathon team modal UI and add API contract
- Add feature to select city in team detail modal using CityFilterSelect component
- Add feature to change team logo and banner
- Add API contract documentation for hackathon teams in backoffice
2025-12-02 23:14:40 +07:00
Hafid Nur 136af9caf2 feat(backoffice): add searchable city filter
- add component for city filter
- apply to user management and team management pages
2025-12-02 22:49:08 +07:00
Hafid Nur fbc8e0b16a feat(backoffice): update hackathon team management page
- add modal for manage team, add new team, and view project submission
- reorganize the table column and data table
2025-12-02 22:45:35 +07:00
Hafid Nur aa4686254b Merge branch 'develop' of github.com:IMPHNEN/imphnen-frontend-service into backoffice/for-hackathon 2025-12-02 18:13:03 +07:00
Hafid Nur 2d80ed05db feat(backoffice): little adjustment in hackathon users management UI and API contract 2025-12-02 18:11:34 +07:00
Maulana SodiqinandClaude d923237969 hide presentation URL field from submission pages
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 11:18:11 +07:00
Maulana SodiqinandClaude b2a28c6fd3 feat: add submission status filter to browse teams page
- Add has_submission filter option to useTeams hook
- Add Submission Status dropdown filter (All/Submitted/Not Submitted)
- Filter persists in URL query params

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 23:28:25 +07:00
Hafid Nur d927b95457 feat(backoffice): add API contract for hackathon users 2025-12-01 11:41:56 +07:00
Hafid Nur 2dc9dd985a feat(backoffice): update page hackathon user management
- update data table component
- update filtering & pagination
- add modal display to edit and add user
- hide notification icon in backoffice wrapper
2025-12-01 11:41:18 +07:00
Maulana Sodiqin 417ff3390b fix submission status 2025-11-30 22:55:52 +07:00
Maulana Sodiqin 0c646ceb85 feat: pyush 2025-11-30 22:43:09 +07:00
Maulana SodiqinandClaude 6bbc87479a feat(auth): disable registration after 23:29 WIB Nov 30, 2025
Show "Registration Closed" message after deadline

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 22:15:49 +07:00
Hafid Nur 80a6aa9fb5 update endpoint 2025-11-30 18:57:04 +07:00
Hafid Nur 1343404e01 feat(hackathon): draft data table column & API Contract 2025-11-30 18:54:34 +07:00
Hafid Nur 5faba43728 base UI for Hackathon backoffice
TODO:
- Organize table schema for users, teams, and submissions management
- Create API Contract for additional back-end endpoint
2025-11-30 17:22:47 +07:00
Hafid Nur a20f5eff85 test datatable with mock data 2025-11-30 16:55:11 +07:00
Maulana SodiqinandClaude 23b6677323 fix: unwrap nested team data in hooks instead of components
- useMyTeams and useTeamsByUserId now unwrap {team: {...}} structure
- Simplified dashboard and user profile components

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 14:13:11 +07:00
Maulana SodiqinandClaude 45c75b3283 fix(dashboard): unwrap nested team data from API response
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 14:11:12 +07:00
Hafid Nur 18e835450a feat(backoffice): Create a nested/dropdown sidebar list
- Create a dropdown sidebar list
- Show back the old navigation and group them
- Make the sidebar responsive for mobile view
2025-11-30 12:59:18 +07:00
Hafid Nur f5ad55235c feat(backoffice): create a boilerplate page for Hackathon dashboard
- Create an empty page for Hackathon dashboard
- Comment out and hide the existing backoffice sidebar
2025-11-30 12:29:58 +07:00
Maulana SodiqinandClaude 764eb34e47 fix(users): remove member count from team card
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 00:36:39 +07:00
Maulana SodiqinandClaude fe4e9291fa fix(users): unwrap nested team data from API response
API returns {team: {...}} wrapped objects, need to access item.team

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 00:33:26 +07:00
Maulana SodiqinandClaude ec9a81d7b5 fix(users): remove team count from section title
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 00:09:06 +07:00
Maulana Sodiqin e652c6ed0e fix: team 2025-11-30 00:04:34 +07:00
Maulana SodiqinandClaude d431564aac fix(dashboard): show single team card full width
- User can only have one team, no need for list/grid
- Display team card full width with larger styling
- Bigger logo, title, and description area

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 00:02:02 +07:00
Maulana SodiqinandClaude 350d08a2b4 fix(dashboard): improve team card display
- Add fallback banner image
- Add fallback logo placeholder
- Use member_count for member display
- Add submission badge
- Improve card layout consistency with other pages

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 00:00:25 +07:00
Maulana SodiqinandClaude eb5e043357 fix(users): fix team card display on user profile page
- Use member_count instead of members.length for member count
- Use singular "Team" when user has only 1 team
- Add fallback banner image
- Add fallback logo placeholder
- Add submission badge
- Improve card layout and styling

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 23:56:57 +07:00
Maulana SodiqinandClaude d67e3e7244 feat(teams): add member count filter to browse teams
- Add min_members and max_members params to useTeams hook
- Add member filter dropdown with options: All, Looking for Members (1-4), 1-5 members
- Filter persists in URL query params

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 23:12:42 +07:00
Maulana Sodiqin a7196dd453 feat: persist pagination 2025-11-29 17:23:55 +07:00
Maulana SodiqinandClaude c83e54fae8 feat(teams): add skeleton loading and per_page selector
- Show skeleton cards during pagination loading
- Add per_page dropdown (6, 12, 24, 48 options)
- Reset to page 1 when changing per_page

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 17:11:56 +07:00
Maulana SodiqinandClaude 35a5761a7c fix(teams): move member warning above buttons in browse cards
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 17:09:17 +07:00
Maulana SodiqinandClaude b472fbdbbf fix(teams): correct API response structure for browse teams
- Match ListResponse structure: data (array) + meta (pagination)
- Use correct query param name: per_page instead of limit

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 17:05:27 +07:00
Maulana SodiqinandClaude 4fcc96ac15 feat(teams): implement numbered pagination for browse teams
- Replace infinite scroll with traditional numbered pagination
- Add page numbers, prev/next buttons
- Show "Showing X-Y of Z teams" count
- Auto-reset to page 1 on search/filter changes
- Update useTeams hook to return pagination metadata

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 17:03:24 +07:00
Maulana SodiqinandClaude 2dfcd3adad feat(teams): hide leave team button for submitted teams
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 16:59:30 +07:00
Maulana SodiqinandClaude d79788258b fix(teams): update submitted badge styling to less rounded corners
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 16:54:47 +07:00
Maulana SodiqinandClaude 427d2b69c7 fix(teams): move submitted badge to info row
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 16:51:43 +07:00
Maulana SodiqinandClaude 7e5e4cc29b fix(teams): disable submit button for single-member teams
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 16:49:07 +07:00
Maulana SodiqinandClaude a1e89b882a feat(teams): add warning for single-member teams
- Show warning on browse page for teams with only 1 member
- Show warning on team detail page for single-member teams
- Helps users understand they need 2+ members to submit

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 16:48:03 +07:00
Maulana SodiqinandClaude a74f0181f1 fix(teams): hide edit team info for submitted teams
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 16:13:16 +07:00
Maulana SodiqinandClaude a9a1e5450b feat(teams): hide join/invite options for submitted teams
- Hide "Request to Join" button on browse page for submitted teams
- Hide "Invite Member" and "Join Requests" buttons on team detail for submitted teams
- Change "Submit Project" to "View Submission" for submitted teams
- Rename "Manage Members" to "View Members"
- Hide "Delete Team" for submitted teams

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 16:08:05 +07:00
Maulana SodiqinandClaude 5e65942060 fix(auth): detect password reset tokens on login page and redirect
Added useEffect to detect access_token in URL hash/query params on
login page. When Supabase redirects password reset emails to /auth/login
instead of /auth/callback, the page now automatically redirects to
/auth/reset-password with the token.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 12:01:06 +07:00
Maulana Sodiqin d044d0ef50 faet: push 2025-11-29 11:37:50 +07:00
Maulana Sodiqin d40a913ccc feat: make public github 2025-11-29 11:26:36 +07:00
Maulana Sodiqin 66178ccc18 fix: auth error 2025-11-29 11:24:23 +07:00
Maulana Sodiqin 8280ba2a64 feat: add teams restriction after submiting 2025-11-28 20:01:16 +07:00
Maulana Sodiqin 8259020450 feat: add submited indicator 2025-11-28 19:57:39 +07:00
Maulana Sodiqin 692a7a7258 feat: add size warning 2025-11-28 19:54:48 +07:00
Maulana Sodiqin 1ee04ada14 feat: add destroy team feature 2025-11-28 19:30:43 +07:00
Maulana Sodiqin 193cb61b45 fix: detail member page only leader hit pending endpoint 2025-11-28 19:09:05 +07:00
Maulana Sodiqin 4777a34f23 feat: red button 2025-11-28 18:56:53 +07:00
Maulana SodiqinandClaude 4d3677ebd2 fix(hackathon): use correct leave team endpoint
Changed from DELETE /teams/{id}/members/{userId} to POST /teams/{id}/leave

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 18:36:21 +07:00
Maulana SodiqinandClaude f0eb11010d feat(hackathon): add leave team feature for team members
- Add Leave Team button in Quick Actions section for non-leader members
- Add confirmation modal before leaving with warning message
- Use existing useLeaveTeam hook from service library

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 18:23:54 +07:00
Maulana SodiqinandClaude e1f13b3872 feat(hackathon): improve team member count display and submission verification
- Fix browse teams page to use member_count from API response
- Add member_count field to TTeamItem type
- Add minimum team members requirement (2) for project submission
- Add type-to-confirm verification before final submission

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 18:15:34 +07:00
Maulana SodiqinandClaude fa30849d03 feat(hackathon): improve auth flow and UX
- Add email activation requirement for signup (no auto-login)
- Add form validation with React Hook Form and Zod on signup page
- Update callback page to handle email confirmation and password reset redirects
- Fix token format in API interceptor (use access_token)
- Fix middleware to use SessionToken for auth check
- Add infinite scroll with IntersectionObserver on browse teams page
- Replace all internal <a href> with <Link> components
- Add useInfiniteTeams hook for paginated team browsing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 17:40:34 +07:00
Maulana SodiqinandClaude f8d592d811 fix(hackathon): disable maintenance mode
- Redirect unauthenticated users to /auth/login instead of /maintenance
- Allow all /auth/* routes for unauthenticated access

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 16:02:35 +07:00
Maulana SodiqinandClaude c385aa1996 fix(hackathon): remove Supabase dependencies completely
- Remove Supabase export from service library
- Update forgot-password and reset-password pages to use backend API
- Update layout.tsx to use useAuthStore instead of Supabase session
- Update sidebar logout to not use Supabase
- Update middleware to use SessionUser instead of Supabase
- Update use-session hook to remove Supabase dependency

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 15:57:30 +07:00
Maulana Sodiqin 9a97eb3e8e feat: integrate auth 2025-11-28 13:09:53 +07:00
Hafid NurandGitHub 70ee5300f7 add maintenance page (#66) 2025-11-27 23:46:52 +07:00
35c9539c08 feat(hackathon): Change "edit profile" into modal dialog (#65)
* feat: update landing page

* feat: Add favicon and SEO (JSON-LD schema, meta tags)

- Add favicon image
- Add JSON-LD schema for Hackathon event
- Add react-helmet-async for managing meta tags

* chore: remove react-helmet-async

* chore: update landing page

* chore: update UI for signup and login

- add back button for better UX

* feat: add image loading state and skeleton UI for team dashboard

* fix: landing navbar not sticky to top, fix onboarding UI

* feat(hackathon): update UI

- Update dashboard, onboarding user, edit profile, and team layout
  to make it more consistent

* fix: optimize placeholder banner image & minor fix dark mode

* feat(hackathon): update landing page dark mode color scheme

* feat(hackathon): update auth page dark mode color scheme

* feat(hackathon): dashboard UI improvement and dark mode adjustment

* feat(hackathon): browse team page

- update dark mode color scheme
- add some border style to give outline and depth
- change emoji to icon

* feat(hackathon): update team page

- update dark mode color scheme for team page: view, team edit,
  and submit project page
- fix bug in chat where messages are displayed double
- make the form field size consistent

* feat(hackathon): update dark mode onboarding and edit profile

* feat(hackathon): change "edit profile" to modal dialog

- Add "edit profile" modal dialog that can be triggered from dashboard
- Update background overlay for dark mode
- Update dark mode for auth callback

* feat(hackathon): dark mode for user page

---------

Co-authored-by: Maulana Sodiqin <sodiqincahyana1@gmail.com>
Co-authored-by: Muhammad Zakir Ramadhan <61570975+zakirkun@users.noreply.github.com>
2025-11-27 16:25:21 +07:00
76 changed files with 13548 additions and 1782 deletions
+7 -1
View File
@@ -10,7 +10,13 @@
"Bash(ren page.tsx page-original.tsx)",
"Bash(ren:*)",
"Bash(npx supabase:*)",
"Bash(libs/service/src/types/supabase.ts)"
"Bash(libs/service/src/types/supabase.ts)",
"Bash(npx nx build:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push)",
"Bash(findstr:*)",
"Bash(ls:*)"
],
"deny": [],
"ask": []
+9
View File
@@ -0,0 +1,9 @@
{
"mcpServers": {
"nx-mcp": {
"type": "stdio",
"command": "npx",
"args": ["nx", "mcp"]
}
}
}
+13
View File
@@ -0,0 +1,13 @@
<!-- nx configuration start-->
<!-- Leave the start & end comments to automatically receive updates. -->
# General Guidelines for working with Nx
- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
- You have access to the Nx MCP server and its tools, use them to help the user
- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable.
- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies
- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration
- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors
<!-- nx configuration end-->
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8" />
<title>Backoffice</title>
@@ -0,0 +1,64 @@
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
import { FC, ReactElement } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
getAdminUsers,
getAdminTeams,
getAdminSubmissions,
} from '@imphnen-frontend-service/service';
export const HackathonDashboardPage: FC = (): ReactElement => {
// Fetch total participants
const { data: usersData } = useQuery({
queryKey: ['admin-users-count'],
queryFn: () => getAdminUsers({ page: 1, per_page: 1 }),
});
// Fetch total teams
const { data: teamsData } = useQuery({
queryKey: ['admin-teams-count'],
queryFn: () => getAdminTeams({ page: 1, per_page: 1 }),
});
// Fetch total submissions
const { data: submissionsData } = useQuery({
queryKey: ['admin-submissions-count'],
queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }),
});
const totalParticipants = usersData?.meta?.total_data ?? '??';
const totalTeams = teamsData?.meta?.total_data ?? '??';
const totalSubmissions = submissionsData?.meta?.total_data ?? '??';
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
<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}
</h3>
<p className="text-neutral-400 text-p3">Total Project Submitted</p>
</div>
</section>
</BackofficeWrapper>
);
};
export default HackathonDashboardPage;
@@ -0,0 +1,249 @@
import { FC } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import {
CloseOutlined,
LinkOutlined,
ProjectOutlined,
FileImageOutlined,
} from '@ant-design/icons';
import { TAdminSubmissionItem } from '@imphnen-frontend-service/service';
import { cn } from '@imphnen-frontend-service/utils';
interface SubmissionModalProps {
isOpen: boolean;
onClose: () => void;
submission: TAdminSubmissionItem;
}
const SubmissionModal: FC<SubmissionModalProps> = ({
isOpen,
onClose,
submission,
}) => {
if (!isOpen) return null;
const getStatusColor = (status: string) => {
switch (status) {
case 'submitted':
return 'bg-success-50 border-success-200 text-success-800';
case 'pending':
return 'bg-orange-50 border-orange-200 text-orange-800';
case 'approved':
return 'bg-blue-50 border-blue-200 text-blue-800';
case 'rejected':
return 'bg-error-50 border-error-200 text-error-800';
default:
return 'bg-neutral-50 border-neutral-200 text-neutral-800';
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-success-100 flex items-center justify-center">
<ProjectOutlined className="text-success-600 text-lg" />
</div>
<div>
<h2 className="text-xl font-semibold text-neutral-900">
{submission.project_name}
</h2>
<p className="text-sm text-neutral-500">
Team ID: {submission.team_id} Submitted{' '}
{new Date(submission.submitted_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</div>
</div>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 transition-colors cursor-pointer"
>
<CloseOutlined className="text-xl" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Submission Status */}
<div
className={cn(
'flex items-center gap-3 p-4 border rounded-lg',
getStatusColor(submission.status)
)}
>
<div
className={cn(
'w-3 h-3 rounded-full',
submission.status === 'submitted' && 'bg-success-500',
submission.status === 'pending' && 'bg-orange-500',
submission.status === 'approved' && 'bg-blue-500',
submission.status === 'rejected' && 'bg-error-500'
)}
></div>
<div>
<p className="text-sm font-medium">
Status:{' '}
{submission.status.charAt(0).toUpperCase() +
submission.status.slice(1)}
</p>
<p className="text-xs">Submitted by: {submission.submitted_by}</p>
</div>
</div>
{/* Project Description */}
<div>
<h3 className="text-sm font-medium text-neutral-700 mb-2">
Project Description
</h3>
<p className="text-sm text-neutral-600 leading-relaxed">
{submission.description}
</p>
</div>
{/* Project Links */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-neutral-700">
Project Links
</h3>
{/* Repository URL */}
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
<LinkOutlined className="text-primary-500 mt-1" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-neutral-600 mb-1">
Repository
</p>
<a
href={submission.repository_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary-600 hover:text-primary-700 hover:underline break-all"
>
{submission.repository_url}
</a>
</div>
</div>
{/* Demo URL */}
{submission.demo_url && (
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
<LinkOutlined className="text-primary-500 mt-1" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-neutral-600 mb-1">
Live Demo
</p>
<a
href={submission.demo_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary-600 hover:text-primary-700 hover:underline break-all"
>
{submission.demo_url}
</a>
</div>
</div>
)}
{/* Presentation URL */}
{submission.presentation_url && (
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
<LinkOutlined className="text-primary-500 mt-1" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-neutral-600 mb-1">
Presentation
</p>
<a
href={submission.presentation_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary-600 hover:text-primary-700 hover:underline break-all"
>
{submission.presentation_url}
</a>
</div>
</div>
)}
</div>
{/* Screenshots */}
{submission.screenshots && submission.screenshots.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-neutral-700 flex items-center gap-2">
<FileImageOutlined className="text-primary-500" />
Screenshots ({submission.screenshots.length})
</h3>
<div className="grid grid-cols-2 gap-3">
{submission.screenshots.map((screenshot, index) => (
<a
key={index}
href={screenshot}
target="_blank"
rel="noopener noreferrer"
className="block rounded-lg overflow-hidden border border-neutral-200 hover:border-primary-300 transition-colors"
>
<img
src={screenshot}
alt={`Screenshot ${index + 1}`}
className="w-full h-40 object-cover"
/>
</a>
))}
</div>
</div>
)}
{/* Metadata */}
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-neutral-200">
<div>
<p className="text-xs text-neutral-500 mb-1">Created</p>
<p className="text-sm text-neutral-900">
{new Date(submission.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
<div>
<p className="text-xs text-neutral-500 mb-1">Last Updated</p>
<p className="text-sm text-neutral-900">
{new Date(submission.updated_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-neutral-200 bg-neutral-50">
<Button variant="secondary" onClick={onClose}>
Close
</Button>
{/* <Button
variant="primary"
onClick={() => {
console.log('Edit submission:', submission.id);
}}
>
Edit Status
</Button> */}
</div>
</div>
</div>
);
};
export default SubmissionModal;
@@ -0,0 +1,375 @@
import {
FC,
ReactElement,
useState,
useEffect,
useMemo,
useCallback,
} from 'react';
import SubmissionModal from './_components/submission-modal';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import {
SearchOutlined,
FilterOutlined,
LoadingOutlined,
EyeOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import {
getAdminSubmissions,
TAdminSubmissionItem,
} from '@imphnen-frontend-service/service';
import { useSearchParams } from 'react-router-dom';
type SubmissionType = TAdminSubmissionItem;
export const HackathonSubmissionsPage: FC = (): ReactElement => {
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = Math.max(
1,
parseInt(searchParams.get('page') || '1', 10)
);
const searchQuery = searchParams.get('search') || '';
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
const statusFilter = searchParams.get('status') || 'all';
const [showSubmissionModal, setShowSubmissionModal] = useState(false);
const [selectedSubmission, setSelectedSubmission] =
useState<SubmissionType | null>(null);
const [globalFilter, setGlobalFilter] = useState(searchQuery);
// Fetch submissions from API
const {
data: submissionsResponse,
isLoading,
isFetching,
} = useQuery({
queryKey: [
'admin-submissions',
currentPage,
perPage,
statusFilter,
searchQuery,
],
queryFn: () =>
getAdminSubmissions({
page: currentPage,
per_page: perPage,
status: statusFilter !== 'all' ? statusFilter : undefined,
search: searchQuery || undefined,
}),
staleTime: 30000, // 30 seconds cache
gcTime: 5 * 60 * 1000, // 5 minutes
});
const 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();
params.set('page', newPage.toString());
if (perPage !== 10) params.set('per_page', perPage.toString());
if (searchQuery) params.set('search', searchQuery);
if (statusFilter !== 'all') params.set('status', statusFilter);
setSearchParams(params);
window.scrollTo({ top: 0, behavior: 'smooth' });
},
[setSearchParams, perPage, searchQuery, statusFilter]
);
// 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');
if (perPage !== 10) params.set('per_page', perPage.toString());
if (globalFilter.trim()) {
params.set('search', globalFilter.trim());
}
if (statusFilter !== 'all') params.set('status', statusFilter);
setSearchParams(params);
}, [globalFilter, setSearchParams, perPage, statusFilter]);
const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
},
[handleSearch]
);
// Handle per page change
const handlePerPageChange = useCallback(
(newPerPage: number) => {
const params = new URLSearchParams();
params.set('page', '1');
params.set('per_page', newPerPage.toString());
if (searchQuery) params.set('search', searchQuery);
if (statusFilter !== 'all') params.set('status', statusFilter);
setSearchParams(params);
},
[setSearchParams, searchQuery, statusFilter]
);
// Handle status filter change
// const handleStatusFilterChange = useCallback(
// (newStatus: string) => {
// const params = new URLSearchParams();
// params.set('page', '1');
// if (perPage !== 10) params.set('per_page', perPage.toString());
// if (searchQuery) params.set('search', searchQuery);
// if (newStatus !== 'all') params.set('status', newStatus);
// setSearchParams(params);
// },
// [setSearchParams, perPage, searchQuery]
// );
// Handle modal
const handleShowSubmissionModal = useCallback(
(submission: SubmissionType) => {
setSelectedSubmission(submission);
setShowSubmissionModal(true);
},
[]
);
const handleCloseSubmissionModal = useCallback(() => {
setShowSubmissionModal(false);
setSelectedSubmission(null);
}, []);
// Get submissions data
const filteredData = useMemo(() => {
return submissionsResponse?.data || [];
}, [submissionsResponse]);
// Memoize columns
const columns: ColumnDef<SubmissionType>[] = useMemo(
() => [
{
accessorKey: 'project_name',
header: 'Project Name',
cell: ({ row }) => (
<span className="font-medium text-neutral-900">
{row.original.project_name}
</span>
),
enableSorting: true,
},
{
accessorKey: 'team_id',
header: 'Team ID',
cell: ({ row }) => (
<span className="text-sm text-neutral-700 font-mono">
{row.original.team_id}
</span>
),
enableSorting: false,
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const status = row.original.status;
return (
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
status === 'submitted'
? 'bg-success-100 text-success-800'
: status === 'pending'
? 'bg-orange-100 text-orange-800'
: 'bg-neutral-100 text-neutral-700'
)}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
},
enableSorting: true,
},
{
accessorKey: 'submitted_at',
header: 'Submitted',
cell: ({ row }) => (
<span className="text-neutral-900 text-sm">
{new Date(row.original.submitted_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
),
enableSorting: true,
sortingFn: 'datetime',
},
{
id: 'actions',
header: 'Actions',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 text-sm px-4 py-2"
onClick={() => handleShowSubmissionModal(row.original)}
>
<EyeOutlined className="text-sm" />
View
</Button>
),
enableSorting: false,
},
],
[handleShowSubmissionModal]
);
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
Project Submissions
</h1>
<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
type="text"
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
placeholder="Search by project name..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
onKeyPress={handleSearchKeyPress}
/>
</div>
{/* Per Page Dropdown */}
<div className="relative">
<select
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={perPage}
onChange={(e) =>
handlePerPageChange(parseInt(e.target.value, 10))
}
>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
<option value={100}>100 / page</option>
</select>
</div>
{/* Status Filter */}
{/* <div className="relative">
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
<select
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-40 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={statusFilter}
onChange={(e) => handleStatusFilterChange(e.target.value)}
>
<option value="all">All Status</option>
<option value="submitted">Submitted</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
</div> */}
</div>
</div>
{/* Active filters */}
{/* {statusFilter !== 'all' && (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span>
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
Status: {statusFilter}
<button
onClick={() => handleStatusFilterChange('all')}
className="text-info-600 hover:text-info-800 cursor-pointer"
>
</button>
</span>
<Button
variant="secondary"
size="sm"
onClick={() => {
handleStatusFilterChange('all');
setGlobalFilter('');
}}
className="text-sm text-neutral-600"
>
Clear All
</Button>
</div>
)} */}
{/* Loading & results */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
<span className="ml-3 text-neutral-600">
Loading submissions...
</span>
</div>
) : filteredData.length > 0 ? (
<>
<div className="text-sm text-neutral-600">
Showing {filteredData.length} of {totalData} submissions (Page{' '}
{currentPage} of {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">(Updating...)</span>
)}
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination={true}
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="text-center py-12 text-neutral-500">
No submissions found. Try adjusting your filters.
</div>
)}
</section>
{/* Submission Modal */}
{selectedSubmission && (
<SubmissionModal
isOpen={showSubmissionModal}
onClose={handleCloseSubmissionModal}
submission={selectedSubmission}
/>
)}
</BackofficeWrapper>
);
};
export default HackathonSubmissionsPage;
@@ -0,0 +1,514 @@
import { FC, useState, useEffect, useMemo, useRef } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { CityFilterSelect } from '../../../../components/city-filter-select';
import TeamBannerPlaceholder from './team-banner-placeholder';
import { cn } from '@imphnen-frontend-service/utils';
import { TAdminTeamItem } from '@imphnen-frontend-service/service';
import {
TeamOutlined,
CloseOutlined,
DeleteOutlined,
SaveOutlined,
CalendarOutlined,
CrownOutlined,
ExclamationOutlined,
UploadOutlined,
CameraOutlined,
EyeOutlined,
EyeInvisibleOutlined,
} from '@ant-design/icons';
type TeamType = TAdminTeamItem;
interface ModalProps {
isOpen: boolean;
onClose: () => void;
team: TeamType | null;
}
const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
const [formData, setFormData] = useState<TeamType | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showLogoMenu, setShowLogoMenu] = useState(false);
const logoInputRef = useRef<HTMLInputElement>(null);
const bannerInputRef = useRef<HTMLInputElement>(null);
// Initialize form data when modal opens
useEffect(() => {
if (isOpen) {
if (team) {
setFormData({ ...team });
} else {
setFormData({
id: '',
name: '',
description: '',
city: '',
banner: null,
logo: null,
visibility: 'public',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
leader_id: '',
});
}
}
}, [isOpen, team]);
// Check if form has changes
const hasChanges = useMemo(() => {
if (!formData || !team) return !!formData;
return (
formData.name !== team.name ||
formData.description !== team.description ||
formData.city !== team.city ||
formData.visibility !== team.visibility ||
formData.logo !== team.logo ||
formData.banner !== team.banner
);
}, [formData, team]);
// Check if required fields are filled
const isFormValid = useMemo(() => {
if (!formData) return false;
return (
formData.name.trim() !== '' &&
formData.city.trim() !== '' &&
formData.description.trim() !== ''
);
}, [formData]);
const canSave = hasChanges && isFormValid;
if (!isOpen || !formData) return null;
const handleInputChange = (field: keyof TeamType, value: string | null) => {
setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
};
const handleSave = () => {
console.log('Saving team:', formData);
onClose();
};
const handleDelete = () => {
if (!team) return;
console.log('Deleting team:', team.id);
setShowDeleteConfirm(false);
onClose();
};
const handleLogoUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
if (file.size > 5 * 1024 * 1024) {
alert('Image size must be less than 5MB');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const logoUrl = e.target?.result as string;
handleInputChange('logo', logoUrl);
setShowLogoMenu(false);
};
reader.readAsDataURL(file);
};
const handleBannerUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
if (file.size > 5 * 1024 * 1024) {
alert('Image size must be less than 5MB');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const bannerUrl = e.target?.result as string;
handleInputChange('banner', bannerUrl);
};
reader.readAsDataURL(file);
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
<TeamOutlined className="text-primary-600 text-lg" />
</div>
<div>
<h2 className="text-xl font-semibold text-neutral-900">
{team ? 'Team Details' : 'Create New Team'}
</h2>
<p className="text-sm text-neutral-500">
{team
? 'View and manage team information'
: 'Add a new team to the hackathon'}
</p>
</div>
</div>
<button
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors cursor-pointer"
onClick={() => {
setShowLogoMenu(false);
onClose();
}}
>
<CloseOutlined className="text-neutral-400 text-lg" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6" onClick={() => setShowLogoMenu(false)}>
<input
type="file"
ref={logoInputRef}
onChange={handleLogoUpload}
accept="image/*"
className="hidden"
/>
<input
type="file"
ref={bannerInputRef}
onChange={handleBannerUpload}
accept="image/*"
className="hidden"
/>
{/* Banner Section */}
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Team Banner{' '}
<span className="text-xs text-neutral-500">
(3:1 aspect ratio recommended)
</span>
</label>
<div className="relative group">
<TeamBannerPlaceholder
banner={formData.banner || undefined}
teamName={formData.name || 'Team Name'}
className="rounded-lg border border-neutral-200 transition-all group-hover:border-primary-300"
/>
<div className="absolute inset-0 bg-black/40 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
bannerInputRef.current?.click();
}}
className="bg-white/90 hover:bg-white text-neutral-700 border-transparent shadow-sm gap-2"
>
<UploadOutlined className="text-sm" />
{formData.banner ? 'Change Banner' : 'Add Banner'}
</Button>
{formData.banner && (
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleInputChange('banner', null);
}}
className="bg-white/90 hover:bg-white text-red-600 border-transparent shadow-sm hover:text-red-700 gap-2"
>
<DeleteOutlined className="text-sm" />
Delete
</Button>
)}
</div>
</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">
Logo
</label>
<div className="relative group">
<div className="w-24 h-24 rounded-full bg-neutral-100 flex items-center justify-center overflow-hidden border border-neutral-200 group-hover:border-primary-300 transition-colors">
{formData.logo ? (
<img
src={formData.logo}
alt={formData.name || 'Team Logo'}
className="w-full h-full object-cover"
/>
) : (
<TeamOutlined className="text-neutral-400 text-xl" />
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
setShowLogoMenu(!showLogoMenu);
}}
className="absolute inset-0 bg-neutral-300/80 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center w-24 h-24"
>
<CameraOutlined className="text-white text-lg" />
</button>
{showLogoMenu && (
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10">
<button
onClick={(e) => {
e.stopPropagation();
logoInputRef.current?.click();
}}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-2 cursor-pointer"
>
<UploadOutlined className="text-sm" />
{formData.logo ? 'Change Logo' : 'Upload Logo'}
</button>
{formData.logo && (
<button
onClick={(e) => {
e.stopPropagation();
handleInputChange('logo', null);
setShowLogoMenu(false);
}}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer"
>
<DeleteOutlined className="text-sm" />
Remove Logo
</button>
)}
</div>
)}
</div>
</div>
<div className="col-span-10 space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Team Name <span className="text-danger-500">*</span>
</label>
<input
type="text"
className="w-full border border-neutral-200 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
placeholder="Enter team name"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
/>
</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>
</label>
<textarea
className="w-full border border-neutral-200 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none resize-none"
placeholder="Enter team description"
rows={3}
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
/>
</div>
{/* City */}
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
City <span className="text-danger-500">*</span>
</label>
<CityFilterSelect
value={formData.city || 'all'}
onChange={(city) =>
handleInputChange('city', city === 'all' ? '' : city)
}
className="w-full"
placeholder="Search cities..."
allOptionLabel="Select a city"
filterIcon={false}
/>
</div>
{/* Visibility */}
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Team Visibility
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="visibility"
value="public"
checked={formData.visibility === 'public'}
onChange={(e) =>
handleInputChange('visibility', e.target.value)
}
className="text-primary-600"
/>
<EyeOutlined className="text-info-600" />
<span className="text-sm">Public</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="visibility"
value="private"
checked={formData.visibility === 'private'}
onChange={(e) =>
handleInputChange('visibility', e.target.value)
}
className="text-primary-600"
/>
<EyeInvisibleOutlined className="text-neutral-600" />
<span className="text-sm">Private</span>
</label>
</div>
</div>
{/* Team Details */}
{team && (
<div className="space-y-4 border-t border-neutral-200 pt-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Team Leader ID
</label>
<div className="p-3 bg-neutral-50 rounded-lg flex items-center gap-3">
<CrownOutlined className="text-yellow-600 text-lg" />
<span className="text-sm text-neutral-700 font-mono">
{team.leader_id}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Created
</label>
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
<CalendarOutlined className="text-neutral-500" />
<span className="text-sm text-neutral-700">
{new Date(team.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Last Updated
</label>
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
<CalendarOutlined className="text-neutral-500" />
<span className="text-sm text-neutral-700">
{new Date(team.updated_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
</div>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-neutral-200">
<div>
{team && (
<Button
variant="danger"
size="md"
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-2"
>
<DeleteOutlined />
Delete Team
</Button>
)}
</div>
<div className="flex items-center gap-3">
<Button variant="secondary" size="md" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
size="md"
onClick={handleSave}
disabled={!canSave}
className="flex items-center gap-2"
>
<SaveOutlined />
{team ? 'Save Changes' : 'Create Team'}
</Button>
</div>
</div>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-60 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-full bg-danger-100 flex items-center justify-center">
<ExclamationOutlined className="text-danger-600 text-xl" />
</div>
<div>
<h3 className="text-lg font-semibold text-neutral-900">
Delete Team
</h3>
<p className="text-sm text-neutral-500">
This action cannot be undone.
</p>
</div>
</div>
<p className="text-sm text-neutral-700 mb-6">
Are you sure you want to delete "{team?.name}"? This will
permanently remove the team and all associated data.
</p>
<div className="flex items-center gap-3 justify-end">
<Button
variant="secondary"
size="md"
onClick={() => setShowDeleteConfirm(false)}
>
Cancel
</Button>
<Button
variant="danger"
size="md"
onClick={handleDelete}
className="flex items-center gap-2"
>
<DeleteOutlined />
Delete Team
</Button>
</div>
</div>
</div>
)}
</div>
);
};
export default ModalTeamDetail;
@@ -0,0 +1,82 @@
import { FC } from 'react';
import { TeamOutlined } from '@ant-design/icons';
import { cn } from '@imphnen-frontend-service/utils';
interface TeamBannerPlaceholderProps {
banner?: string;
teamName: string;
className?: string;
showPlaceholder?: boolean;
}
const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
banner,
teamName,
className = '',
showPlaceholder = true,
}) => {
const aspectRatioClass = 'aspect-[3/1]'; // 3:1 aspect ratio
if (!banner && !showPlaceholder) {
return null;
}
if (banner) {
return (
<div
className={cn(
'w-full bg-gray-100 overflow-hidden relative',
aspectRatioClass,
className
)}
>
<img
src={banner}
alt={`${teamName} banner`}
className="w-full h-full object-cover"
onError={(e) => {
// Fallback to placeholder if image fails to load
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const placeholder = target.nextElementSibling as HTMLElement;
if (placeholder) {
placeholder.style.display = 'flex';
}
}}
/>
{/* Fallback placeholder (hidden by default, shown on image error) */}
<div
className={cn(
'absolute inset-0 bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
'hidden' // Hidden by default
)}
>
<div className="text-center">
<TeamOutlined className="text-4xl text-gray-400 mb-2" />
<p className="text-sm text-gray-500 font-medium">{teamName}</p>
<p className="text-xs text-gray-400">Team Banner</p>
</div>
</div>
</div>
);
}
// No banner - show placeholder
return (
<div
className={cn(
'w-full bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
aspectRatioClass,
className
)}
>
<div className="text-center">
<TeamOutlined className="text-4xl text-gray-400 mb-2" />
<p className="text-sm text-gray-500 font-medium">{teamName}</p>
<p className="text-xs text-gray-400">No Banner</p>
</div>
</div>
);
};
export default TeamBannerPlaceholder;
@@ -0,0 +1,447 @@
import {
FC,
ReactElement,
useState,
useEffect,
useMemo,
useCallback,
} from 'react';
import ModalTeamDetail from './_components/modal-team-detail-new';
import { CityFilterSelect } from '../../../components/city-filter-select';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import {
EditOutlined,
TeamOutlined,
SearchOutlined,
FilterOutlined,
PlusOutlined,
LoadingOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import {
getAdminTeams,
TAdminTeamItem,
} from '@imphnen-frontend-service/service';
import { useSearchParams } from 'react-router-dom';
type TeamType = TAdminTeamItem;
export const HackathonTeamsPage: FC = (): ReactElement => {
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = Math.max(
1,
parseInt(searchParams.get('page') || '1', 10)
);
const searchQuery = searchParams.get('search') || '';
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showNewTeamModal, setShowNewTeamModal] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<TeamType | null>(null);
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,
isFetching,
} = useQuery({
queryKey: [
'admin-teams',
currentPage,
perPage,
cityFilter,
visibilityFilter,
searchQuery,
],
queryFn: () =>
getAdminTeams({
page: currentPage,
per_page: perPage,
search: searchQuery || undefined,
}),
staleTime: 30000, // 30 seconds cache
gcTime: 5 * 60 * 1000, // 5 minutes
});
const totalData = teamsResponse?.meta?.total_data || 0;
const totalPages = teamsResponse?.meta?.total_page || 1;
// Handle page change - update URL query params
const handlePageChange = useCallback(
(newPage: number) => {
const params = new URLSearchParams();
params.set('page', newPage.toString());
if (perPage !== 10) params.set('per_page', perPage.toString());
if (searchQuery) params.set('search', searchQuery);
setSearchParams(params);
window.scrollTo({ top: 0, behavior: 'smooth' });
},
[setSearchParams, perPage, searchQuery]
);
// Validate page number doesn't exceed total pages
useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
setSearchParams({ page: totalPages.toString() });
}
}, [currentPage, totalPages, setSearchParams, isLoading]);
// Sync globalFilter with URL search param on mount
useEffect(() => {
setGlobalFilter(searchQuery);
}, [searchQuery]);
// Handle search teams
const handleSearch = useCallback(() => {
const params = new URLSearchParams();
params.set('page', '1');
if (perPage !== 10) params.set('per_page', perPage.toString());
if (globalFilter.trim()) {
params.set('search', globalFilter.trim());
}
setSearchParams(params);
}, [globalFilter, setSearchParams, perPage]);
// Handle Enter key press in search input
const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
},
[handleSearch]
);
// Handle per page change
const handlePerPageChange = useCallback(
(newPerPage: number) => {
const params = new URLSearchParams();
params.set('page', '1');
params.set('per_page', newPerPage.toString());
if (searchQuery) params.set('search', searchQuery);
setSearchParams(params);
},
[setSearchParams, searchQuery]
);
// Memoize the callback to prevent recreation
const handleShowDetailModal = useCallback((team: TeamType) => {
setSelectedTeam(team);
setShowDetailModal(true);
}, []);
const handleCloseDetailModal = useCallback(() => {
setShowDetailModal(false);
setSelectedTeam(null);
}, []);
const handleShowNewTeamModal = useCallback(() => {
setShowNewTeamModal(true);
}, []);
const handleCloseNewTeamModal = useCallback(() => {
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(
() => [
{
accessorKey: 'name',
header: 'Team',
cell: ({ row }) => {
const team = row.original;
return (
<div className="flex items-center gap-3">
{/* Team Logo */}
<div className="w-10 h-10 rounded-full bg-neutral-100 flex items-center justify-center shrink-0 overflow-hidden">
{team.logo ? (
<img
src={team.logo}
alt={team.name}
className="w-full h-full object-cover"
/>
) : (
<TeamOutlined className="text-neutral-400 text-lg" />
)}
</div>
{/* Team Name */}
<div className="min-w-0 flex-1">
<p
className="font-medium text-neutral-900 truncate max-w-sm"
title={team.name}
>
{team.name}
</p>
</div>
</div>
);
},
enableSorting: true,
},
{
accessorKey: 'city',
header: 'City',
cell: ({ row }) => (
<span className="text-neutral-700">{row.original.city}</span>
),
enableSorting: true,
},
{
accessorKey: 'visibility',
header: 'Visibility',
cell: ({ row }) => {
const isPublic = row.original.visibility === 'public';
return (
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
isPublic
? 'bg-success-100 text-success-800'
: 'bg-neutral-100 text-neutral-700'
)}
>
{isPublic ? 'Public' : 'Private'}
</span>
);
},
enableSorting: true,
},
{
id: 'leader',
header: 'Leader ID',
cell: ({ row }) => (
<div className="text-sm text-neutral-700 font-mono">
{row.original.leader_id}
</div>
),
enableSorting: false,
},
{
accessorKey: 'created_at',
header: 'Created',
cell: ({ row }) => (
<span className="text-neutral-900 text-sm">
{new Date(row.original.created_at).toLocaleDateString('en-UK', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
),
enableSorting: true,
sortingFn: 'datetime',
},
{
id: 'actions',
header: 'Actions',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 text-sm px-4 py-2"
onClick={() => handleShowDetailModal(row.original)}
>
<EditOutlined className="text-sm" />
Manage
</Button>
</div>
),
enableSorting: false,
},
],
[handleShowDetailModal]
);
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
Team Management
</h1>
{/* Filters and actions */}
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
<div className="flex flex-wrap gap-3 items-center justify-between">
{/* Left side - Search & filters */}
<div className="flex flex-wrap gap-3 items-center">
{/* Search bar */}
<div className="relative">
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
<input
type="text"
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
placeholder="Search teams by name or city..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
onKeyPress={handleSearchKeyPress}
/>
</div>
{/* Per Page Dropdown */}
<div className="relative">
<select
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={perPage}
onChange={(e) =>
handlePerPageChange(parseInt(e.target.value, 10))
}
>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
<option value={100}>100 / page</option>
</select>
</div>
{/* 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"
size="md"
className="flex items-center gap-2 px-4 py-2"
onClick={handleShowNewTeamModal}
>
<PlusOutlined className="text-sm" />
Add Team
</Button>
</div>
</div>
{/* Active filters display */}
{(visibilityFilter !== 'all' || cityFilter !== 'all') && (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span>
{/* Visibility filter badge */}
{visibilityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
Visibility: {visibilityFilter}
<button
onClick={() => setVisibilityFilter('all')}
className="text-info-600 hover:text-info-800 cursor-pointer"
>
</button>
</span>
)}
{/* City filter badge */}
{cityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
City: {cityFilter}
<button
onClick={() => setCityFilter('all')}
className="text-green-600 hover:text-green-800 cursor-pointer"
>
</button>
</span>
)}
{/* Clear all filters */}
<Button
variant="secondary"
size="sm"
onClick={() => {
setVisibilityFilter('all');
setCityFilter('all');
setGlobalFilter('');
}}
className="text-sm text-neutral-600"
>
Clear All
</Button>
</div>
)}
{/* Loading & results display */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
<span className="ml-3 text-neutral-600">Loading teams...</span>
</div>
) : filteredData.length > 0 ? (
<>
<div className="text-sm text-neutral-600">
Showing {filteredData.length} of {totalData} teams (Page{' '}
{currentPage} of {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">(Updating...)</span>
)}
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination={true}
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="text-center py-12 text-neutral-500">
No teams found. Try adjusting your filters.
</div>
)}
</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
/>
</BackofficeWrapper>
);
};
export default HackathonTeamsPage;
@@ -0,0 +1,655 @@
import { FC, useState, useEffect, useMemo, useRef } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import {
UserOutlined,
EnvironmentOutlined,
CalendarOutlined,
SaveOutlined,
CloseOutlined,
ExclamationOutlined,
CameraOutlined,
DeleteOutlined,
UploadOutlined,
} from '@ant-design/icons';
interface UserType {
id: string;
avatar?: string | null;
fullname: string;
bio?: string;
location: string | null;
is_active: boolean;
skills: string[];
created_at: string;
updated_at: string;
}
interface ModalProps {
isOpen: boolean;
onClose: () => void;
user: UserType | null;
}
const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
const [formData, setFormData] = useState<UserType | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showAvatarMenu, setShowAvatarMenu] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Initialize form data when modal opens
useEffect(() => {
if (isOpen) {
if (user) {
// Edit existing user
setFormData({ ...user });
} else {
// Create new user
setFormData({
id: '', // Will be generated by backend
fullname: '',
bio: '',
location: '',
is_active: true,
skills: [],
avatar: undefined,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
}
}
}, [isOpen, user]);
// Check if form has changes
const hasChanges = useMemo(() => {
if (!formData) return false;
if (!user) return true; // New user always has changes
return (
formData.fullname !== user.fullname ||
formData.location !== user.location ||
formData.is_active !== user.is_active ||
formData.avatar !== user.avatar ||
JSON.stringify(formData.skills) !== JSON.stringify(user.skills) ||
formData.bio !== user.bio
);
}, [formData, user]);
// Check if required fields are filled
const isFormValid = useMemo(() => {
if (!formData) return false;
return formData.fullname?.trim() !== '' && formData.location?.trim() !== '';
}, [formData]);
const canSave = hasChanges && isFormValid;
if (!isOpen || !formData) return null;
const handleInputChange = (
field: keyof UserType,
value: string | boolean | string[] | undefined
) => {
setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
};
const handleSkillsChange = (skills: string[]) => {
setFormData((prev) => (prev ? { ...prev, skills } : null));
};
const handleSave = () => {
if (!formData) return;
if (user) {
// Update existing user
console.log('Update user data:', formData);
} else {
// Create new user
console.log('Create new user:', formData);
}
// Here you would typically make an API call to save the data
onClose();
};
const handleCancel = () => {
if (user) {
setFormData({ ...user }); // Reset to original for edit mode
}
onClose();
};
const handleDeleteAccount = () => {
if (!user) return; // Can't delete new user
console.log('Delete user:', user.id);
setShowDeleteConfirm(false);
onClose();
};
const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
// Validate file type
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
alert('Image size must be less than 5MB');
return;
}
// Create preview URL
const reader = new FileReader();
reader.onload = (e) => {
const avatarUrl = e.target?.result as string;
handleInputChange('avatar', avatarUrl);
setShowAvatarMenu(false);
};
reader.readAsDataURL(file);
}
};
const handleRemoveAvatar = () => {
handleInputChange('avatar', undefined);
setShowAvatarMenu(false);
};
const triggerFileUpload = () => {
fileInputRef.current?.click();
};
const availableSkills = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
return (
<div className="fixed inset-0 z-50">
<div
className="fixed inset-0 bg-black/50"
onClick={(e) => {
setShowAvatarMenu(false);
onClose();
}}
/>
<div className="fixed inset-0 flex items-center justify-center p-4">
<div
className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Hidden File Input */}
<input
type="file"
ref={fileInputRef}
onChange={handleAvatarUpload}
accept="image/*"
className="hidden"
/>
{/* Header */}
<div className="border-b border-neutral-200 px-8 py-6 flex justify-between items-start">
<div className="flex items-center gap-4">
{/* Interactive User Avatar */}
<div className="relative group ">
<div className="w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden border-2 border-transparent group-hover:border-primary-300 transition-colors">
{formData.avatar ? (
<img
src={formData.avatar}
alt={formData.fullname}
className="w-full h-full object-cover"
/>
) : (
<UserOutlined className="text-neutral-500 text-2xl" />
)}
</div>
{/* Avatar Hover Overlay */}
<button
onClick={() => setShowAvatarMenu(!showAvatarMenu)}
className="absolute inset-0 bg-neutral-400 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<CameraOutlined className="text-white text-lg" />
</button>
{/* Avatar Menu Dropdown */}
{showAvatarMenu && (
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10">
<button
onClick={triggerFileUpload}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-2 cursor-pointer"
>
<UploadOutlined className="text-sm" />
{formData.avatar ? 'Change Photo' : 'Upload Photo'}
</button>
{formData.avatar && (
<button
onClick={handleRemoveAvatar}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer"
>
<DeleteOutlined className="text-sm" />
Remove Photo
</button>
)}
</div>
)}
</div>
<div>
<div className="flex items-center gap-3 mb-2">
<h2 className="text-2xl font-bold text-neutral-900">
{user ? 'Edit User Profile' : 'Create New User'}
</h2>
{user && (
<span className="px-3 py-1 bg-info-100 text-info-700 text-xs font-medium rounded-2xl">
Hover avatar to change
</span>
)}
</div>
<div className="text-sm text-neutral-500">
{user
? `Make changes to ${
formData.fullname || 'this user'
}'s profile information`
: 'Fill in the information below to create a new user account'}
</div>
</div>
</div>
<button
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors cursor-pointer"
onClick={() => {
setShowAvatarMenu(false);
handleCancel();
}}
>
<CloseOutlined className="text-neutral-400 text-lg" />
</button>
</div>
{/* Content */}
<div className="p-8" onClick={() => setShowAvatarMenu(false)}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Left Column - Basic Info */}
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
Basic Information
</h3>
<div className="space-y-4">
{/* Full Name - Required */}
<div className="flex items-center gap-3">
<UserOutlined className="text-neutral-400" />
<div className="flex-1">
<label className="text-sm text-neutral-500 block mb-1">
Full Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.fullname}
onChange={(e) =>
handleInputChange('fullname', e.target.value)
}
className={cn(
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none',
!formData.fullname ||
formData.fullname.trim() === ''
? 'border-red-300 bg-red-50'
: 'border-neutral-300'
)}
placeholder="Enter full name"
/>
{(!formData.fullname ||
formData.fullname.trim() === '') && (
<p className="text-red-500 text-xs mt-1">
Full name is required
</p>
)}
</div>
</div>
{/* Location - Required */}
<div className="flex items-center gap-3">
<EnvironmentOutlined className="text-neutral-400" />
<div className="flex-1">
<label className="text-sm text-neutral-500 block mb-1">
Location <span className="text-red-500">*</span>
</label>
<select
value={formData.location || ''}
onChange={(e) =>
handleInputChange('location', e.target.value)
}
className={cn(
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white',
!formData.location ||
formData.location.trim() === ''
? 'border-red-300 bg-red-50'
: 'border-neutral-300'
)}
>
<option value="">Select location</option>
<option value="Jakarta">Jakarta</option>
<option value="Bandung">Bandung</option>
<option value="Surabaya">Surabaya</option>
<option value="Medan">Medan</option>
<option value="Yogyakarta">Yogyakarta</option>
</select>
{(!formData.location ||
formData.location.trim() === '') && (
<p className="text-red-500 text-xs mt-1">
Location is required
</p>
)}
</div>
</div>
{/* Joined Date - Read Only - Only show for existing users */}
{user && (
<div className="flex items-center gap-3">
<CalendarOutlined className="text-neutral-400" />
<div>
<p className="text-sm text-neutral-500">
Joined Date
</p>
<p className="font-medium">
{new Date(formData.created_at).toLocaleDateString(
'en-US',
{
year: 'numeric',
month: 'long',
day: 'numeric',
}
)}
</p>
</div>
</div>
)}
</div>
</div>
{/* Bio Section - Optional */}
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-3">
Bio{' '}
<span className="text-neutral-400 text-sm font-normal">
(Optional)
</span>
</h3>
<textarea
value={formData.bio || ''}
onChange={(e) =>
handleInputChange('bio', e.target.value || undefined)
}
placeholder="Tell us about yourself..."
rows={4}
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none resize-none"
/>
</div>
</div>
{/* Right Column - Skills & Status */}
<div className="space-y-6">
{/* Account Status - Enhanced Tab Design */}
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
Account Status
</h3>
<div className="flex bg-neutral-100 p-1 rounded-lg">
<button
onClick={() => handleInputChange('is_active', true)}
className={cn(
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
formData.is_active
? 'bg-white text-success-700 shadow-sm ring-1 ring-success-200'
: 'text-neutral-600 hover:text-neutral-800'
)}
>
<div className="flex items-center justify-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
formData.is_active
? 'bg-success-500'
: 'bg-neutral-400'
)}
/>
Active
</div>
</button>
<button
onClick={() => handleInputChange('is_active', false)}
className={cn(
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
!formData.is_active
? 'bg-white text-neutral-700 shadow-sm ring-1 ring-neutral-200'
: 'text-neutral-600 hover:text-neutral-800'
)}
>
<div className="flex items-center justify-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
!formData.is_active
? 'bg-neutral-500'
: 'bg-neutral-400'
)}
/>
Inactive
</div>
</button>
</div>
<p className="text-xs text-neutral-500 mt-2">
{formData.is_active
? 'User can access their account and participate in activities'
: 'User account is suspended and cannot access services'}
</p>
</div>
{/* Skills Section - Optional */}
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
Skills & Expertise{' '}
<span className="text-neutral-400 text-sm font-normal">
(Optional)
</span>
</h3>
<div className="space-y-3">
<div className="flex flex-wrap gap-2 min-h-10 p-3 border border-neutral-300 rounded-lg bg-neutral-50">
{formData.skills.length > 0 ? (
formData.skills.map((skill, index) => (
<span
key={index}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-2xl text-sm font-medium bg-blue-100 text-blue-800"
>
{skill}
<button
onClick={() =>
handleSkillsChange(
formData.skills.filter((_, i) => i !== index)
)
}
className="text-blue-600 hover:text-blue-800 ml-1 cursor-pointer"
>
</button>
</span>
))
) : (
<span className="text-neutral-400 text-sm">
No skills added yet
</span>
)}
</div>
<select
value=""
onChange={(e) => {
if (
e.target.value &&
!formData.skills.includes(e.target.value)
) {
handleSkillsChange([
...formData.skills,
e.target.value,
]);
}
}}
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white"
>
<option value="">Add a skill...</option>
{availableSkills
.filter((skill) => !formData.skills.includes(skill))
.map((skill) => (
<option key={skill} value={skill}>
{skill}
</option>
))}
</select>
</div>
</div>
{/* Account Details - Read Only - Only show for existing users */}
{user && (
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
Account Details
</h3>
<div className="space-y-3 bg-neutral-50 p-4 rounded-lg">
<div className="flex justify-between items-center py-1">
<span className="text-neutral-600 text-sm">
User ID
</span>
<span className="font-mono text-sm text-neutral-800">
{formData.id}
</span>
</div>
<div className="flex justify-between items-center py-1">
<span className="text-neutral-600 text-sm">
Last Updated
</span>
<span className="text-sm text-neutral-800">
{new Date(formData.updated_at).toLocaleDateString(
'en-US',
{
month: 'short',
day: 'numeric',
year: 'numeric',
}
)}
</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* Footer Actions */}
<div className="border-t border-neutral-200 px-8 py-6">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<div className="text-sm text-neutral-500">
{canSave
? 'Ready to save changes'
: hasChanges
? 'Please fill required fields'
: 'No changes made'}
</div>
{/* Delete Account Button - Only show for existing users */}
{user && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="text-red-600 hover:text-red-700 text-sm font-medium transition-colors cursor-pointer"
>
Delete Account
</button>
)}
</div>
<div className="flex items-center gap-3">
<Button
variant="secondary"
size="sm"
onClick={handleCancel}
className="px-6"
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSave}
disabled={!canSave}
className={cn(
'flex items-center gap-2 px-6',
!canSave && 'opacity-50 cursor-not-allowed'
)}
>
<SaveOutlined className="text-sm" />
{user ? 'Save Changes' : 'Create User'}
</Button>
</div>
</div>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 z-60">
<div
className="fixed inset-0 bg-black/50"
onClick={() => setShowDeleteConfirm(false)}
/>
<div className="fixed inset-0 flex items-center justify-center p-4">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md">
<div className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
<ExclamationOutlined className="text-red-600 text-lg" />
</div>
<div>
<h3 className="text-lg font-semibold text-neutral-900">
Delete Account
</h3>
<p className="text-sm text-neutral-500">
This action cannot be undone
</p>
</div>
</div>
<p className="text-neutral-700 mb-6">
Are you sure you want to permanently delete{' '}
<strong>{formData.fullname}</strong>'s account? This will
remove all their data and cannot be reversed.
</p>
<div className="flex gap-3 justify-end">
<Button
variant="secondary"
size="sm"
onClick={() => setShowDeleteConfirm(false)}
className="px-4"
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={handleDeleteAccount}
className="px-4 bg-red-600 hover:bg-red-700 border-red-600"
>
Delete Account
</Button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default ModalUserDetail;
@@ -0,0 +1,573 @@
import {
FC,
ReactElement,
useState,
useEffect,
useMemo,
useCallback,
} from 'react';
import ModalUserDetail from './_components/modal-user-detail';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import {
EditOutlined,
UserOutlined,
SearchOutlined,
FilterOutlined,
PlusOutlined,
LoadingOutlined,
} from '@ant-design/icons';
import { CityFilterSelect } from '../../../components/city-filter-select';
import { useQuery } from '@tanstack/react-query';
import {
getAdminUsers,
TAdminUserItem,
} from '@imphnen-frontend-service/service';
import { useSearchParams } from 'react-router-dom';
type UserType = TAdminUserItem;
// Skills options for filter
const skillsOptions = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
export const HackathonUsersPage: FC = (): ReactElement => {
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = Math.max(
1,
parseInt(searchParams.get('page') || '1', 10)
);
const searchQuery = searchParams.get('search') || '';
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showNewUserModal, setShowNewUserModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
const [globalFilter, setGlobalFilter] = useState(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,
isFetching,
} = useQuery({
queryKey: [
'admin-users',
currentPage,
perPage,
cityFilter,
statusFilter,
searchQuery,
],
queryFn: () =>
getAdminUsers({
page: currentPage,
per_page: perPage,
search: searchQuery || undefined,
}),
staleTime: 30000, // 30 seconds cache
gcTime: 5 * 60 * 1000, // 5 minutes
});
const totalData = usersResponse?.meta?.total_data || 0;
const totalPages = usersResponse?.meta?.total_page || 1;
// Handle page change - update URL query params
const handlePageChange = useCallback(
(newPage: number) => {
const params = new URLSearchParams();
params.set('page', newPage.toString());
if (perPage !== 10) params.set('per_page', perPage.toString());
if (searchQuery) params.set('search', searchQuery);
setSearchParams(params);
window.scrollTo({ top: 0, behavior: 'smooth' });
},
[setSearchParams, perPage, searchQuery]
);
// Validate page number doesn't exceed total pages
useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
setSearchParams({ page: totalPages.toString() });
}
}, [currentPage, totalPages, setSearchParams, isLoading]);
// Sync globalFilter with URL search param on mount
useEffect(() => {
setGlobalFilter(searchQuery);
}, [searchQuery]);
// Handle search users
const handleSearch = useCallback(() => {
const params = new URLSearchParams();
params.set('page', '1');
if (perPage !== 10) params.set('per_page', perPage.toString());
if (globalFilter.trim()) {
params.set('search', globalFilter.trim());
}
setSearchParams(params);
}, [globalFilter, setSearchParams, perPage]);
// Handle Enter key press in search input
const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
},
[handleSearch]
);
// Handle per page change
const handlePerPageChange = useCallback(
(newPerPage: number) => {
const params = new URLSearchParams();
params.set('page', '1');
params.set('per_page', newPerPage.toString());
if (searchQuery) params.set('search', searchQuery);
setSearchParams(params);
},
[setSearchParams, searchQuery]
);
// Memoize the callback to prevent recreation
const handleShowDetailModal = useCallback((user: UserType) => {
setSelectedUser(user);
setShowDetailModal(true);
}, []);
const handleCloseDetailModal = useCallback(() => {
setShowDetailModal(false);
setSelectedUser(null);
}, []);
const handleShowNewUserModal = useCallback(() => {
setShowNewUserModal(true);
}, []);
const handleCloseNewUserModal = useCallback(() => {
setShowNewUserModal(false);
}, []);
// Filter data based on current filter states
const filteredData = useMemo(() => {
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) =>
userSkills.includes(skill)
);
if (!hasMatchingSkill) return false;
}
return true;
});
}, [usersResponse, statusFilter, skillsFilter]);
// Memoize columns to prevent recreation on every render
const columns: ColumnDef<UserType>[] = useMemo(
() => [
{
accessorKey: 'fullname',
header: 'User',
cell: ({ row }) => (
<div className="flex items-center gap-3">
{/* Avatar */}
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
{row.original.avatar ? (
<img
src={row.original.avatar}
alt={row.original.fullname}
className="w-full h-full object-cover"
/>
) : (
<UserOutlined className="text-neutral-500 text-lg" />
)}
</div>
{/* Name only */}
<div className="min-w-0 flex-1">
<p className="font-medium text-neutral-900 truncate">
{row.original.fullname}
</p>
</div>
</div>
),
enableSorting: true,
},
{
accessorKey: 'skills',
header: 'Skills',
cell: ({ row }) => {
const skills = row.original.skills || [];
return (
<div className="flex flex-wrap gap-1 max-w-xs">
{skills.length > 0 ? (
<>
{skills.slice(0, 2).map((skill, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
>
{skill.replace(' Developer', '').replace(' Engineer', '')}
</span>
))}
{skills.length > 2 && (
<span className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-200 text-success-700">
+{skills.length - 2}
</span>
)}
</>
) : (
<span className="text-neutral-400">-</span>
)}
</div>
);
},
enableSorting: false,
},
{
accessorKey: 'location',
header: 'Location',
cell: ({ row }) => (
<span className="text-neutral-700">{row.original.location}</span>
),
enableSorting: true,
},
{
accessorKey: 'is_active',
header: 'Status',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
)}
/>
<span
className={cn(
'text-sm font-medium',
row.original.is_active ? 'text-success-700' : 'text-neutral-500'
)}
>
{row.original.is_active ? 'Active' : 'Inactive'}
</span>
</div>
),
enableSorting: true,
sortingFn: (rowA, rowB) => {
const aActive = rowA.original.is_active;
const bActive = rowB.original.is_active;
if (aActive && !bActive) return -1;
if (!aActive && bActive) return 1;
return 0;
},
},
{
accessorKey: 'created_at',
header: 'Joined',
cell: ({ row }) => (
<span className="text-neutral-900 text-sm">
{new Date(row.original.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
),
enableSorting: true,
sortingFn: 'datetime',
},
{
id: 'actions',
header: 'Actions',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 text-sm px-4 py-2"
onClick={() => handleShowDetailModal(row.original)}
>
<EditOutlined className="text-sm" />
Manage
</Button>
{/* <Button
variant="secondary"
size="sm"
className="text-sm px-4 py-2"
onClick={() => {
// Toggle user status - implement later
console.log(`Toggle status for ${row.original.fullname}`);
}}
>
{row.original.is_active ? 'Deactivate' : 'Activate'}
</Button> */}
</div>
),
enableSorting: false,
},
],
[handleShowDetailModal]
);
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
User Management
</h1>
{/* Filters and actions */}
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
<div className="flex flex-wrap gap-3 items-center justify-between">
{/* Left side - Search & filters */}
<div className="flex flex-wrap gap-3 items-center">
{/* Search bar */}
<div className="relative">
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
<input
type="text"
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
placeholder="Search users by name or location..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
onKeyPress={handleSearchKeyPress}
/>
</div>
{/* Per Page Dropdown */}
<div className="relative">
<select
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={perPage}
onChange={(e) =>
handlePerPageChange(parseInt(e.target.value, 10))
}
>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
<option value={100}>100 / page</option>
</select>
</div>
{/* Status Filter */}
{/* <div className="relative">
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
<select
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-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"
size="md"
className="flex items-center gap-2 px-4 py-2"
onClick={handleShowNewUserModal}
>
<PlusOutlined className="text-sm" />
Add User
</Button>
</div>
</div>
{/* Active filters display */}
{(skillsFilter.length > 0 ||
statusFilter !== 'all' ||
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}
<button
onClick={() => setStatusFilter('all')}
className="text-info-600 hover:text-info-800 cursor-pointer"
>
</button>
</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}
<button
onClick={() => setCityFilter('all')}
className="text-green-600 hover:text-green-800 cursor-pointer"
>
</button>
</span>
)}
{/* Skills filter badges */}
{skillsFilter.map((skill) => (
<span
key={skill}
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm"
>
{skill.replace(' Developer', '').replace(' Engineer', '')}
<button
onClick={() =>
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
}
className="text-purple-600 hover:text-purple-800 cursor-pointer"
>
</button>
</span>
))}
{/* Clear all filters */}
<Button
variant="secondary"
size="sm"
onClick={() => {
setStatusFilter('all');
setCityFilter('all');
setSkillsFilter([]);
setGlobalFilter('');
}}
className="text-sm text-neutral-600"
>
Clear All
</Button>
</div>
)}
{/* Loading & results display */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
<span className="ml-3 text-neutral-600">Loading users...</span>
</div>
) : filteredData.length > 0 ? (
<>
<div className="text-sm text-neutral-600">
Showing {filteredData.length} of {totalData} users (Page{' '}
{currentPage} of {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">(Updating...)</span>
)}
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination={true}
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="text-center py-12 text-neutral-500">
No users found. Try adjusting your filters.
</div>
)}
</section>
{/* Modals component */}
<ModalUserDetail
isOpen={showDetailModal}
onClose={handleCloseDetailModal}
user={selectedUser}
/>
{/* New User Modal */}
<ModalUserDetail
isOpen={showNewUserModal}
onClose={handleCloseNewUserModal}
user={null} // null indicates creating new user
/>
</BackofficeWrapper>
);
};
export default HackathonUsersPage;
+40 -2
View File
@@ -1,13 +1,51 @@
import { FC, ReactElement } from 'react';
import { FC, ReactElement, useState } from 'react';
import { Outlet } from 'react-router-dom';
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
export const AppLayout: FC = (): ReactElement => {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
return (
<div className="bg-primary-50 min-h-screen flex justify-center">
<div className="bg-primary-50 min-h-screen w-full flex">
<BackofficeSidebar />
<BackofficeSidebar
isOpen={mobileSidebarOpen}
onClose={() => setMobileSidebarOpen(false)}
/>
<div className="flex-1 overflow-auto">
{/* Sticky top header */}
<header
className={
'lg:hidden sticky top-0 bg-white border-b border-primary-200 px-4 py-3 flex items-center gap-3 ' +
(mobileSidebarOpen ? 'z-0' : 'z-30')
}
>
{/* Mobile menu button (shown on small screens) */}
<button
type="button"
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700 cursor-pointer"
onClick={() => setMobileSidebarOpen(true)}
aria-label="Open sidebar"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<h1 className="text-p3 font-semibold text-primary-700">
IMPHNEN Backoffice
</h1>
</header>
<Outlet />
</div>
</div>
@@ -1,19 +1,29 @@
import { SearchOutlined } from "@ant-design/icons";
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
import { cn, For } from "@imphnen-frontend-service/utils";
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
import { ReactElement, useState } from "react";
import { ModalDetailUser } from "./_components/modal/detail";
import { SearchOutlined } from '@ant-design/icons';
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import { cn, For } from '@imphnen-frontend-service/utils';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import { ReactElement, useState } from 'react';
import { ModalDetailUser } from './_components/modal/detail';
type UserStatus = 'active' | 'inactive';
interface UserType {
id: number
name: string
email: string
rating: number
status: UserStatus
id: number;
name: string;
email: string;
rating: number;
status: UserStatus;
}
const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
@@ -22,15 +32,15 @@ const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
email: 'fullname23@gmail.com',
rating: 4.5,
status: i % 2 === 0 ? 'active' : 'inactive',
}))
}));
export default function Components(): ReactElement {
const TABS = ['mentor', 'mentee'] as const
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor')
const [showDetail, setShowDetail] = useState(false)
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
const TABS = ['mentor', 'mentee'] as const;
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor');
const [showDetail, setShowDetail] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
@@ -39,7 +49,7 @@ export default function Components(): ReactElement {
const columns: ColumnDef<UserType>[] = [
{
id: 'select',
meta: { cellClassName: cn("w-20") },
meta: { cellClassName: cn('w-20') },
header: ({ table }) => (
<input
type="checkbox"
@@ -97,7 +107,7 @@ export default function Components(): ReactElement {
},
{
header: 'Action',
meta: { cellClassName: cn("w-72") },
meta: { cellClassName: cn('w-72') },
cell: ({ row }) => (
<Button
variant="primary"
@@ -113,7 +123,7 @@ export default function Components(): ReactElement {
</Button>
),
},
]
];
const table = useReactTable({
data: mockData,
@@ -134,14 +144,19 @@ export default function Components(): ReactElement {
return (
<BackofficeWrapper title="Dimentorin.dev">
<div className="mb-8 flex justify-between items-center">
<h1 className="text-p1 font-semibold text-neutral-700">User Management</h1>
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">
User Management
</h1>
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
<For data={TABS}>
{(tab) => (
<Button
key={tab}
variant="text"
className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
className={cn(
'px-3 py-2 capitalize',
activeTab === tab && 'bg-white'
)}
onClick={() => setActiveTab(tab)}
>
{tab}
@@ -163,12 +178,16 @@ export default function Components(): ReactElement {
</div>
</div>
<Select>
<option selected disabled>Rating</option>
<option selected disabled>
Rating
</option>
<option value="4.5">4.5</option>
<option value="5">5</option>
</Select>
<Select>
<option selected disabled>Status</option>
<option selected disabled>
Status
</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</Select>
@@ -2,22 +2,39 @@ import { useForm } from 'react-hook-form';
import {
authLoginSchema,
TLoginRequest,
useBackofficeLogin,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useSession } from '@imphnen-frontend-service/utils';
import { useNavigate } from 'react-router';
import { toast } from 'sonner';
export const useLogin = () => {
const navigate = useNavigate();
const loginMutation = useBackofficeLogin();
const form = useForm<TLoginRequest>({
resolver: zodResolver(authLoginSchema),
mode: 'all',
defaultValues: {
email: '',
password: '',
},
});
const { signIn } = useSession();
const onSubmit = form.handleSubmit((data) => signIn(data));
const onSubmit = form.handleSubmit(async (data) => {
try {
await loginMutation.mutateAsync(data);
toast.success('Login berhasil!');
navigate('/hackathon-dashboard');
} catch (error) {
console.error('[Backoffice Login] Error:', error);
toast.error((error as Error).message || 'Login gagal');
}
});
return {
form,
onSubmit,
isLoading: loginMutation.isPending,
};
};
@@ -4,7 +4,7 @@ import { useLogin } from './_hooks/use-login';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
export const Components: FC = (): ReactElement => {
const { form, onSubmit } = useLogin();
const { form, onSubmit, isLoading } = useLogin();
return (
<div className="flex justify-center items-center min-h-screen">
@@ -22,6 +22,7 @@ export const Components: FC = (): ReactElement => {
name="email"
size="lg"
className="w-full"
disabled={isLoading}
/>
<ControlledInputField
control={form.control}
@@ -31,10 +32,11 @@ export const Components: FC = (): ReactElement => {
name="password"
size="lg"
className="w-full"
disabled={isLoading}
/>
<Button
disabled={
form.formState.isSubmitting ||
isLoading ||
form.formState.isValidating ||
!form.formState.isValid
}
@@ -42,7 +44,7 @@ export const Components: FC = (): ReactElement => {
size="md"
className="w-full"
>
Login
{isLoading ? 'Loading...' : 'Login'}
</Button>
</form>
</div>
+23
View File
@@ -0,0 +1,23 @@
import { Link } from 'react-router-dom';
export default function NotFoundPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<h1 className="text-9xl font-bold text-gray-200 mb-4">404</h1>
<h2 className="text-3xl font-semibold text-gray-900 dark:text-gray-300 mb-4">
Page Not Found
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
The page you are looking for doesn't exist or has been moved.
</p>
<Link
to="/hackathon-dashboard"
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
Go Back Home
</Link>
</div>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
export default function ErrorPage() {
const error = useRouteError();
let errorMessage: string;
if (isRouteErrorResponse(error)) {
errorMessage = error.statusText;
} else if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === 'string') {
errorMessage = error;
} else {
console.error(error);
errorMessage = 'Unknown error';
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-6xl font-bold text-red-600 mb-4">Oops!</h1>
<p className="text-xl text-gray-700 mb-2">
Sorry, an unexpected error has occurred.
</p>
<p className="text-gray-500 italic">{errorMessage}</p>
</div>
</div>
);
}
@@ -0,0 +1,148 @@
import { FC, useState, useRef, useEffect } from 'react';
import { FilterOutlined } from '@ant-design/icons';
import INDONESIAN_CITIES from '../constants/cities';
interface CityFilterSelectProps {
value: string;
onChange: (value: string) => void;
className?: string;
placeholder?: string;
allOptionLabel?: string;
filterIcon?: boolean;
}
export const CityFilterSelect: FC<CityFilterSelectProps> = ({
value,
onChange,
className = '',
placeholder = 'Search cities...',
allOptionLabel = 'All Cities',
filterIcon = true,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Filter cities based on search query
const filteredCities = INDONESIAN_CITIES.filter((city) =>
city.toLowerCase().includes(searchQuery.toLowerCase())
);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setSearchQuery('');
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelectCity = (city: string) => {
onChange(city);
setSearchQuery('');
setIsOpen(false);
};
const handleInputClick = () => {
setIsOpen(true);
setSearchQuery('');
};
const handleClearSelection = () => {
onChange('all');
setSearchQuery('');
setIsOpen(false);
};
const displayValue = value === 'all' ? allOptionLabel : value;
const showClearButton = value !== 'all' && !isOpen;
return (
<div className={`relative ${className}`} ref={dropdownRef}>
<div className="relative">
{filterIcon && (
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
)}
<input
ref={inputRef}
type="text"
value={isOpen ? searchQuery : displayValue}
onChange={(e) => {
setSearchQuery(e.target.value);
if (!isOpen) setIsOpen(true);
}}
onClick={handleInputClick}
onFocus={handleInputClick}
placeholder={isOpen ? placeholder : displayValue}
className={`border border-neutral-200 rounded-lg pr-10 py-2.5 text-sm w-full focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer ${
filterIcon ? ' pl-10' : 'pl-3'
}`}
/>
{showClearButton && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleClearSelection();
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 text-xs cursor-pointer z-20"
>
</button>
)}
</div>
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{/* All Cities Option */}
<div
onClick={() => handleSelectCity('all')}
className={`px-3 py-2 cursor-pointer hover:bg-neutral-50 border-b border-neutral-100 ${
value === 'all'
? 'bg-primary-50 text-primary-700 font-medium'
: 'text-neutral-900'
}`}
>
{allOptionLabel}
</div>
{/* Filtered Cities */}
{filteredCities.length > 0 ? (
<div className="py-1">
{filteredCities.slice(0, 100).map((city) => (
<div
key={city}
onClick={() => handleSelectCity(city)}
className={`px-3 py-2 cursor-pointer hover:bg-neutral-50 text-sm ${
value === city
? 'bg-primary-50 text-primary-700 font-medium'
: 'text-neutral-700'
}`}
>
{city}
</div>
))}
{filteredCities.length > 100 && (
<div className="px-3 py-2 text-xs text-neutral-500 border-t border-neutral-100">
Showing first 100 results. Continue typing to refine...
</div>
)}
</div>
) : searchQuery ? (
<div className="px-3 py-2 text-neutral-500 text-sm">
No cities found matching "{searchQuery}"
</div>
) : null}
</div>
)}
</div>
);
};
+518
View File
@@ -0,0 +1,518 @@
const INDONESIAN_CITIES: string[] = [
'Aceh Selatan',
'Aceh Tenggara',
'Aceh Timur',
'Aceh Tengah',
'Aceh Barat',
'Aceh Besar',
'Pidie',
'Aceh Utara',
'Simeulue',
'Aceh Singkil',
'Bireuen',
'Aceh Barat Daya',
'Gayo Lues',
'Aceh Jaya',
'Nagan Raya',
'Aceh Tamiang',
'Bener Meriah',
'Pidie Jaya',
'Kota Banda Aceh',
'Kota Sabang',
'Kota Lhokseumawe',
'Kota Langsa',
'Kota Subulussalam',
'Tapanuli Tengah',
'Tapanuli Utara',
'Tapanuli Selatan',
'Nias',
'Langkat',
'Karo',
'Deli Serdang',
'Simalungun',
'Asahan',
'Labuhanbatu',
'Dairi',
'Toba',
'Mandailing Natal',
'Nias Selatan',
'Pakpak Bharat',
'Humbang Hasundutan',
'Samosir',
'Serdang Bedagai',
'Batu Bara',
'Padang Lawas Utara',
'Padang Lawas',
'Labuhanbatu Selatan',
'Labuhanbatu Utara',
'Nias Utara',
'Nias Barat',
'Kota Medan',
'Kota Pematangsiantar',
'Kota Sibolga',
'Kota Tanjung Balai',
'Kota Binjai',
'Kota Tebing Tinggi',
'Kota Padangsidimpuan',
'Kota Gunungsitoli',
'Pesisir Selatan',
'Solok',
'Sijunjung',
'Tanah Datar',
'Padang Pariaman',
'Agam',
'Lima Puluh Kota',
'Pasaman',
'Kepulauan Mentawai',
'Dharmasraya',
'Solok Selatan',
'Pasaman Barat',
'Kota Padang',
'Kota Solok',
'Kota Sawahlunto',
'Kota Padang Panjang',
'Kota Bukittinggi',
'Kota Payakumbuh',
'Kota Pariaman',
'Kampar',
'Indragiri Hulu',
'Bengkalis',
'Indragiri Hilir',
'Pelalawan',
'Rokan Hulu',
'Rokan Hilir',
'Siak',
'Kuantan Singingi',
'Kepulauan Meranti',
'Kota Pekanbaru',
'Kota Dumai',
'Kerinci',
'Merangin',
'Sarolangun',
'Batanghari',
'Muaro Jambi',
'Tanjung Jabung Barat',
'Tanjung Jabung Timur',
'Bungo',
'Tebo',
'Kota Jambi',
'Kota Sungai Penuh',
'Ogan Komering Ulu',
'Ogan Komering Ilir',
'Muara Enim',
'Lahat',
'Musi Rawas',
'Musi Banyuasin',
'Banyuasin',
'Ogan Komering Ulu Timur',
'Ogan Komering Ulu Selatan',
'Ogan Ilir',
'Empat Lawang',
'Penukal Abab Lematang Ilir',
'Musi Rawas Utara',
'Kota Palembang',
'Kota Pagar Alam',
'Kota Lubuk Linggau',
'Kota Prabumulih',
'Bengkulu Selatan',
'Rejang Lebong',
'Bengkulu Utara',
'Kaur',
'Seluma',
'Muko Muko',
'Lebong',
'Kepahiang',
'Bengkulu Tengah',
'Kota Bengkulu',
'Lampung Selatan',
'Lampung Tengah',
'Lampung Utara',
'Lampung Barat',
'Tulang Bawang',
'Tanggamus',
'Lampung Timur',
'Way Kanan',
'Pesawaran',
'Pringsewu',
'Mesuji',
'Tulang Bawang Barat',
'Pesisir Barat',
'Kota Bandar Lampung',
'Kota Metro',
'Bangka',
'Belitung',
'Bangka Selatan',
'Bangka Tengah',
'Bangka Barat',
'Belitung Timur',
'Kota Pangkal Pinang',
'Bintan',
'Karimun',
'Natuna',
'Lingga',
'Kepulauan Anambas',
'Kota Batam',
'Kota Tanjung Pinang',
'Kepulauan Seribu',
'Kota Jakarta Pusat',
'Kota Jakarta Utara',
'Kota Jakarta Barat',
'Kota Jakarta Selatan',
'Kota Jakarta Timur',
'Bogor',
'Sukabumi',
'Cianjur',
'Bandung',
'Garut',
'Tasikmalaya',
'Ciamis',
'Kuningan',
'Cirebon',
'Majalengka',
'Sumedang',
'Indramayu',
'Subang',
'Purwakarta',
'Karawang',
'Bekasi',
'Bandung Barat',
'Pangandaran',
'Kota Bogor',
'Kota Sukabumi',
'Kota Bandung',
'Kota Cirebon',
'Kota Bekasi',
'Kota Depok',
'Kota Cimahi',
'Kota Tasikmalaya',
'Kota Banjar',
'Cilacap',
'Banyumas',
'Purbalingga',
'Banjarnegara',
'Kebumen',
'Purworejo',
'Wonosobo',
'Magelang',
'Boyolali',
'Klaten',
'Sukoharjo',
'Wonogiri',
'Karanganyar',
'Sragen',
'Grobogan',
'Blora',
'Rembang',
'Pati',
'Kudus',
'Jepara',
'Demak',
'Semarang',
'Temanggung',
'Kendal',
'Batang',
'Pekalongan',
'Pemalang',
'Tegal',
'Brebes',
'Kota Magelang',
'Kota Surakarta',
'Kota Salatiga',
'Kota Semarang',
'Kota Pekalongan',
'Kota Tegal',
'Kulon Progo',
'Bantul',
'Gunungkidul',
'Sleman',
'Kota Yogyakarta',
'Pacitan',
'Ponorogo',
'Trenggalek',
'Tulungagung',
'Blitar',
'Kediri',
'Malang',
'Lumajang',
'Jember',
'Banyuwangi',
'Bondowoso',
'Situbondo',
'Probolinggo',
'Pasuruan',
'Sidoarjo',
'Mojokerto',
'Jombang',
'Nganjuk',
'Madiun',
'Magetan',
'Ngawi',
'Bojonegoro',
'Tuban',
'Lamongan',
'Gresik',
'Bangkalan',
'Sampang',
'Pamekasan',
'Sumenep',
'Kota Kediri',
'Kota Blitar',
'Kota Malang',
'Kota Probolinggo',
'Kota Pasuruan',
'Kota Mojokerto',
'Kota Madiun',
'Kota Surabaya',
'Kota Batu',
'Pandeglang',
'Lebak',
'Tangerang',
'Serang',
'Kota Tangerang',
'Kota Cilegon',
'Kota Serang',
'Kota Tangerang Selatan',
'Jembrana',
'Tabanan',
'Badung',
'Gianyar',
'Klungkung',
'Bangli',
'Karangasem',
'Buleleng',
'Kota Denpasar',
'Lombok Barat',
'Lombok Tengah',
'Lombok Timur',
'Sumbawa',
'Dompu',
'Bima',
'Sumbawa Barat',
'Lombok Utara',
'Kota Mataram',
'Kota Bima',
'Kupang',
'Timor Tengah Selatan',
'Timor Tengah Utara',
'Belu',
'Alor',
'Flores Timur',
'Sikka',
'Ende',
'Ngada',
'Manggarai',
'Sumba Timur',
'Sumba Barat',
'Lembata',
'Rote Ndao',
'Manggarai Barat',
'Nagekeo',
'Sumba Tengah',
'Sumba Barat Daya',
'Manggarai Timur',
'Sabu Raijua',
'Malaka',
'Kota Kupang',
'Sambas',
'Mempawah',
'Sanggau',
'Ketapang',
'Sintang',
'Kapuas Hulu',
'Bengkayang',
'Landak',
'Sekadau',
'Melawi',
'Kayong Utara',
'Kubu Raya',
'Kota Pontianak',
'Kota Singkawang',
'Kotawaringin Barat',
'Kotawaringin Timur',
'Kapuas',
'Barito Selatan',
'Barito Utara',
'Katingan',
'Seruyan',
'Sukamara',
'Lamandau',
'Gunung Mas',
'Pulang Pisau',
'Murung Raya',
'Barito Timur',
'Kota Palangkaraya',
'Tanah Laut',
'Kotabaru',
'Banjar',
'Barito Kuala',
'Tapin',
'Hulu Sungai Selatan',
'Hulu Sungai Tengah',
'Hulu Sungai Utara',
'Tabalong',
'Tanah Bumbu',
'Balangan',
'Kota Banjarmasin',
'Kota Banjarbaru',
'Paser',
'Kutai Kartanegara',
'Berau',
'Kutai Barat',
'Kutai Timur',
'Penajam Paser Utara',
'Mahakam Ulu',
'Kota Balikpapan',
'Kota Samarinda',
'Kota Bontang',
'Bulungan',
'Malinau',
'Nunukan',
'Tana Tidung',
'Kota Tarakan',
'Bolaang Mongondow',
'Minahasa',
'Kepulauan Sangihe',
'Kepulauan Talaud',
'Minahasa Selatan',
'Minahasa Utara',
'Minahasa Tenggara',
'Bolaang Mongondow Utara',
'Kepulauan Siau Tagulandang Biaro (Sitaro)',
'Bolaang Mongondow Timur',
'Bolaang Mongondow Selatan',
'Kota Manado',
'Kota Bitung',
'Kota Tomohon',
'Kota Kotamobagu',
'Banggai',
'Poso',
'Donggala',
'Toli Toli',
'Buol',
'Morowali',
'Banggai Kepulauan',
'Parigi Moutong',
'Tojo Una Una',
'Sigi',
'Banggai Laut',
'Morowali Utara',
'Kota Palu',
'Kepulauan Selayar',
'Bulukumba',
'Bantaeng',
'Jeneponto',
'Takalar',
'Gowa',
'Sinjai',
'Bone',
'Maros',
'Pangkajene Kepulauan',
'Barru',
'Soppeng',
'Wajo',
'Sidenreng Rappang',
'Pinrang',
'Enrekang',
'Luwu',
'Tana Toraja',
'Luwu Utara',
'Luwu Timur',
'Toraja Utara',
'Kota Makassar',
'Kota Pare Pare',
'Kota Palopo',
'Kolaka',
'Konawe',
'Muna',
'Buton',
'Konawe Selatan',
'Bombana',
'Wakatobi',
'Kolaka Utara',
'Konawe Utara',
'Buton Utara',
'Kolaka Timur',
'Konawe Kepulauan',
'Muna Barat',
'Buton Tengah',
'Buton Selatan',
'Kota Kendari',
'Kota Bau Bau',
'Gorontalo',
'Boalemo',
'Bone Bolango',
'Pahuwato',
'Gorontalo Utara',
'Kota Gorontalo',
'Pasangkayu (Mamuju Utara)',
'Mamuju',
'Mamasa',
'Polewali Mandar',
'Majene',
'Mamuju Tengah',
'Maluku Tengah',
'Maluku Tenggara',
'Kepulauan Tanimbar (Maluku Tenggara Barat)',
'Buru',
'Seram Bagian Timur',
'Seram Bagian Barat',
'Kepulauan Aru',
'Maluku Barat Daya',
'Buru Selatan',
'Kota Ambon',
'Kota Tual',
'Halmahera Barat',
'Halmahera Tengah',
'Halmahera Utara',
'Halmahera Selatan',
'Kepulauan Sula',
'Halmahera Timur',
'Pulau Morotai',
'Pulau Taliabu',
'Kota Ternate',
'Kota Tidore Kepulauan',
'Jayapura',
'Kepulauan Yapen',
'Biak Numfor',
'Sarmi',
'Keerom',
'Waropen',
'Supiori',
'Mamberamo Raya',
'Kota Jayapura',
'Manokwari',
'Fak Fak',
'Teluk Bintuni',
'Teluk Wondama',
'Kaimana',
'Manokwari Selatan',
'Pegunungan Arfak',
'Merauke',
'Boven Digoel',
'Mappi',
'Asmat',
'Nabire',
'Puncak Jaya',
'Paniai',
'Mimika',
'Puncak',
'Dogiyai',
'Intan Jaya',
'Deiyai',
'Jayawijaya',
'Pegunungan Bintang',
'Yahukimo',
'Tolikara',
'Mamberamo Tengah',
'Yalimo',
'Lanny Jaya',
'Nduga',
'Sorong',
'Sorong Selatan',
'Raja Ampat',
'Tambrauw',
'Maybrat',
'Kota Sorong',
];
export default INDONESIAN_CITIES;
+2 -2
View File
@@ -130,7 +130,7 @@
html {
font-family: 'Bai Jamjuree', sans-serif;
font-weight: 400;
font-size: 12px;
font-size: 14px;
line-height: 1.2;
}
}
}
+2 -2
View File
@@ -84,7 +84,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
if (mappingPublicRoutes.includes(pathname)) {
if (token) return redirect('/dashboard');
if (token) return redirect('/hackathon-dashboard');
return null;
}
@@ -100,7 +100,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
if (!hasPermission) {
return '/dashboard';
return '/hackathon-dashboard';
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/apps/backoffice',
server: {
port: 3000,
port: 3003,
host: 'localhost',
},
preview: {
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+95 -114
View File
@@ -1,11 +1,11 @@
import { FC, ReactElement, useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router';
import { useAuthStore, supabase } from '@imphnen-frontend-service/service';
import { useGitHubCallback } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
const CallbackPage: FC = (): ReactElement => {
const navigate = useNavigate();
const { setSession } = useAuthStore();
const { mutateAsync: exchangeGitHubCode } = useGitHubCallback();
const [isProcessing, setIsProcessing] = useState(true);
const [error, setError] = useState<string | null>(null);
const hasRunRef = useRef(false);
@@ -13,134 +13,68 @@ const CallbackPage: FC = (): ReactElement => {
useEffect(() => {
const handleCallback = async () => {
if (hasRunRef.current) {
// console.log('[Callback] Already processed, skipping...');
return;
}
hasRunRef.current = true;
try {
// console.log('[Callback] Processing OAuth callback...');
// console.log('[Callback] Current URL:', globalThis.location.href);
// Check URL hash for Supabase email confirmation callback
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
const urlParams = new URLSearchParams(globalThis.location.search);
// Supabase client is configured with detectSessionInUrl: true
// This means Supabase automatically detects and processes OAuth tokens from the URL hash
// We just need to wait a moment for it to complete, then check for the session
const type = hashParams.get('type') || urlParams.get('type');
const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
// console.log('[Callback] Waiting for Supabase to process OAuth callback...');
await new Promise((resolve) => setTimeout(resolve, 1000));
// Debug: log what we received
console.log('[Callback] Params:', { type, accessToken: !!accessToken, hash: globalThis.location.hash, search: globalThis.location.search });
// Get the session that Supabase automatically created from the URL hash
const {
data: { session: sessionData },
error: sessionError,
} = await supabase.auth.getSession();
// Handle Supabase email callbacks (has access_token in hash or query)
// This includes: signup confirmation, email confirmation, password recovery
if (accessToken) {
setIsProcessing(false);
if (sessionError) {
// console.error('[Callback] Session error:', sessionError);
throw new Error(sessionError.message || 'Failed to get session');
// Password recovery - type is 'recovery' or we have access_token from reset email
if (type === 'recovery' || type === 'magiclink') {
toast.success('Email verified! Please set your new password.');
navigate('/auth/reset-password?access_token=' + accessToken);
return;
}
// Signup/Email confirmation
if (type === 'signup' || type === 'email_confirmation') {
toast.success('Email verified successfully! Please log in to continue.');
navigate('/auth/login');
return;
}
// If we have access_token but unknown type, assume it's password recovery
// (Supabase sometimes sends without explicit type)
toast.success('Email verified! Please set your new password.');
navigate('/auth/reset-password?access_token=' + accessToken);
return;
}
if (!sessionData || !sessionData.user) {
throw new Error(
'No session found after OAuth callback. Please try logging in again.'
);
// Get the code from URL query params (GitHub OAuth)
const code = urlParams.get('code');
if (!code) {
throw new Error('No authorization code received');
}
// console.log('[Callback] Supabase session established:', {
// userId: sessionData.user.id,
// email: sessionData.user.email,
// });
// Exchange the code for tokens using backend API (GitHub OAuth)
const result = await exchangeGitHubCode({ code });
// Create/update user in the users table (for foreign key constraints)
// console.log('[Callback] Creating/updating user record...');
const { data: userData, error: upsertError } = await supabase
.from('users')
.upsert(
{
id: sessionData.user.id,
email: sessionData.user.email || '',
fullname:
sessionData.user.user_metadata?.full_name ||
sessionData.user.user_metadata?.name ||
sessionData.user.email?.split('@')[0] ||
'',
avatar: sessionData.user.user_metadata?.avatar_url || '',
is_active: true,
updated_at: new Date().toISOString(),
},
{
onConflict: 'id',
}
)
.select()
.single();
if (upsertError) {
// console.warn('[Callback] Failed to create user record:', upsertError);
// Don't throw - continue with login even if user record creation fails
} else {
// console.log('[Callback] User record created/updated successfully');
}
// Store user-friendly data in Zustand for UI purposes
// Supabase now manages the actual auth session
// Use data from database if available, otherwise use OAuth metadata
const userRecord = userData || {
id: sessionData.user.id,
email: sessionData.user.email || '',
fullname:
sessionData.user.user_metadata?.full_name ||
sessionData.user.user_metadata?.name ||
sessionData.user.email?.split('@')[0] ||
'',
avatar: sessionData.user.user_metadata?.avatar_url || '',
phone_number: '',
birthdate: '',
gender: '',
is_active: true,
};
setSession({
token: {
access_token: sessionData.access_token,
refresh_token: sessionData.refresh_token || '',
},
user: {
id: userRecord.id,
email: userRecord.email,
fullname: userRecord.fullname,
phone_number: userRecord.phone_number || '',
avatar: userRecord.avatar || '',
birthdate: userRecord.birthdate || '',
gender: userRecord.gender || '',
is_active: userRecord.is_active,
location: userRecord.location,
bio: userRecord.bio,
skills: userRecord.skills,
role: {
id: '',
name: 'user',
permissions: [],
created_at: '',
updated_at: '',
},
},
});
// console.log('[Callback] Session stored successfully');
toast.success('Login successful!');
setIsProcessing(false);
// Check if user has completed onboarding (has location)
// Use globalThis.location.replace for hard redirect to prevent history issues
if (userRecord.location) {
// console.log('[Callback] User has completed onboarding, redirecting to dashboard...');
if (result.user.location) {
globalThis.location.replace('/dashboard');
} else {
// console.log('[Callback] User needs onboarding, redirecting...');
globalThis.location.replace('/onboarding/user');
}
} catch (err) {
// console.error('[Callback] Error:', err);
console.error('[Callback] Error:', err);
setError((err as Error).message);
setIsProcessing(false);
toast.error('An error occurred during login');
@@ -153,21 +87,68 @@ const CallbackPage: FC = (): ReactElement => {
handleCallback();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Run only once on mount
}, []);
if (error) {
// Check if error is related to private email
const isPrivateEmailError =
error.toLowerCase().includes('failed to create user') ||
error.toLowerCase().includes('email') ||
error.toLowerCase().includes('user record');
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 px-4">
<div className="bg-white w-full max-w-2xl p-8 rounded-2xl shadow-lg border border-red-200">
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 px-4">
<div className="bg-white dark:bg-gray-900 w-full max-w-2xl p-8 rounded-2xl shadow-lg border border-red-200 dark:border-red-800">
<div className="text-center mb-6">
<div className="text-red-500 text-5xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
GitHub Login Failed
</h2>
<p className="text-red-600 mb-4 whitespace-pre-line">{error}</p>
<p className="text-red-600 dark:text-red-400 mb-4 whitespace-pre-line">{error}</p>
</div>
<p className="text-gray-600 text-sm mt-6 text-center">
{isPrivateEmailError && (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-amber-800 dark:text-amber-300 mb-2">
Is your GitHub email set to private?
</h3>
<p className="text-amber-700 dark:text-amber-400 text-sm mb-3">
GitHub login requires a public email address. Please follow these steps:
</p>
<ol className="text-amber-700 dark:text-amber-400 text-sm list-decimal list-inside space-y-1 mb-3">
<li>
Go to{' '}
<a
href="https://github.com/settings/emails"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-amber-900 dark:hover:text-amber-200"
>
GitHub Email Settings
</a>
</li>
<li>Uncheck "Keep my email addresses private"</li>
<li>
Or go to{' '}
<a
href="https://github.com/settings/profile"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-amber-900 dark:hover:text-amber-200"
>
Profile Settings
</a>{' '}
and set a public email
</li>
<li>Try signing in with GitHub again</li>
</ol>
<p className="text-amber-600 dark:text-amber-500 text-xs">
Alternatively, you can sign up using email and password instead.
</p>
</div>
)}
<p className="text-gray-600 dark:text-gray-400 text-sm mt-6 text-center">
Redirecting to login page in 3 seconds...
</p>
</div>
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { supabase } from '@imphnen-frontend-service/service';
import { useForgotPassword } from '@imphnen-frontend-service/service';
import { Link, useNavigate } from 'react-router';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
@@ -7,9 +7,9 @@ import ThemeToggle from '../../../components/theme-toggle';
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [emailSent, setEmailSent] = useState(false);
const navigate = useNavigate();
const forgotPasswordMutation = useForgotPassword();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -20,44 +20,33 @@ export default function ForgotPasswordPage() {
}
try {
setIsLoading(true);
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/reset-password`,
});
if (error) {
throw error;
}
await forgotPasswordMutation.mutateAsync({ email });
setEmailSent(true);
toast.success('Password reset email sent! Check your inbox.');
} catch (err) {
// console.error('Failed to send reset email:', err);
toast.error((err as Error).message || 'Failed to send reset email');
} finally {
setIsLoading(false);
}
};
if (emailSent) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 text-center">
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
<div className="mb-6">
<div className="mx-auto w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
<div className="mx-auto w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-4">
<span className="text-3xl"></span>
</div>
<h2 className="text-3xl font-bold text-gray-900 mb-2">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Check Your Email
</h2>
<p className="text-gray-600">
<p className="text-gray-600 dark:text-gray-400">
We've sent a password reset link to <strong>{email}</strong>
</p>
</div>
<div className="space-y-4">
<p className="text-sm text-gray-600">
<p className="text-sm text-gray-600 dark:text-gray-400">
Click the link in the email to reset your password. The link will
expire in 1 hour.
</p>
@@ -70,7 +59,7 @@ export default function ForgotPasswordPage() {
<button
onClick={() => setEmailSent(false)}
className="w-full py-3 text-gray-600 hover:text-gray-900 transition-colors"
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
>
Send another email
</button>
@@ -116,18 +105,18 @@ export default function ForgotPasswordPage() {
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
disabled={isLoading}
className="bg-white dark:bg-gray-800 w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
disabled={forgotPasswordMutation.isPending}
className="bg-white dark:bg-gray-800 text-gray-900 dark:text-white w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
required
/>
</div>
<button
type="submit"
disabled={isLoading}
disabled={forgotPasswordMutation.isPending}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? 'Sending...' : 'Send Reset Link'}
{forgotPasswordMutation.isPending ? 'Sending...' : 'Send Reset Link'}
</button>
</form>
</div>
+68 -75
View File
@@ -1,9 +1,7 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
useGitHubAuth,
useEmailAuth,
supabase,
useAuthStore,
useLogin,
} from '@imphnen-frontend-service/service';
import { GithubOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router';
@@ -12,17 +10,34 @@ import { Icon } from '@iconify/react';
import { ThemeToggle } from '../../../components/theme-toggle';
export default function LoginPage() {
// console.log('[LoginPage] Rendering...');
const navigate = useNavigate();
const { setSession } = useAuthStore();
const { signInWithGitHub } = useGitHubAuth();
const { signInWithEmail } = useEmailAuth();
const loginMutation = useLogin();
const [isGithubLoading, setIsGithubLoading] = useState(false);
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
// Check for password reset tokens in URL and redirect to reset-password page
useEffect(() => {
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
const urlParams = new URLSearchParams(globalThis.location.search);
const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
const type = hashParams.get('type') || urlParams.get('type');
// If we have an access_token, this is likely a password reset redirect that landed on the wrong page
if (accessToken) {
console.log('[Login] Detected access_token, redirecting to reset-password page');
// Check if it's a password recovery
if (type === 'recovery' || type === 'magiclink' || !type) {
toast.info('Redirecting to password reset...');
navigate('/auth/reset-password?access_token=' + accessToken);
}
}
}, [navigate]);
const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault();
@@ -34,52 +49,12 @@ export default function LoginPage() {
}
try {
setIsEmailLoading(true);
// console.log('[Login] Attempting email login...');
const result = await signInWithEmail(email, password);
// console.log('[Login] Email login successful:', result);
// Get user data from database
const { data: userData } = await supabase
.from('users')
.select('*')
.eq('id', result.user.id)
.single();
// Store session in Zustand
setSession({
token: {
access_token: result.session.access_token,
refresh_token: result.session.refresh_token || '',
},
user: {
id: result.user.id,
email: result.user.email || '',
fullname:
userData?.fullname || result.user.user_metadata?.full_name || '',
phone_number: userData?.phone_number || '',
avatar: userData?.avatar || '',
birthdate: userData?.birthdate || '',
gender: userData?.gender || '',
is_active: userData?.is_active || true,
location: userData?.location,
bio: userData?.bio,
skills: userData?.skills,
role: {
id: '',
name: 'user',
permissions: [],
created_at: '',
updated_at: '',
},
},
});
const result = await loginMutation.mutateAsync({ email, password });
toast.success('Login successful!');
// Redirect based on onboarding status
if (userData?.location) {
if (result.user.location) {
navigate('/dashboard');
} else {
navigate('/onboarding/user');
@@ -87,29 +62,25 @@ export default function LoginPage() {
} catch (err) {
console.error('[Login] Email login failed:', err);
setError((err as Error).message || 'Login failed');
setIsEmailLoading(false);
}
};
const handleGithubLogin = async () => {
try {
setIsGithubLoading(true);
// console.log('[Login] Initiating GitHub OAuth...');
const result = await signInWithGitHub();
// console.log('[Login] OAuth result:', result);
// Check if we got a redirect URL
if (result?.url) {
// console.log('[Login] Redirecting to GitHub OAuth:', result.url);
// Manually redirect immediately
globalThis.location.href = result.url;
} else {
// console.error('[Login] No OAuth URL returned');
setIsGithubLoading(false);
setError('Failed to get GitHub OAuth URL');
}
} catch (error) {
// console.error('[Login] GitHub login failed');
} catch (err) {
console.error('[Login] GitHub login failed:', err);
setError((err as Error).message || 'GitHub login failed');
setIsGithubLoading(false);
}
};
@@ -157,7 +128,7 @@ export default function LoginPage() {
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
disabled={isEmailLoading}
disabled={loginMutation.isPending}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
required
/>
@@ -178,24 +149,33 @@ export default function LoginPage() {
Forgot password?
</Link>
</div>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={isEmailLoading}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
required
/>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={loginMutation.isPending}
className="w-full px-4 py-2.5 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
<Icon icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
</button>
</div>
</div>
<button
type="submit"
disabled={isEmailLoading}
disabled={loginMutation.isPending}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
{isEmailLoading ? 'Signing in...' : 'Sign in with Email'}
{loginMutation.isPending ? 'Signing in...' : 'Sign in with Email'}
</button>
</form>
@@ -219,15 +199,28 @@ export default function LoginPage() {
</span>
</button>
<p className="mt-3 text-xs text-center text-gray-500 dark:text-gray-500 font-sans">
Make sure your GitHub email is{' '}
<a
href="https://github.com/settings/emails"
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 dark:text-primary-400 hover:underline"
>
set to public
</a>{' '}
for GitHub sign in to work.
</p>
<div className="mt-6 text-center">
<p className="text-gray-600 dark:text-gray-400 text-sm">
Don't have an account?{' '}
<a
href="/auth/signup"
<Link
to="/auth/signup"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
>
Sign up
</a>
</Link>
</p>
</div>
@@ -1,26 +1,33 @@
import { useState, useEffect } from 'react';
import { supabase } from '@imphnen-frontend-service/service';
import { useResetPassword, useAuthStore } from '@imphnen-frontend-service/service';
import { useNavigate } from 'react-router';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
export default function ResetPasswordPage() {
const navigate = useNavigate();
const { clearSession } = useAuthStore();
const resetPasswordMutation = useResetPassword();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isValidToken, setIsValidToken] = useState(false);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
// useEffect(() => {
// // Check if we have a valid session (from the reset link)
// supabase.auth.getSession().then(({ data: { session } }) => {
// if (session) {
// setIsValidToken(true);
// } else {
// toast.error('Invalid or expired reset link');
// setTimeout(() => navigate('/auth/forgot-password'), 2000);
// }
// });
// }, [navigate]);
useEffect(() => {
// Get the access_token from URL hash (Supabase sends it as hash fragment)
// or from query params (when redirected from callback page)
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
const queryParams = new URLSearchParams(globalThis.location.search);
const token = hashParams.get('access_token') || queryParams.get('access_token');
if (token) {
setAccessToken(token);
} else {
toast.error('Invalid or expired reset link');
setTimeout(() => navigate('/auth/forgot-password'), 2000);
}
}, [navigate]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -35,42 +42,39 @@ export default function ResetPasswordPage() {
return;
}
if (!accessToken) {
toast.error('Invalid reset token');
return;
}
try {
setIsLoading(true);
const { error } = await supabase.auth.updateUser({
password: password,
await resetPasswordMutation.mutateAsync({
access_token: accessToken,
new_password: password,
});
if (error) {
throw error;
}
toast.success('Password updated successfully!');
// Sign out and redirect to login
await supabase.auth.signOut();
// Clear session and redirect to login
clearSession();
navigate('/auth/login');
} catch (err) {
// console.error('Failed to reset password:', err);
toast.error((err as Error).message || 'Failed to reset password');
} finally {
setIsLoading(false);
}
};
// if (!isValidToken) {
// return (
// <div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950">
// <div className="text-center">
// <div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
// <p className="text-gray-600 dark:text-gray-400">
// Verifying reset link...
// </p>
// </div>
// </div>
// );
// }
if (!accessToken) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
<p className="text-gray-600 dark:text-gray-400">
Verifying reset link...
</p>
</div>
</div>
);
}
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
@@ -92,17 +96,26 @@ export default function ResetPasswordPage() {
>
New Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={isLoading}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
required
minLength={6}
/>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={resetPasswordMutation.isPending}
className="w-full px-4 py-2.5 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
required
minLength={6}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
<Icon icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
</button>
</div>
</div>
<div>
@@ -112,25 +125,34 @@ export default function ResetPasswordPage() {
>
Confirm New Password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
disabled={isLoading}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
required
minLength={6}
/>
<div className="relative">
<input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
disabled={resetPasswordMutation.isPending}
className="w-full px-4 py-2.5 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
required
minLength={6}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
<Icon icon={showConfirmPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
</button>
</div>
</div>
<button
type="submit"
disabled={isLoading}
disabled={resetPasswordMutation.isPending}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? 'Updating...' : 'Update Password'}
{resetPasswordMutation.isPending ? 'Updating...' : 'Update Password'}
</button>
</form>
</div>
+254 -143
View File
@@ -1,142 +1,200 @@
import { useState } from 'react';
import {
useGitHubAuth,
useEmailAuth,
supabase,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { useGitHubAuth, useSignup } from '@imphnen-frontend-service/service';
import { GithubOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router';
import { useNavigate, Link } from 'react-router';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import { ThemeToggle } from '../../../components/theme-toggle';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const signupSchema = z
.object({
fullname: z
.string()
.min(1, 'Full name is required')
.min(2, 'Full name must be at least 2 characters'),
email: z
.string()
.min(1, 'Email is required')
.email('Please enter a valid email address'),
password: z
.string()
.min(1, 'Password is required')
.min(6, 'Password must be at least 6 characters'),
confirmPassword: z.string().min(1, 'Please confirm your password'),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type SignupFormData = z.infer<typeof signupSchema>;
// Registration deadline: 2025-11-30 23:29:00 WIB (UTC+7)
const REGISTRATION_DEADLINE = new Date('2025-11-30T16:29:00Z');
export default function SignupPage() {
const navigate = useNavigate();
const { setSession } = useAuthStore();
const { signInWithGitHub } = useGitHubAuth();
const { signUpWithEmail } = useEmailAuth();
const [isGithubLoading, setIsGithubLoading] = useState(false);
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [fullname, setFullname] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const handleEmailSignup = async (e: React.FormEvent) => {
e.preventDefault();
// Check if registration is closed
const isRegistrationClosed = new Date() >= REGISTRATION_DEADLINE;
const { signInWithGitHub } = useGitHubAuth();
const signupMutation = useSignup();
const [isGithubLoading, setIsGithubLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [registrationSuccess, setRegistrationSuccess] = useState(false);
const [registeredEmail, setRegisteredEmail] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isValid },
} = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
mode: 'onChange',
});
const onSubmit = async (data: SignupFormData) => {
setError(null);
if (!fullname || !email || !password || !confirmPassword) {
setError('Please fill in all fields');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters long');
return;
}
try {
setIsEmailLoading(true);
// console.log('[Signup] Attempting email signup...');
const result = await signUpWithEmail(email, password, fullname);
// console.log('[Signup] Email signup successful:', result);
if (!result.user) {
throw new Error('Signup failed - no user returned');
}
// Create user record in database
const { error: upsertError } = await supabase.from('users').upsert(
{
id: result.user.id,
email: result.user.email || '',
fullname: fullname,
is_active: true,
updated_at: new Date().toISOString(),
},
{
onConflict: 'id',
}
);
if (upsertError) {
console.warn('[Signup] Failed to create user record');
}
// If session is available (email confirmation disabled), store it
if (result.session) {
setSession({
token: {
access_token: result.session.access_token,
refresh_token: result.session.refresh_token || '',
},
user: {
id: result.user.id,
email: result.user.email || '',
fullname: fullname,
phone_number: '',
avatar: '',
birthdate: '',
gender: '',
is_active: true,
role: {
id: '',
name: 'user',
permissions: [],
created_at: '',
updated_at: '',
},
},
});
toast.success('Account created successfully!');
navigate('/onboarding/user');
} else {
// Email confirmation is enabled
toast.success(
'Account created! Please check your email to verify your account.'
);
setTimeout(() => {
navigate('/auth/login');
}, 2000);
}
const result = await signupMutation.mutateAsync({
email: data.email,
password: data.password,
fullname: data.fullname,
});
toast.success(result.message);
setRegisteredEmail(data.email);
setRegistrationSuccess(true);
} catch (err) {
// console.error('[Signup] Email signup failed:', err);
console.error('[Signup] Email signup failed:', err);
setError((err as Error).message || 'Signup failed');
setIsEmailLoading(false);
}
};
// Show closed registration screen
if (isRegistrationClosed) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
<div className="mb-6">
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-4">
<Icon
icon="mdi:clock-alert"
className="text-3xl text-red-600 dark:text-red-400"
/>
</div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Registration Closed
</h2>
<p className="text-gray-600 dark:text-gray-400">
The registration period for this hackathon has ended.
</p>
</div>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Thank you for your interest! Registration closed on November 30, 2025 at 23:29 WIB.
</p>
<Link to="/auth/login">
<button className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer">
Go to Login
</button>
</Link>
<button
onClick={() => navigate('/')}
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
>
Back to Homepage
</button>
</div>
</div>
</div>
);
}
// Show success screen after registration
if (registrationSuccess) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
<div className="mb-6">
<div className="mx-auto w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-4">
<Icon
icon="mdi:email-check"
className="text-3xl text-green-600 dark:text-green-400"
/>
</div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Check Your Email
</h2>
<p className="text-gray-600 dark:text-gray-400">
We've sent an activation link to{' '}
<strong>{registeredEmail}</strong>
</p>
</div>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Click the link in the email to activate your account. The link
will expire in 24 hours.
</p>
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<p className="text-sm text-amber-700 dark:text-amber-300">
Don't forget to check your spam folder if you don't see the
email.
</p>
</div>
<Link to="/auth/login">
<button className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer">
Go to Login
</button>
</Link>
<button
onClick={() => setRegistrationSuccess(false)}
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
>
Register with different email
</button>
</div>
</div>
</div>
);
}
const handleGithubLogin = async () => {
try {
setIsGithubLoading(true);
// console.log('[Signup] Initiating GitHub OAuth...');
const result = await signInWithGitHub();
// console.log('[Signup] OAuth result:', result);
if (result?.url) {
// console.log('[Signup] Redirecting to GitHub OAuth:', result.url);
globalThis.location.href = result.url;
} else {
// console.error('[Signup] No OAuth URL returned');
setIsGithubLoading(false);
setError('Failed to get GitHub OAuth URL');
}
} catch (error) {
// console.error('[Signup] GitHub login failed:', error);
} catch (err) {
console.error('[Signup] GitHub login failed:', err);
setError((err as Error).message || 'GitHub login failed');
setIsGithubLoading(false);
}
};
const inputBaseClass =
'w-full px-4 py-2.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed';
const inputErrorClass = 'border-red-500 dark:border-red-500';
const inputNormalClass = 'border-gray-300 dark:border-gray-600';
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700">
@@ -166,7 +224,7 @@ export default function SignupPage() {
</div>
)}
<form onSubmit={handleEmailSignup} className="space-y-4">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label
htmlFor="fullname"
@@ -177,13 +235,18 @@ export default function SignupPage() {
<input
id="fullname"
type="text"
value={fullname}
onChange={(e) => setFullname(e.target.value)}
{...register('fullname')}
placeholder="John Doe"
disabled={isEmailLoading}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
required
disabled={signupMutation.isPending}
className={`${inputBaseClass} ${
errors.fullname ? inputErrorClass : inputNormalClass
}`}
/>
{errors.fullname && (
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
{errors.fullname.message}
</p>
)}
</div>
<div>
@@ -196,13 +259,18 @@ export default function SignupPage() {
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
{...register('email')}
placeholder="your@email.com"
disabled={isEmailLoading}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
required
disabled={signupMutation.isPending}
className={`${inputBaseClass} ${
errors.email ? inputErrorClass : inputNormalClass
}`}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
{errors.email.message}
</p>
)}
</div>
<div>
@@ -212,16 +280,30 @@ export default function SignupPage() {
>
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={isEmailLoading}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
required
/>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
{...register('password')}
placeholder="••••••••"
disabled={signupMutation.isPending}
className={`${inputBaseClass} pr-12 ${
errors.password ? inputErrorClass : inputNormalClass
}`}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
<Icon icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
{errors.password.message}
</p>
)}
</div>
<div>
@@ -231,24 +313,40 @@ export default function SignupPage() {
>
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
disabled={isEmailLoading}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
required
/>
<div className="relative">
<input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
{...register('confirmPassword')}
placeholder="••••••••"
disabled={signupMutation.isPending}
className={`${inputBaseClass} pr-12 ${
errors.confirmPassword ? inputErrorClass : inputNormalClass
}`}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
<Icon icon={showConfirmPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
</button>
</div>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
{errors.confirmPassword.message}
</p>
)}
</div>
<button
type="submit"
disabled={isEmailLoading}
disabled={!isValid || signupMutation.isPending}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
{isEmailLoading ? 'Creating account...' : 'Create Account'}
{signupMutation.isPending
? 'Creating account...'
: 'Create Account'}
</button>
</form>
@@ -272,15 +370,28 @@ export default function SignupPage() {
</span>
</button>
<p className="mt-3 text-xs text-center text-gray-500 dark:text-gray-500 font-sans">
Make sure your GitHub email is{' '}
<a
href="https://github.com/settings/emails"
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 dark:text-primary-400 hover:underline"
>
set to public
</a>{' '}
for GitHub sign up to work.
</p>
<div className="mt-6 text-center">
<p className="text-gray-600 dark:text-gray-400 text-sm">
Already have an account?{' '}
<a
href="/auth/login"
<Link
to="/auth/login"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
>
Sign in
</a>
</Link>
</p>
</div>
@@ -0,0 +1,517 @@
import { FC, ReactElement, useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { decodeCertificateId } from '../../../utils/certificate';
import {
useTeamById,
useTeamSubmission,
useAuthStore,
} from '@imphnen-frontend-service/service';
import QRCode from 'qrcode';
import html2canvas from 'html2canvas';
interface DecodedCert {
teamId: string;
submissionId: string;
}
const CertificatePage: FC = (): ReactElement => {
const { certId } = useParams<{ certId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [decodedInfo, setDecodedInfo] = useState<DecodedCert | null>(null);
const [error, setError] = useState<string | null>(null);
const teamNameRef = useRef<HTMLHeadingElement>(null);
const userNameRef = useRef<HTMLHeadingElement>(null);
const [teamNameFontSize, setTeamNameFontSize] = useState('2.25rem');
const [userNameFontSize, setUserNameFontSize] = useState('2.25rem');
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
const certificateRef = useRef<HTMLDivElement>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [certificateImage, setCertificateImage] = useState<string>('');
const [showTemplate, setShowTemplate] = useState(true);
useEffect(() => {
if (certId) {
decodeCertificateId(certId)
.then(setDecodedInfo)
.catch(() => {
setError('Invalid certificate ID');
});
}
}, [certId]);
// Generate QR Code
useEffect(() => {
if (certId) {
const certificateUrl = `${window.location.origin}/certificate/${certId}`;
QRCode.toDataURL(certificateUrl, {
width: 200,
margin: 1,
color: {
dark: '#000000',
light: '#ffffff',
},
})
.then(setQrCodeUrl)
.catch((err) => console.error('QR Code generation failed:', err));
}
}, [certId]);
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
decodedInfo?.teamId || '',
!!decodedInfo?.teamId
);
const { data: submissionData, isLoading: isLoadingSubmission } =
useTeamSubmission(decodedInfo?.teamId || '', !!decodedInfo?.teamId);
const team = teamData?.data;
const submission = submissionData?.data;
const isLoading =
(!decodedInfo && !error) || isLoadingTeam || isLoadingSubmission;
// Dynamic font sizing: shrink by 2px if height exceeds 80px
useEffect(() => {
const adjustFontSize = (
element: HTMLElement | null,
maxHeight: number,
startSize: number,
setter: (size: string) => void
) => {
if (!element) return;
let currentSize = startSize;
element.style.fontSize = `${currentSize}px`;
while (element.offsetHeight > maxHeight && currentSize > 1) {
currentSize -= 2;
element.style.fontSize = `${currentSize}px`;
}
setter(`${currentSize}px`);
};
const timer = setTimeout(() => {
adjustFontSize(teamNameRef.current, 80, 20, setTeamNameFontSize);
adjustFontSize(userNameRef.current, 80, 36, setUserNameFontSize);
}, 0);
return () => clearTimeout(timer);
}, [team?.name, session?.user?.fullname]);
// Generate certificate canvas screenshot
useEffect(() => {
const generateCertificate = async () => {
if (!certificateRef.current || !team || !submission || !qrCodeUrl) return;
setIsGenerating(true);
try {
// Wait a bit for fonts and images to load
await new Promise((resolve) => setTimeout(resolve, 500));
const canvas = await html2canvas(certificateRef.current, {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
width: 1000,
height: (1000 * 2480) / 3508,
});
const imageUrl = canvas.toDataURL('image/png');
setCertificateImage(imageUrl);
setShowTemplate(false);
} catch (error) {
console.error('Failed to generate certificate:', error);
} finally {
setIsGenerating(false);
}
};
generateCertificate();
}, [team, submission, qrCodeUrl, session?.user?.fullname]);
// Download certificate
const handleDownloadCertificate = () => {
if (!certificateImage) return;
const link = document.createElement('a');
link.href = certificateImage;
link.download = `certificate-${team?.name || 'hackathon'}.png`;
link.click();
};
// Print certificate
const handlePrintCertificate = () => {
if (!certificateImage) return;
const printWindow = window.open('', '_blank');
if (printWindow) {
printWindow.document.write(`
<html>
<head>
<title>Certificate - ${team?.name}</title>
<style>
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
img { max-width: 100%; height: auto; }
@media print {
@page { size: A4 landscape; margin: 0; }
body { margin: 0; }
img { width: 100%; height: auto; }
}
</style>
</head>
<body>
<img src="${certificateImage}" />
</body>
</html>
`);
printWindow.document.close();
printWindow.onload = () => {
printWindow.print();
};
}
};
if (error || !certId) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-6xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Invalid Certificate
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
{error || 'The certificate ID is invalid or malformed.'}
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</div>
);
}
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<div className="text-gray-600 dark:text-gray-400">
Loading certificate...
</div>
</div>
</div>
);
}
if (!submission || submission.id !== decodedInfo?.submissionId) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-6xl mb-4">📄</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Certificate Not Found
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
The submission associated with this certificate could not be found.
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
{/* Print Styles */}
<style>{`
@media print {
@page {
size: A4 landscape;
margin: 0;
}
body {
margin: 0;
padding: 0;
}
body * {
visibility: hidden;
}
#certificate-wrapper {
visibility: visible;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: white;
}
#certificate, #certificate * {
visibility: visible;
}
#certificate {
position: relative;
max-width: 100%;
page-break-after: avoid;
}
.no-print {
display: none !important;
}
}
/* Mobile responsive - zoom out to fit */
@media (max-width: 768px) {
#certificate-container {
transform-origin: top center;
}
}
`}</style>
{/* Header */}
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 no-print">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Certificate
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{team?.name}
</p>
</div>
<Button
variant="secondary"
onClick={() =>
navigate(`/teams/${decodedInfo?.teamId}/submission`)
}
>
Back to Submission
</Button>
</div>
</div>
</div>
{/* Certificate Content */}
<div
className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"
id="certificate-wrapper"
>
{/* Hidden Template for Canvas Generation */}
<div
className={showTemplate ? 'block' : 'hidden'}
style={{ position: 'absolute', left: '-9999px' }}
>
<div
ref={certificateRef}
id="certificate-template"
style={{
position: 'relative',
backgroundImage: 'url(/images/blank_cert.png)',
backgroundSize: 'cover',
backgroundPosition: 'center',
width: '1000px',
height: `${(1000 * 2480) / 3508}px`,
}}
>
{/* User Name (from session) */}
<div
style={{
position: 'absolute',
top: '40%',
left: '50%',
transform: 'translateX(-50%)',
width: '80%',
}}
>
<h3
ref={userNameRef}
style={{
fontWeight: 'bold',
color: '#111827',
textAlign: 'center',
fontSize: userNameFontSize,
lineHeight: '1.2',
wordBreak: 'break-word',
textShadow: '0 1px 2px rgba(0,0,0,0.1)',
margin: 0,
}}
>
{session?.user?.fullname || 'N/A'}
</h3>
</div>
{/* Team Name */}
<div
style={{
position: 'absolute',
top: '46%',
left: '50%',
transform: 'translateX(-50%)',
width: '70%',
}}
>
<h3
ref={teamNameRef}
style={{
fontWeight: '600',
color: '#1f2937',
textAlign: 'center',
fontSize: teamNameFontSize,
lineHeight: '1.2',
wordBreak: 'break-word',
margin: 0,
}}
>
{team?.name}
</h3>
</div>
{/* Participation Text */}
<div
style={{
position: 'absolute',
top: '60%',
left: '50%',
transform: 'translateX(-50%)',
width: '70%',
}}
>
<p
style={{
textAlign: 'center',
color: '#374151',
fontSize: '18px',
fontWeight: '500',
margin: 0,
}}
>
<span style={{ fontWeight: 'bold' }}>
Peserta Hackathon IMPHNEN x KOLOSAL AI
</span>
</p>
</div>
{/* QR Code */}
<div
style={{
position: 'absolute',
bottom: '8%',
left: '8%',
}}
>
{qrCodeUrl && (
<div
style={{
backgroundColor: '#ffffff',
padding: '8px',
borderRadius: '4px',
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
}}
>
<img
src={qrCodeUrl}
alt="Certificate QR Code"
style={{ width: '96px', height: '96px' }}
/>
</div>
)}
</div>
{/* Date */}
<div
style={{
position: 'absolute',
bottom: '8%',
right: '8%',
}}
>
<p
style={{
fontSize: '14px',
color: '#374151',
margin: 0,
}}
>
{submission.submitted_at
? new Date(submission.submitted_at).toLocaleDateString(
'id-ID',
{
day: 'numeric',
month: 'long',
year: 'numeric',
}
)
: 'N/A'}
</p>
</div>
</div>
</div>
{/* Display Certificate Image */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 overflow-hidden p-2">
{isGenerating && (
<div className="flex items-center justify-center p-12">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<div className="text-gray-600 dark:text-gray-400">
Generating certificate...
</div>
</div>
</div>
)}
{certificateImage && !isGenerating && (
<img
src={certificateImage}
alt="Certificate"
className="w-full h-auto"
style={{ maxWidth: '100%', height: 'auto' }}
/>
)}
{/* Actions */}
<div className="bg-gray-50 dark:bg-gray-800 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
<Button
variant="secondary"
onClick={handleDownloadCertificate}
className="flex items-center gap-2"
disabled={isGenerating}
>
{isGenerating ? '⏳ Generating...' : '📥 Download'}
</Button>
<Button
variant="secondary"
onClick={handlePrintCertificate}
className="flex items-center gap-2"
disabled={isGenerating}
>
{isGenerating ? '⏳ Generating...' : '🖨️ Print'}
</Button>
<Button
onClick={() =>
navigate(`/teams/${decodedInfo?.teamId}/submission`)
}
variant="secondary"
className="col-span-2 flex items-center gap-2 xl:col-span-1"
>
View Submission
</Button>
</div>
</div>
{/* Info Box */}
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 no-print">
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
Certificate Information
</h3>
<p className="text-sm text-blue-800 dark:text-blue-200">
This certificate is a digital record of your hackathon participation
and project submission. You can print or save this page as a PDF for
your records.
</p>
</div>
</div>
</div>
);
};
export default CertificatePage;
+171 -52
View File
@@ -11,6 +11,12 @@ import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Icon } from '@iconify/react';
import ProfilePage from '../profile/page';
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
// Submission deadline: 2025-12-07 23:59:00 WIB (UTC+7)
const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z');
type Invitation = {
id: string;
team: {
@@ -34,6 +40,46 @@ type Invitation = {
const DashboardPage: FC = (): ReactElement => {
const { session } = useAuthStore();
const [showProfileModal, setShowProfileModal] = useState(false);
const [timeLeft, setTimeLeft] = useState<{
days: number;
hours: number;
minutes: number;
seconds: number;
} | null>(null);
// Check if team features are closed
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
// Check if submission deadline passed
const isSubmissionDeadlinePassed = new Date() >= SUBMISSION_DEADLINE;
// Countdown timer
useEffect(() => {
if (isSubmissionDeadlinePassed) return;
const calculateTimeLeft = () => {
const now = new Date();
const difference = SUBMISSION_DEADLINE.getTime() - now.getTime();
if (difference <= 0) {
setTimeLeft(null);
return;
}
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
const minutes = Math.floor((difference / 1000 / 60) % 60);
const seconds = Math.floor((difference / 1000) % 60);
setTimeLeft({ days, hours, minutes, seconds });
};
calculateTimeLeft();
const timer = setInterval(calculateTimeLeft, 1000);
return () => clearInterval(timer);
}, [isSubmissionDeadlinePassed]);
// Lock background scroll when profile modal is open
useEffect(() => {
if (showProfileModal) {
@@ -88,11 +134,70 @@ const DashboardPage: FC = (): ReactElement => {
)}
</div>
{/* Countdown Timer */}
{timeLeft && !isSubmissionDeadlinePassed && (
<div className="mb-8 bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6">
<div className="flex items-start space-x-3">
<span className="text-3xl"></span>
<div className="flex-1">
<h3 className="font-bold text-blue-900 dark:text-blue-100 text-lg">
Submission Deadline
</h3>
<p className="text-blue-800 dark:text-blue-200 mt-2 text-sm font-sans">
Project submissions close on December 7, 2025 at 23:59 WIB
</p>
<div className="mt-4 grid grid-cols-4 gap-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{timeLeft.days}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Days
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{timeLeft.hours.toString().padStart(2, '0')}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Hours
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{timeLeft.minutes.toString().padStart(2, '0')}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Minutes
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{timeLeft.seconds.toString().padStart(2, '0')}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Seconds
</div>
</div>
</div>
</div>
</div>
</div>
)}
{invitations.length > 0 && (
<div className="mb-8 bg-primary-50 dark:bg-blue-900/20 border border-primary-200 dark:border-blue-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
Team Invitations ({invitations.length})
</h2>
{isTeamFeaturesClosed && (
<div className="mb-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<p className="text-sm text-amber-700 dark:text-amber-300 flex items-center gap-2">
<Icon icon="mdi:clock-alert" className="text-lg shrink-0" />
Team features are closed. You can no longer accept invitations.
</p>
</div>
)}
<div className="space-y-3 font-sans">
{invitations.map((invitation) => (
<div
@@ -108,21 +213,23 @@ const DashboardPage: FC = (): ReactElement => {
{invitation.inviter?.fullname ?? 'Unknown User'}
</p>
</div>
<div className="flex space-x-2">
<Button
size="sm"
onClick={() => handleAcceptInvitation(invitation.id)}
>
Accept
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => handleRejectInvitation(invitation.id)}
>
Decline
</Button>
</div>
{!isTeamFeaturesClosed && (
<div className="flex space-x-2">
<Button
size="sm"
onClick={() => handleAcceptInvitation(invitation.id)}
>
Accept
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => handleRejectInvitation(invitation.id)}
>
Decline
</Button>
</div>
)}
</div>
))}
</div>
@@ -130,57 +237,69 @@ const DashboardPage: FC = (): ReactElement => {
)}
{myTeams.length > 0 ? (
<div className="mb-6 md:mb-8">
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
My Team
</h2>
<div className="grid grid-cols-1 gap-4 md:gap-6 w-full md:max-w-lg">
{myTeams.map((team) => (
(() => {
const team = myTeams[0] as any;
return (
<div className="mb-6 md:mb-8">
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
My Team
</h2>
<Link
key={team.id}
to={'/teams/' + team.id}
className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow"
className="block bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow border dark:border-gray-700"
>
{team.banner && (
<div className="w-full aspect-3/1">
<img
src={team.banner}
alt={team.name}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="p-4">
<div className="flex flex-col md:flex-row items-center space-y-3 space-x-3 -mt-14 md:mt-0 mb-2 md:mb-3 min-w-12 min-h-12 ">
{team.logo && (
<img
src={team.banner || '/images/banner-imphnen.webp'}
alt={team.name}
className="w-full aspect-3/1 object-cover"
/>
<div className="p-4 md:p-6">
<div className="flex items-center space-x-4 mb-4">
{team.logo ? (
<img
src={team.logo}
alt={team.name}
className="w-20 h-20 rounded-full object-cover shrink-0 border-black dark:border-gray-700"
className="w-16 h-16 rounded-full object-cover shrink-0"
/>
) : (
<div className="w-16 h-16 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0">
<Icon icon="mdi:account-group" className="text-gray-500 dark:text-gray-400 text-2xl" />
</div>
)}
<div>
<h3 className="text-xl md:text-lg font-bold text-gray-900 dark:text-white leading-tight">
<div className="flex-1 min-w-0">
<h3 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white leading-tight line-clamp-1">
{team.name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 flex items-center">
<Icon
icon="heroicons:map-pin-16-solid"
width="16"
height="16"
/>
<span className="ml-1">{team.city}</span>
</p>
<div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-3 font-sans mt-2">
{team.has_submission && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0">
<Icon icon="mdi:check-circle" className="text-sm" />
Submitted
</span>
)}
{team.city && (
<span className="flex items-center gap-1 truncate">
<Icon icon="mdi:map-marker" className="shrink-0" />
<span className="truncate">{team.city}</span>
</span>
)}
<span className="flex items-center gap-1 shrink-0">
<Icon icon="mdi:account-group" />
{team.member_count || team.members?.length || 0} member{(team.member_count || team.members?.length || 0) !== 1 ? 's' : ''}
</span>
</div>
</div>
</div>
<p className="text-gray-600 dark:text-gray-400 line-clamp-3 text-ellipsis font-sans">
{team.description}
</p>
{team.description && (
<p className="text-gray-600 dark:text-gray-400 line-clamp-3 font-sans">
{team.description}
</p>
)}
</div>
</Link>
))}
</div>
</div>
</div>
);
})()
) : (
<div className="mb-6 md:mb-8 bg-white dark:bg-gray-900 rounded-lg shadow-md p-6 md:p-8">
<div className="text-center">
+23 -36
View File
@@ -1,6 +1,11 @@
import { Outlet, ScrollRestoration, useLocation, useNavigate } from 'react-router-dom';
import {
Outlet,
ScrollRestoration,
useLocation,
useNavigate,
} from 'react-router-dom';
import { useEffect, useState } from 'react';
import { supabase } from '@imphnen-frontend-service/service';
import { useAuthStore, useUserMe } from '@imphnen-frontend-service/service';
// Define onboarding routes
const ONBOARDING_ROUTES = new Set(['/onboarding/user']);
@@ -8,6 +13,8 @@ const ONBOARDING_ROUTES = new Set(['/onboarding/user']);
export default function RootLayout() {
const location = useLocation();
const navigate = useNavigate();
const { session } = useAuthStore();
const { data: userData, isLoading: isUserLoading } = useUserMe();
const [isChecking, setIsChecking] = useState(true);
useEffect(() => {
@@ -26,24 +33,15 @@ export default function RootLayout() {
return;
}
// Check Supabase session
const { data: { session }, error } = await supabase.auth.getSession();
if (error) {
console.error('[Layout] Session error:', error);
}
// Public auth pages (login, signup, forgot-password, reset-password) - allow unauthenticated access
const isPublicAuthPage = pathname === '/auth/login' || pathname === '/auth/signup' || pathname === '/auth/forgot-password' || pathname === '/auth/reset-password';
if (isPublicAuthPage) {
// Public auth pages - allow unauthenticated access
if (pathname.startsWith('/auth')) {
// If already authenticated and not on password reset pages, redirect to dashboard
if (session && pathname !== '/auth/reset-password') {
navigate('/dashboard', { replace: true });
setIsChecking(false);
return;
}
// Allow unauthenticated access
// Allow unauthenticated access to auth pages
setIsChecking(false);
return;
}
@@ -61,30 +59,19 @@ export default function RootLayout() {
return;
}
// Wait for user data to load before checking onboarding
if (isUserLoading) {
return;
}
// Check if user has completed onboarding (skip for onboarding routes)
if (!ONBOARDING_ROUTES.has(pathname)) {
try {
const { data: userData, error: userError } = await supabase
.from('users')
.select('location')
.eq('id', session.user.id)
.single();
const hasLocation = !!userData?.data?.location || !!session?.user?.location;
if (userError) {
console.error('[Layout] Failed to fetch user data:', userError);
setIsChecking(false);
return;
}
const hasLocation = !!userData?.location;
if (!hasLocation) {
navigate('/onboarding/user', { replace: true });
setIsChecking(false);
return;
}
} catch (error) {
// Silently handle error
if (!hasLocation) {
navigate('/onboarding/user', { replace: true });
setIsChecking(false);
return;
}
}
@@ -92,7 +79,7 @@ export default function RootLayout() {
};
checkAuth();
}, [location.pathname, navigate]);
}, [location.pathname, navigate, session, userData, isUserLoading]);
// Show loading state while checking auth
if (isChecking) {
@@ -0,0 +1,23 @@
import { Link } from 'react-router';
export default function MaintenancePage() {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 px-4">
<div className="text-center">
<h1 className="text-6xl font-bold text-yellow-600 mb-4">🚧</h1>
<h2 className="text-3xl font-semibold mb-2">We'll be back soon!</h2>
<p className="text-gray-600">
Our site is currently undergoing scheduled maintenance.
<br />
Thank you for your patience.
</p>
<Link
to="/"
className="mt-4 inline-block text-primary-600 hover:underline"
>
Back to Homepage
</Link>
</div>
</div>
);
}
@@ -60,6 +60,20 @@ const UserOnboardingPage: FC = (): ReactElement => {
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate file size (max 2MB)
if (file.size > 2 * 1024 * 1024) {
toast.error('The file is too large. Maximum size is 2MB.');
e.target.value = '';
return;
}
// Validate file type
if (!file.type.startsWith('image/')) {
toast.error('The file must be an image');
e.target.value = '';
return;
}
setAvatarFile(file);
const reader = new FileReader();
reader.onloadend = () => {
@@ -150,7 +164,7 @@ const UserOnboardingPage: FC = (): ReactElement => {
/>
</label>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2 text-center">
Optional, but highly recommended
Optional, but highly recommended. Max 2MB
</p>
</div>
</div>
+4 -4
View File
@@ -1,4 +1,4 @@
import { useNavigate } from 'react-router';
import { useNavigate, Link } from 'react-router';
import { useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Icon } from '@iconify/react';
@@ -796,12 +796,12 @@ export default function HomePage() {
</a>
</li>
<li>
<a
href="/auth/signup"
<Link
to="/auth/signup"
className="hover:text-white transition-colors"
>
Daftar
</a>
</Link>
</li>
</ul>
</div>
+6 -4
View File
@@ -84,15 +84,17 @@ const ProfilePage: FC<ProfileModalProps> = ({
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
toast.error('The file is too large. Maximum 5MB');
// Validate file size (max 2MB)
if (file.size > 2 * 1024 * 1024) {
toast.error('The file is too large. Maximum size is 2MB.');
e.target.value = '';
return;
}
// Validate file type
if (!file.type.startsWith('image/')) {
toast.error('The file must be an image');
e.target.value = '';
return;
}
@@ -235,7 +237,7 @@ const ProfilePage: FC<ProfileModalProps> = ({
<p className="text-sm text-gray-500 dark:text-neutral-400 text-center font-sans">
Click the camera icon to change your photo
<br />
Format: JPG, PNG. Max 5MB
Format: JPG, PNG. Max 2MB
</p>
</div>
@@ -16,6 +16,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { CitySelect } from '../../../../components/city-select';
import { Icon } from '@iconify/react';
import { toast } from 'sonner';
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
const EditTeamPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
@@ -85,6 +88,11 @@ const EditTeamPage: FC = (): ReactElement => {
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > MAX_FILE_SIZE) {
toast.error('Logo image is too large. Maximum size is 2MB.');
e.target.value = '';
return;
}
setLogoFile(file);
const reader = new FileReader();
reader.onloadend = () => {
@@ -97,6 +105,11 @@ const EditTeamPage: FC = (): ReactElement => {
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > MAX_FILE_SIZE) {
toast.error('Banner image is too large. Maximum size is 2MB.');
e.target.value = '';
return;
}
setBannerFile(file);
const reader = new FileReader();
reader.onloadend = () => {
@@ -183,7 +196,7 @@ const EditTeamPage: FC = (): ReactElement => {
Click to upload banner
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
1200x400 recommended
1200x400 recommended. Max 2MB
</p>
</div>
<input
@@ -215,32 +228,35 @@ const EditTeamPage: FC = (): ReactElement => {
</span>
</div>
)}
<div className="flex flex-col md:flex-row items-start justify-start gap-1">
<label htmlFor="logo" className="cursor-pointer">
<span className="px-4 py-2 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700 inline-block">
{logoPreview ? 'Change Logo' : 'Upload Logo'}
</span>
<input
id="logo"
type="file"
accept="image/*"
className="hidden"
onChange={handleLogoChange}
/>
</label>
{logoPreview && (
<button
type="button"
onClick={() => {
setLogoFile(null);
setLogoPreview('');
form.setValue('logo', null);
}}
className="px-4 py-2 text-sm bg-danger-600 text-white rounded-lg hover:bg-danger-700 cursor-pointer"
>
Remove
</button>
)}
<div>
<div className="flex flex-col md:flex-row items-start justify-start gap-1">
<label htmlFor="logo" className="cursor-pointer">
<span className="px-4 py-2 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700 inline-block">
{logoPreview ? 'Change Logo' : 'Upload Logo'}
</span>
<input
id="logo"
type="file"
accept="image/*"
className="hidden"
onChange={handleLogoChange}
/>
</label>
{logoPreview && (
<button
type="button"
onClick={() => {
setLogoFile(null);
setLogoPreview('');
form.setValue('logo', null);
}}
className="px-4 py-2 text-sm bg-danger-600 text-white rounded-lg hover:bg-danger-700 cursor-pointer"
>
Remove
</button>
)}
</div>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">Max 2MB</p>
</div>
</div>
</div>
@@ -47,6 +47,7 @@ const ManageMembersPage: FC = (): ReactElement => {
: [];
const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id;
const hasSubmission = team?.has_submission;
const form = useForm<TInviteMemberForm>({
resolver: zodResolver(inviteMemberSchema),
@@ -133,7 +134,11 @@ const ManageMembersPage: FC = (): ReactElement => {
</p>
</div>
<div className="flex space-x-3">
<Button onClick={() => setShowInviteModal(true)}>
<Button
onClick={() => setShowInviteModal(true)}
disabled={hasSubmission}
title={hasSubmission ? 'Cannot invite members after project submission' : undefined}
>
Invite Member
</Button>
<Button
@@ -148,6 +153,23 @@ const ManageMembersPage: FC = (): ReactElement => {
</div>
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
{/* Submission Lock Warning */}
{hasSubmission && (
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-4">
<div className="flex items-start space-x-3">
<span className="text-2xl">🔒</span>
<div>
<h3 className="font-bold text-amber-900 dark:text-amber-100">
Team Locked
</h3>
<p className="text-amber-800 dark:text-amber-200 text-sm font-sans mt-1">
Your team has submitted a project. You cannot add or remove members after submission to maintain competition integrity.
</p>
</div>
</div>
</div>
)}
{/* Join Requests */}
{joinRequests.length > 0 && (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-6">
@@ -196,7 +218,8 @@ const ManageMembersPage: FC = (): ReactElement => {
<Button
size="sm"
onClick={() => handleApproveRequest(request.id)}
disabled={isResponding}
disabled={isResponding || hasSubmission}
title={hasSubmission ? 'Cannot accept members after project submission' : undefined}
>
Approve
</Button>
@@ -272,7 +295,7 @@ const ManageMembersPage: FC = (): ReactElement => {
</div>
</div>
</div>
{member.role !== 'leader' && (
{member.role !== 'leader' && !hasSubmission && (
<Button
size="sm"
variant="secondary"
@@ -289,12 +312,14 @@ const ManageMembersPage: FC = (): ReactElement => {
</div>
{/* Warning */}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
<strong>Note:</strong> Members cannot leave the team without your
approval. Only you can remove members from the team.
</p>
</div>
{!hasSubmission && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
<strong>Note:</strong> Members cannot leave the team without your
approval. Only you can remove members from the team.
</p>
</div>
)}
</div>
{/* Invite Member Modal */}
+250 -46
View File
@@ -7,6 +7,8 @@ import {
useInviteMember,
useTeamJoinRequests,
useRespondToJoinRequest,
useLeaveTeam,
useDeleteTeam,
ETeamMemberRole,
useAuthStore,
} from '@imphnen-frontend-service/service';
@@ -15,6 +17,9 @@ import { Icon } from '@iconify/react';
const MAX_TEAM_MEMBERS = 5;
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
// Image component with loading state
const ImageWithLoader: FC<{
src: string;
@@ -50,8 +55,14 @@ const TeamDashboardPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
// Check if team features are closed
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
const [showInviteModal, setShowInviteModal] = useState(false);
const [showJoinRequestsModal, setShowJoinRequestsModal] = useState(false);
const [showLeaveModal, setShowLeaveModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const [inviteEmail, setInviteEmail] = useState('');
const [imagesLoaded, setImagesLoaded] = useState(false);
const [imageLoadCount, setImageLoadCount] = useState(0);
@@ -85,25 +96,29 @@ const TeamDashboardPage: FC = (): ReactElement => {
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(
teamId || ''
);
const team = teamData?.data;
const members = membersData?.data || [];
const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id;
// Only fetch join requests if user is the team leader
const { data: joinRequestsData } = useTeamJoinRequests(
teamId || '',
!!teamId
!!teamId && isLeader
);
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(
teamId || ''
);
const { mutateAsync: respondToJoinRequest, isPending: isResponding } =
useRespondToJoinRequest(teamId || '');
const { mutateAsync: leaveTeam, isPending: isLeaving } = useLeaveTeam();
const { mutateAsync: deleteTeam, isPending: isDeleting } = useDeleteTeam();
const team = teamData?.data;
const members = membersData?.data || [];
const joinRequests = joinRequestsData?.data || [];
const pendingJoinRequests = joinRequests.filter(
(req: any) => req.status === 'pending'
);
const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id;
const isMember = members.some(
(member: any) => member.user_id === currentUserId
);
@@ -139,6 +154,32 @@ const TeamDashboardPage: FC = (): ReactElement => {
}
};
const handleLeaveTeam = async () => {
if (!teamId) return;
try {
await leaveTeam(teamId);
toast.success('You have left the team');
navigate('/dashboard');
} catch (error) {
console.error('Failed to leave team:', error);
toast.error('Failed to leave team');
}
};
const handleDeleteTeam = async () => {
if (!teamId) return;
try {
await deleteTeam(teamId);
toast.success('Team deleted successfully');
navigate('/dashboard');
} catch (error: any) {
console.error('Failed to delete team:', error);
toast.error(error?.message || 'Failed to delete team');
}
};
if (isLoadingTeam || isLoadingMembers) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
@@ -334,42 +375,49 @@ const TeamDashboardPage: FC = (): ReactElement => {
Team Management
</h2>
<div className="grid gap-3 sm:grid-cols-2">
<Button
className="w-full"
variant="secondary"
onClick={() => setShowInviteModal(true)}
disabled={!canInvite}
>
<Icon icon="mdi:plus" className="inline-block mr-2" />
Invite Member{' '}
{!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
</Button>
<Button
className="w-full relative"
variant="secondary"
onClick={() => setShowJoinRequestsModal(true)}
>
<Icon icon="mdi:email" className="inline-block mr-2" /> Join
Requests
{pendingJoinRequests.length > 0 && (
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center">
{pendingJoinRequests.length}
</span>
)}
</Button>
<Link to={`/teams/${teamId}/edit`}>
<Button className="w-full" variant="secondary">
<Icon icon="mdi:pencil" className="inline-block mr-2" />
Edit Team Info
</Button>
</Link>
{/* Hide invite/join management after submission or deadline */}
{!team.has_submission && !isTeamFeaturesClosed && (
<>
<Button
className="w-full"
variant="secondary"
onClick={() => setShowInviteModal(true)}
disabled={!canInvite}
>
<Icon icon="mdi:plus" className="inline-block mr-2" />
Invite Member{' '}
{!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
</Button>
<Button
className="w-full relative"
variant="secondary"
onClick={() => setShowJoinRequestsModal(true)}
>
<Icon icon="mdi:email" className="inline-block mr-2" /> Join
Requests
{pendingJoinRequests.length > 0 && (
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center">
{pendingJoinRequests.length}
</span>
)}
</Button>
</>
)}
{!team.has_submission && !isTeamFeaturesClosed && (
<Link to={`/teams/${teamId}/edit`}>
<Button className="w-full" variant="secondary">
<Icon icon="mdi:pencil" className="inline-block mr-2" />
Edit Team Info
</Button>
</Link>
)}
<Link to={`/teams/${teamId}/members`}>
<Button className="w-full" variant="secondary">
<Icon
icon="mdi:account-group"
className="inline-block mr-2"
/>
Manage Members
View Members
</Button>
</Link>
<Link to={`/teams/${teamId}/chat`}>
@@ -378,40 +426,92 @@ const TeamDashboardPage: FC = (): ReactElement => {
Team Chat
</Button>
</Link>
<Link to={`/teams/${teamId}/submit`}>
<Button className="w-full">
<Icon icon="mdi:rocket" className="inline-block mr-2" />
Submit Project
</Button>
</Link>
{team.has_submission ? (
<Link to={`/teams/${teamId}/submission`}>
<Button className="w-full" variant="secondary">
<Icon icon="mdi:file-document" className="inline-block mr-2" />
View Submission
</Button>
</Link>
) : (
<Link to={`/teams/${teamId}/submit`}>
<Button className="w-full" disabled={members.length < 2}>
<Icon icon="mdi:rocket" className="inline-block mr-2" />
Submit Project
</Button>
</Link>
)}
</div>
{!canInvite && members.length >= MAX_TEAM_MEMBERS && (
{!team.has_submission && !canInvite && members.length >= MAX_TEAM_MEMBERS && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-3 text-center">
Maximum team size reached ({MAX_TEAM_MEMBERS} members)
</p>
)}
{/* Danger Zone - Only show when leader is alone, no submission, and features not closed */}
{members.length === 1 && !team.has_submission && !isTeamFeaturesClosed && (
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-medium text-red-600 dark:text-red-400 mb-3">
Danger Zone
</h3>
<Button
className="w-full bg-red-600 hover:bg-red-700 text-white"
onClick={() => setShowDeleteModal(true)}
>
<Icon icon="mdi:delete" className="inline-block mr-2" />
Delete Team
</Button>
</div>
)}
</div>
)}
{/* Quick Actions for Members (non-leaders) */}
{!isLeader && isMember && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 md:p-6">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
Quick Actions
</h2>
<div className="grid gap-3 sm:grid-cols-2">
<Link to={`/teams/${teamId}/chat`}>
<Button className="w-full" variant="secondary">
💬 Team Chat
<Icon icon="mdi:chat" className="inline-block mr-2" />
Team Chat
</Button>
</Link>
{team.has_submission && (
<Link to={`/teams/${teamId}/submission`}>
<Button className="w-full" variant="secondary">
📄 View Submission
<Icon icon="mdi:file-document" className="inline-block mr-2" />
View Submission
</Button>
</Link>
)}
{!team.has_submission && !isTeamFeaturesClosed && (
<Button
className="w-full bg-red-600 hover:bg-red-700 text-white"
onClick={() => setShowLeaveModal(true)}
>
<Icon icon="mdi:exit-run" className="inline-block mr-2" />
Leave Team
</Button>
)}
</div>
</div>
)}
{/* Warning for single member teams */}
{members.length === 1 && !team.has_submission && isMember && (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 md:p-6">
<div className="flex items-center space-x-3">
<span className="text-3xl"></span>
<div>
<h3 className="font-bold text-amber-900 dark:text-amber-400">
Team Needs More Members
</h3>
<p className="text-amber-700 dark:text-amber-300 text-sm">
Your team needs at least 2 members to submit a project. Invite someone or wait for join requests!
</p>
</div>
</div>
</div>
)}
@@ -696,6 +796,110 @@ const TeamDashboardPage: FC = (): ReactElement => {
</div>
</div>
)}
{/* Leave Team Confirmation Modal */}
{showLeaveModal && (
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
<div className="text-center mb-6">
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-4">
<Icon
icon="mdi:exit-run"
className="text-3xl text-red-600 dark:text-red-400"
/>
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Leave Team?
</h2>
<p className="text-gray-600 dark:text-gray-400">
Are you sure you want to leave <strong>{team?.name}</strong>?
</p>
</div>
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-6">
<p className="text-sm text-amber-800 dark:text-amber-300 font-sans">
<strong>Warning:</strong> If you leave, you will need to request to join again or be re-invited by the team leader.
</p>
</div>
<div className="flex space-x-3">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => setShowLeaveModal(false)}
>
Cancel
</Button>
<Button
onClick={handleLeaveTeam}
className="flex-1 bg-red-600 hover:bg-red-700"
disabled={isLeaving}
>
{isLeaving ? 'Leaving...' : 'Leave Team'}
</Button>
</div>
</div>
</div>
)}
{/* Delete Team Confirmation Modal */}
{showDeleteModal && (
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
<div className="text-center mb-6">
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-4">
<Icon
icon="mdi:delete-alert"
className="text-3xl text-red-600 dark:text-red-400"
/>
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Delete Team?
</h2>
<p className="text-gray-600 dark:text-gray-400">
This action is <strong className="text-red-600 dark:text-red-400">permanent</strong> and cannot be undone.
</p>
</div>
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
<p className="text-sm text-red-800 dark:text-red-300 font-sans">
<strong>Warning:</strong> All team data, chat messages, and submissions will be permanently deleted.
</p>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Type <span className="font-bold text-red-600 dark:text-red-400">{team?.name}</span> to confirm:
</label>
<input
type="text"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="Type team name here"
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500"
autoComplete="off"
/>
</div>
<div className="flex space-x-3">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => {
setShowDeleteModal(false);
setDeleteConfirmText('');
}}
>
Cancel
</Button>
<Button
onClick={handleDeleteTeam}
className="flex-1 bg-red-600 hover:bg-red-700"
disabled={isDeleting || deleteConfirmText !== team?.name}
>
{isDeleting ? 'Deleting...' : 'Delete Team'}
</Button>
</div>
</div>
</div>
)}
</div>
);
};
@@ -2,6 +2,7 @@ import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import { useTeamById, useTeamSubmission } from '@imphnen-frontend-service/service';
import { encodeCertificateId } from '../../../../utils/certificate';
const SubmissionViewPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
@@ -15,18 +16,18 @@ const SubmissionViewPage: FC = (): ReactElement => {
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950">
<div className="text-gray-600 dark:text-neutral-400">Loading submission...</div>
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-gray-600 dark:text-gray-400">Loading submission...</div>
</div>
);
}
if (!submission) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950">
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-6xl mb-4">📄</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">No Submission Yet</h2>
<p className="text-gray-600 dark:text-neutral-400 mb-4">Your team hasn't submitted a project</p>
<p className="text-gray-600 dark:text-gray-400 mb-4">Your team hasn't submitted a project</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
</div>
);
@@ -43,13 +44,13 @@ const SubmissionViewPage: FC = (): ReactElement => {
: 'Not submitted';
return (
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950">
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700">
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Project Submission</h1>
<p className="text-gray-600 dark:text-neutral-400 mt-1">{team?.name}</p>
<p className="text-gray-600 dark:text-gray-400 mt-1">{team?.name}</p>
</div>
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
@@ -60,24 +61,78 @@ const SubmissionViewPage: FC = (): ReactElement => {
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Status Banner */}
<div className="bg-green-50 dark:bg-green-900/20 border border-green-500 rounded-lg p-6 mb-6">
<div className="flex items-center space-x-3">
<span className="text-4xl">✅</span>
<div>
<h3 className="font-bold text-green-900 dark:text-green-100 text-lg">Project Submitted Successfully</h3>
<p className="text-green-700 dark:text-green-300 text-sm">
Submitted on {submittedDate}
</p>
<p className="text-green-600 dark:text-green-400 text-xs mt-1">
This submission is now read-only and cannot be edited
</p>
{submission.status === 'submitted' ? (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-500 rounded-lg p-6 mb-6">
<div className="flex items-center space-x-3">
<span className="text-4xl">✅</span>
<div>
<h3 className="font-bold text-green-900 dark:text-green-100 text-lg">Project Submitted Successfully</h3>
<p className="text-green-700 dark:text-green-300 text-sm">
Submitted on {submittedDate}
</p>
<p className="text-green-600 dark:text-green-400 text-xs mt-1">
This submission is now read-only and cannot be edited
</p>
</div>
</div>
</div>
</div>
) : submission.status === 'pending_verification' ? (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-500 rounded-lg p-6 mb-6">
<div className="flex items-center space-x-3">
<span className="text-4xl">⏳</span>
<div>
<h3 className="font-bold text-yellow-900 dark:text-yellow-100 text-lg">Submission Pending Verification</h3>
<p className="text-yellow-700 dark:text-yellow-300 text-sm">
Your submission is being processed
</p>
</div>
</div>
</div>
) : (
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-500 rounded-lg p-6 mb-6">
<div className="flex items-center space-x-3">
<span className="text-4xl">📝</span>
<div>
<h3 className="font-bold text-orange-900 dark:text-orange-100 text-lg">Draft Submission</h3>
<p className="text-orange-700 dark:text-orange-300 text-sm">
This submission is still in draft and has not been finalized
</p>
</div>
</div>
</div>
)}
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-950/50 overflow-hidden">
{/* Certificate Banner - Only show when submission is submitted */}
{submission.status === 'submitted' && (
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-lg p-6 mb-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center space-x-3 flex-1 min-w-0">
<span className="text-4xl shrink-0">🏆</span>
<div className="min-w-0">
<h3 className="font-bold text-amber-900 dark:text-amber-100 text-lg">
View Your Certificate
</h3>
<p className="text-amber-700 dark:text-amber-300 text-sm">
Congratulations! Your certificate is ready to download and share.
</p>
</div>
</div>
<button
onClick={async () => {
const certId = await encodeCertificateId(teamId || '', submission.id);
navigate(`/certificate/${encodeURIComponent(certId)}`);
}}
className="shrink-0 px-6 py-2 bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors"
>
Get Certificate
</button>
</div>
</div>
)}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 overflow-hidden">
{/* Project Header */}
<div className="bg-gradient-to-r from-blue-600 to-blue-800 text-white p-8">
<div className="bg-linear-to-r from-blue-600 to-blue-800 text-white p-8">
<h2 className="text-3xl font-bold mb-2">{submission.project_name}</h2>
<p className="text-blue-100">Team: {team?.name}</p>
</div>
@@ -87,8 +142,8 @@ const SubmissionViewPage: FC = (): ReactElement => {
{/* Description */}
<div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Project Description</h3>
<div className="bg-gray-50 dark:bg-neutral-800 rounded-lg p-4">
<p className="text-gray-700 dark:text-neutral-300 whitespace-pre-wrap">{submission.description}</p>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{submission.description}</p>
</div>
</div>
@@ -124,21 +179,6 @@ const SubmissionViewPage: FC = (): ReactElement => {
</div>
)}
{/* Presentation URL */}
{submission.presentation_url && (
<div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Presentation</h3>
<a
href={submission.presentation_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-2 text-blue-600 dark:text-primary-400 hover:text-blue-800 dark:hover:text-primary-300"
>
<span>📊</span>
<span className="break-all">{submission.presentation_url}</span>
</a>
</div>
)}
</div>
{/* Screenshots */}
@@ -159,7 +199,7 @@ const SubmissionViewPage: FC = (): ReactElement => {
<img
src={url}
alt={`Screenshot ${index + 1}`}
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-neutral-700 hover:border-blue-500 dark:hover:border-primary-500 transition-colors cursor-pointer"
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-primary-500 transition-colors cursor-pointer"
/>
</a>
))}
@@ -168,21 +208,31 @@ const SubmissionViewPage: FC = (): ReactElement => {
)}
{/* Submission Info */}
<div className="bg-gray-50 dark:bg-neutral-800 rounded-lg p-4 border-t-4 border-blue-600 dark:border-primary-500">
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border-t-4 border-blue-600 dark:border-primary-500">
<h3 className="text-sm font-bold text-gray-900 dark:text-white mb-2">Submission Information</h3>
<div className="grid gap-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-neutral-400">Status:</span>
<span className="font-medium text-green-600 dark:text-green-400">
{submission.status === 'submitted' ? ' Submitted' : 'Draft'}
<span className="text-gray-600 dark:text-gray-400">Status:</span>
<span className={`font-medium ${
submission.status === 'submitted'
? 'text-green-600 dark:text-green-400'
: submission.status === 'pending_verification'
? 'text-yellow-600 dark:text-yellow-400'
: 'text-orange-600 dark:text-orange-400'
}`}>
{submission.status === 'submitted'
? ' Submitted'
: submission.status === 'pending_verification'
? ' Pending Verification'
: '📝 Draft'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-neutral-400">Submitted:</span>
<span className="text-gray-600 dark:text-gray-400">Submitted:</span>
<span className="font-medium text-gray-900 dark:text-white">{submittedDate}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-neutral-400">Submission ID:</span>
<span className="text-gray-600 dark:text-gray-400">Submission ID:</span>
<span className="font-medium text-gray-900 dark:text-white font-mono text-xs">
{submission.id}
</span>
@@ -1,4 +1,4 @@
import { FC, ReactElement, useState } from 'react';
import { FC, ReactElement, useState, useEffect } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
@@ -13,13 +13,58 @@ import {
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
const MIN_TEAM_MEMBERS = 2; // Minimum members required to submit (including leader)
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
// Submission deadline: 2025-12-07 23:59:00 WIB (UTC+7)
const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z');
const SubmitProjectPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [confirmText, setConfirmText] = useState('');
const [screenshots, setScreenshots] = useState<string[]>([]);
const [timeLeft, setTimeLeft] = useState<{
days: number;
hours: number;
minutes: number;
seconds: number;
} | null>(null);
// Check if deadline passed
const isDeadlinePassed = new Date() >= SUBMISSION_DEADLINE;
// Countdown timer
useEffect(() => {
if (isDeadlinePassed) return;
const calculateTimeLeft = () => {
const now = new Date();
const difference = SUBMISSION_DEADLINE.getTime() - now.getTime();
if (difference <= 0) {
setTimeLeft(null);
return;
}
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
const minutes = Math.floor((difference / 1000 / 60) % 60);
const seconds = Math.floor((difference / 1000) % 60);
setTimeLeft({ days, hours, minutes, seconds });
};
calculateTimeLeft();
const timer = setInterval(calculateTimeLeft, 1000);
return () => clearInterval(timer);
}, [isDeadlinePassed]);
const { data: teamData } = useTeamById(teamId || '');
const { data: submissionData } = useTeamSubmission(teamId || '', !!teamId);
@@ -31,6 +76,8 @@ const SubmitProjectPage: FC = (): ReactElement => {
const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id;
const hasSubmission = !!submissionData?.data;
const memberCount = team?.members?.length || 0;
const hasEnoughMembers = memberCount >= MIN_TEAM_MEMBERS;
const form = useForm<TProjectSubmissionForm>({
resolver: zodResolver(projectSubmissionSchema),
@@ -78,12 +125,64 @@ const SubmitProjectPage: FC = (): ReactElement => {
);
}
// Show deadline passed screen
if (isDeadlinePassed) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
<div className="mb-6">
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-4">
<Icon
icon="mdi:clock-alert"
className="text-3xl text-red-600 dark:text-red-400"
/>
</div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Submission Closed
</h2>
<p className="text-gray-600 dark:text-gray-400">
Project submissions are no longer accepted.
</p>
</div>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
The submission deadline was December 7, 2025 at 23:59 WIB.
</p>
<button
onClick={() => navigate(`/teams/${teamId}`)}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer"
>
Back to Team
</button>
<button
onClick={() => navigate('/dashboard')}
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
>
Back to Dashboard
</button>
</div>
</div>
</div>
);
}
const handleScreenshotUpload = async (
e: React.ChangeEvent<HTMLInputElement>
) => {
const files = e.target.files;
if (!files) return;
// Validate file sizes
const oversizedFiles = Array.from(files).filter(file => file.size > MAX_FILE_SIZE);
if (oversizedFiles.length > 0) {
toast.error(`${oversizedFiles.length} file(s) are too large. Maximum size is 2MB per file.`);
e.target.value = '';
return;
}
try {
const uploadPromises = Array.from(files).map((file) => uploadFile(file));
const results = await Promise.all(uploadPromises);
@@ -91,6 +190,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
setScreenshots([...screenshots, ...urls]);
} catch (error) {
console.error('Failed to upload screenshots:', error);
toast.error('Failed to upload screenshots. Please try again.');
}
};
@@ -122,6 +222,76 @@ const SubmitProjectPage: FC = (): ReactElement => {
</div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Countdown Timer */}
{timeLeft && (
<div className="bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6 mb-6">
<div className="flex items-start space-x-3">
<span className="text-3xl"></span>
<div className="flex-1">
<h3 className="font-bold text-blue-900 dark:text-blue-100 text-lg">
Submission Deadline
</h3>
<p className="text-blue-800 dark:text-blue-200 mt-2 text-sm font-sans">
Submissions close on December 7, 2025 at 23:59 WIB
</p>
<div className="mt-4 grid grid-cols-4 gap-4">
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{timeLeft.days}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Days
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{timeLeft.hours.toString().padStart(2, '0')}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Hours
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{timeLeft.minutes.toString().padStart(2, '0')}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Minutes
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{timeLeft.seconds.toString().padStart(2, '0')}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Seconds
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* Minimum Members Warning */}
{!hasEnoughMembers && (
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-6 mb-6">
<div className="flex items-start space-x-3">
<span className="text-3xl">👥</span>
<div>
<h3 className="font-bold text-amber-900 dark:text-amber-100 text-lg">
Team Members Required
</h3>
<p className="text-amber-800 dark:text-amber-200 mt-2 text-sm font-sans">
Your team needs at least <strong>{MIN_TEAM_MEMBERS} members</strong> to submit a project.
Currently you have <strong>{memberCount} member{memberCount !== 1 ? 's' : ''}</strong>.
Please invite more members to your team before submitting.
</p>
</div>
</div>
</div>
)}
{/* Warning Banner */}
<div className="bg-red-50 dark:bg-red-900/20 border-2 border-red-500 rounded-lg p-6 mb-6">
<div className="flex items-start space-x-3">
@@ -165,6 +335,9 @@ const SubmitProjectPage: FC = (): ReactElement => {
<label className="block text-[15px] font-medium text-gray-700 dark:text-gray-300">
Project Description <span className="text-red-500">*</span>
</label>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
Describe your project, its features, and what problem it solves. You can also paste your demo video link here.
</p>
<Controller
control={form.control}
name="description"
@@ -172,7 +345,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
<div>
<Textarea
{...field}
placeholder="Describe your project, its features, and what problem it solves..."
placeholder="Describe your project, its features, and what problem it solves... You can paste your demo video link (YouTube, Loom, etc.) here as well."
rows={6}
className="w-full"
size="lg"
@@ -208,16 +381,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
size="lg"
/>
{/* Presentation URL */}
<ControlledInputField
control={form.control}
label="Presentation URL (Optional)"
placeholder="https://slides.com/your-presentation or Google Drive link"
name="presentation_url"
type="url"
size="lg"
/>
{/* Screenshots */}
<div>
<label className="block text-label1 font-medium text-neutral-800 dark:text-gray-300">
@@ -247,7 +410,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
Click to upload screenshots
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1 font-sans">
PNG, JPG up to 5MB each
PNG, JPG. Max 2MB each
</p>
</div>
<input
@@ -274,11 +437,16 @@ const SubmitProjectPage: FC = (): ReactElement => {
<Button
type="submit"
className="flex-1"
disabled={!form.formState.isValid || isUploading}
disabled={!form.formState.isValid || isUploading || !hasEnoughMembers}
>
Review & Submit
</Button>
</div>
{!hasEnoughMembers && (
<p className="text-center text-sm text-amber-600 dark:text-amber-400 mt-2">
You need at least {MIN_TEAM_MEMBERS} team members to submit
</p>
)}
</form>
</div>
</div>
@@ -298,7 +466,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 mb-6">
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
By clicking "Submit Project", you confirm that:
By submitting, you confirm that:
</p>
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-2">
<li> All information is correct and complete</li>
@@ -307,19 +475,35 @@ const SubmitProjectPage: FC = (): ReactElement => {
<li> Your team agrees with this submission</li>
</ul>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Type <span className="font-bold text-red-600 dark:text-red-400">SUBMIT</span> to confirm:
</label>
<input
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="Type SUBMIT here"
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500"
autoComplete="off"
/>
</div>
<div className="flex space-x-3">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => setShowConfirmModal(false)}
onClick={() => {
setShowConfirmModal(false);
setConfirmText('');
}}
>
Go Back
</Button>
<Button
onClick={onSubmit}
className="flex-1 bg-red-600 hover:bg-red-700"
disabled={isSubmitting}
disabled={isSubmitting || confirmText !== 'SUBMIT'}
>
{isSubmitting ? 'Submitting...' : 'Submit Project'}
</Button>
+446 -80
View File
@@ -1,6 +1,6 @@
import { FC, ReactElement, useState, useEffect } from 'react';
import { FC, ReactElement, useState, useEffect, useCallback } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Link, useNavigate } from 'react-router';
import { Link, useNavigate, useSearchParams } from 'react-router';
import {
useTeams,
useJoinTeam,
@@ -14,26 +14,193 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { CitySelect } from '../../../components/city-select';
import { Icon } from '@iconify/react';
const DEFAULT_PER_PAGE = 12;
const PER_PAGE_OPTIONS = [6, 12, 24, 48];
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
// Member filter options
const MEMBER_FILTER_OPTIONS = [
{ label: 'All Teams', value: '', minMembers: undefined, maxMembers: undefined },
{ label: 'Looking for Members (1-4)', value: 'looking', minMembers: 1, maxMembers: 4 },
{ label: '1 Member', value: '1', minMembers: 1, maxMembers: 1 },
{ label: '2 Members', value: '2', minMembers: 2, maxMembers: 2 },
{ label: '3 Members', value: '3', minMembers: 3, maxMembers: 3 },
{ label: '4 Members', value: '4', minMembers: 4, maxMembers: 4 },
{ label: '5 Members (Full)', value: '5', minMembers: 5, maxMembers: 5 },
];
// Submission status filter options
const SUBMISSION_FILTER_OPTIONS = [
{ label: 'All Teams', value: '' },
{ label: 'Submitted', value: 'true' },
{ label: 'Not Submitted', value: 'false' },
];
// Skeleton card component for loading state
const TeamCardSkeleton: FC = () => (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden flex flex-col border dark:border-gray-800 animate-pulse">
<div className="w-full aspect-3/1 bg-gray-200 dark:bg-gray-700" />
<div className="p-6 flex flex-col flex-1">
<div className="flex items-center space-x-3 mb-3">
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700" />
<div className="flex-1">
<div className="h-5 w-32 bg-gray-200 dark:bg-gray-700 rounded mb-2" />
<div className="h-4 w-48 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
</div>
<div className="space-y-2 mb-4 flex-1">
<div className="h-4 w-full bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-4 w-5/6 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-4 w-4/6 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
<div className="space-y-3 mt-auto">
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
</div>
</div>
</div>
);
const BrowseTeamsPage: FC = (): ReactElement => {
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [selectedCity, setSelectedCity] = useState('');
const [searchParams, setSearchParams] = useSearchParams();
// Check if team features are closed
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
// Initialize state from URL params
const initialPage = parseInt(searchParams.get('page') || '1', 10);
const initialPerPage = parseInt(
searchParams.get('per_page') || String(DEFAULT_PER_PAGE),
10
);
const initialSearch = searchParams.get('search') || '';
const initialCity = searchParams.get('city') || '';
const initialMembers = searchParams.get('members') || '';
const initialSubmission = searchParams.get('submission') || '';
const [searchTerm, setSearchTerm] = useState(initialSearch);
const [debouncedSearch, setDebouncedSearch] = useState(initialSearch);
const [selectedCity, setSelectedCity] = useState(initialCity);
const [selectedMembers, setSelectedMembers] = useState(initialMembers);
const [selectedSubmission, setSelectedSubmission] = useState(initialSubmission);
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const [showJoinModal, setShowJoinModal] = useState(false);
const [currentPage, setCurrentPage] = useState(
PER_PAGE_OPTIONS.includes(initialPerPage) ? initialPage : 1
);
const [perPage, setPerPage] = useState(
PER_PAGE_OPTIONS.includes(initialPerPage) ? initialPerPage : DEFAULT_PER_PAGE
);
// Debounce search term
// Update URL when pagination state changes
const updateUrlParams = useCallback(
(params: {
page?: number;
per_page?: number;
search?: string;
city?: string;
members?: string;
submission?: string;
}) => {
const newParams = new URLSearchParams(searchParams);
if (params.page !== undefined) {
if (params.page === 1) {
newParams.delete('page');
} else {
newParams.set('page', String(params.page));
}
}
if (params.per_page !== undefined) {
if (params.per_page === DEFAULT_PER_PAGE) {
newParams.delete('per_page');
} else {
newParams.set('per_page', String(params.per_page));
}
}
if (params.search !== undefined) {
if (params.search === '') {
newParams.delete('search');
} else {
newParams.set('search', params.search);
}
}
if (params.city !== undefined) {
if (params.city === '') {
newParams.delete('city');
} else {
newParams.set('city', params.city);
}
}
if (params.members !== undefined) {
if (params.members === '') {
newParams.delete('members');
} else {
newParams.set('members', params.members);
}
}
if (params.submission !== undefined) {
if (params.submission === '') {
newParams.delete('submission');
} else {
newParams.set('submission', params.submission);
}
}
setSearchParams(newParams, { replace: true });
},
[searchParams, setSearchParams]
);
// Debounce search term and reset page
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchTerm);
if (searchTerm !== debouncedSearch) {
setCurrentPage(1);
updateUrlParams({ search: searchTerm, page: 1 });
}
}, 300);
return () => clearTimeout(timer);
}, [searchTerm]);
const { data: teamsData, isLoading } = useTeams({
// Update URL when city filter changes
useEffect(() => {
if (selectedCity !== initialCity) {
setCurrentPage(1);
updateUrlParams({ city: selectedCity, page: 1 });
}
}, [selectedCity]);
// Get member filter values
const memberFilter = MEMBER_FILTER_OPTIONS.find(
(opt) => opt.value === selectedMembers
);
// Convert submission filter value to boolean
const hasSubmissionFilter = selectedSubmission === 'true' ? true : selectedSubmission === 'false' ? false : undefined;
const {
data: teamsData,
isLoading,
isFetching,
} = useTeams({
page: currentPage,
limit: perPage,
search: debouncedSearch,
city: selectedCity || undefined,
visibility: ETeamVisibility.PUBLIC,
minMembers: memberFilter?.minMembers,
maxMembers: memberFilter?.maxMembers,
hasSubmission: hasSubmissionFilter,
});
const { data: myTeamsData } = useMyTeams();
@@ -44,7 +211,9 @@ const BrowseTeamsPage: FC = (): ReactElement => {
mode: 'all',
});
const teams = teamsData?.data || [];
const teams = teamsData?.teams || [];
const totalPages = teamsData?.totalPages || 1;
const total = teamsData?.total || 0;
const myTeams = myTeamsData?.data || [];
// Helper function to check if user is a member of a team
@@ -70,6 +239,43 @@ const BrowseTeamsPage: FC = (): ReactElement => {
}
});
// Generate page numbers to display
const getPageNumbers = () => {
const pages: (number | string)[] = [];
const maxVisible = 5;
if (totalPages <= maxVisible + 2) {
// Show all pages if total is small
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// Always show first page
pages.push(1);
if (currentPage > 3) {
pages.push('...');
}
// Show pages around current
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (currentPage < totalPages - 2) {
pages.push('...');
}
// Always show last page
pages.push(totalPages);
}
return pages;
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
{/* Header */}
@@ -94,7 +300,7 @@ const BrowseTeamsPage: FC = (): ReactElement => {
{/* Filters */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="bg-white dark:bg-gray-900 p-6 rounded-lg shadow-sm mb-6 border dark:border-gray-800">
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Search Teams
@@ -117,13 +323,63 @@ const BrowseTeamsPage: FC = (): ReactElement => {
placeholder="All Cities (search to filter...)"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Filter by Members
</label>
<select
value={selectedMembers}
onChange={(e) => {
setSelectedMembers(e.target.value);
setCurrentPage(1);
updateUrlParams({ members: e.target.value, page: 1 });
}}
className="w-full h-[42px] px-3 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
{MEMBER_FILTER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Submission Status
</label>
<select
value={selectedSubmission}
onChange={(e) => {
setSelectedSubmission(e.target.value);
setCurrentPage(1);
updateUrlParams({ submission: e.target.value, page: 1 });
}}
className="w-full h-[42px] px-3 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
{SUBMISSION_FILTER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
</div>
{/* Teams Count */}
{!isLoading && total > 0 && (
<div className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Showing {(currentPage - 1) * perPage + 1}-
{Math.min(currentPage * perPage, total)} of {total} teams
</div>
)}
{/* Teams List */}
{isLoading ? (
<div className="text-center py-12">
<p className="text-gray-600 dark:text-gray-400">Loading teams...</p>
{isLoading || isFetching ? (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: perPage }).map((_, index) => (
<TeamCardSkeleton key={index} />
))}
</div>
) : teams.length === 0 ? (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-sm p-12 text-center">
@@ -135,85 +391,195 @@ const BrowseTeamsPage: FC = (): ReactElement => {
</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{teams.map((team) => (
<div
key={team.id}
className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow flex flex-col border dark:border-gray-800"
>
<img
src={team.banner || '/images/banner-imphnen.webp'}
alt={team.name}
className="w-full aspect-3/1 object-cover"
/>
<div className="p-6 flex flex-col flex-1">
<div className="flex items-center space-x-3 mb-3">
{team.logo ? (
<img
src={team.logo}
alt={team.name}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-300 text-xl">
<Icon icon="mdi:account-group" />
</span>
</div>
)}
<div className="flex-1 min-w-0">
<h3 className="text-lg font-bold text-gray-900 dark:text-white line-clamp-2 leading-tight">
{team.name}
</h3>
<div className="text-sm font-sans text-gray-600 dark:text-gray-400 flex gap-2">
<p className="truncate flex-1 min-w-0 flex items-center gap-x-1">
<Icon icon="mdi:map-marker" />{' '}
<span>{team.city}</span>
</p>
<p className="whitespace-nowrap shrink-0 flex items-center gap-x-1">
<Icon icon="mdi:account-group" />{' '}
{team.members?.length || 0} members
</p>
<>
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{teams.map((team: any) => (
<div
key={team.id}
className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow flex flex-col border dark:border-gray-800"
>
<img
src={team.banner || '/images/banner-imphnen.webp'}
alt={team.name}
className="w-full aspect-3/1 object-cover"
/>
<div className="p-6 flex flex-col flex-1">
<div className="flex items-center space-x-3 mb-3">
{team.logo ? (
<img
src={team.logo}
alt={team.name}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-300 text-xl">
<Icon icon="mdi:account-group" />
</span>
</div>
)}
<div className="flex-1 min-w-0">
<h3 className="text-lg font-bold text-gray-900 dark:text-white line-clamp-1 leading-tight">
{team.name}
</h3>
<div className="text-sm font-sans text-gray-600 dark:text-gray-400 flex items-center gap-2 mt-1">
{team.has_submission && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0">
<Icon icon="mdi:check-circle" className="text-sm" />
Submitted
</span>
)}
<p className="truncate flex-1 min-w-0 flex items-center gap-x-1">
<Icon icon="mdi:map-marker" />{' '}
<span>{team.city}</span>
</p>
<p className="whitespace-nowrap shrink-0 flex items-center gap-x-1">
<Icon icon="mdi:account-group" />{' '}
{team.member_count || 0} members
</p>
</div>
</div>
</div>
</div>
<p className="text-gray-600 dark:text-gray-300 text-sm mb-4 line-clamp-3 font-sans">
{team.description}
</p>
<div className="space-y-3 mt-auto">
{isMyTeam(team.id) ? (
<Button
className="w-full"
variant="secondary"
onClick={() => navigate(`/teams/${team.id}`)}
>
Your Team
</Button>
) : (
<>
{myTeams.length === 0 &&
(team.members?.length || 0) < 5 && (
<Button
className="w-full"
onClick={() => handleJoinRequest(team.id)}
>
Request to Join
</Button>
)}
<p className="text-gray-600 dark:text-gray-300 text-sm mb-4 line-clamp-3 font-sans flex-1">
{team.description}
</p>
<div className="space-y-3 mt-auto">
{(team.member_count || 0) === 1 && !team.has_submission && (
<div className="p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<p className="text-xs text-amber-700 dark:text-amber-400 flex items-center gap-1">
<Icon icon="mdi:alert" className="text-sm shrink-0" />
<span>This team needs at least 2 members to submit</span>
</p>
</div>
)}
{isMyTeam(team.id) ? (
<Button
className="w-full"
variant="secondary"
onClick={() => navigate(`/teams/${team.id}`)}
>
View Team
Your Team
</Button>
</>
)}
) : (
<>
{myTeams.length === 0 &&
(team.member_count || 0) < 5 &&
!team.has_submission &&
!isTeamFeaturesClosed && (
<Button
className="w-full"
onClick={() => handleJoinRequest(team.id)}
>
Request to Join
</Button>
)}
<Button
className="w-full"
variant="secondary"
onClick={() => navigate(`/teams/${team.id}`)}
>
View Team
</Button>
</>
)}
</div>
</div>
</div>
))}
</div>
{/* Pagination */}
{(totalPages > 1 || total > 6) && (
<div className="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
{totalPages > 1 && (
<div className="flex items-center gap-2">
<Button
variant="secondary"
onClick={() => {
const newPage = Math.max(1, currentPage - 1);
setCurrentPage(newPage);
updateUrlParams({ page: newPage });
}}
disabled={currentPage === 1 || isFetching}
className="px-3"
>
<Icon icon="mdi:chevron-left" className="text-xl" />
</Button>
<div className="flex items-center gap-1">
{getPageNumbers().map((page, index) =>
typeof page === 'string' ? (
<span
key={`ellipsis-${index}`}
className="px-2 text-gray-400 dark:text-gray-500"
>
...
</span>
) : (
<button
key={page}
onClick={() => {
setCurrentPage(page);
updateUrlParams({ page });
}}
disabled={isFetching}
className={`min-w-10 h-10 px-3 rounded-md text-sm font-medium transition-colors ${
currentPage === page
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-300 dark:border-gray-600'
}`}
>
{page}
</button>
)
)}
</div>
<Button
variant="secondary"
onClick={() => {
const newPage = Math.min(totalPages, currentPage + 1);
setCurrentPage(newPage);
updateUrlParams({ page: newPage });
}}
disabled={currentPage === totalPages || isFetching}
className="px-3"
>
<Icon icon="mdi:chevron-right" className="text-xl" />
</Button>
</div>
)}
<div className="flex items-center gap-3">
{totalPages > 1 && (
<span className="text-sm text-gray-500 dark:text-gray-400">
Page {currentPage} of {totalPages}
</span>
)}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">Show:</span>
<select
value={perPage}
onChange={(e) => {
const newPerPage = Number(e.target.value);
setPerPage(newPerPage);
setCurrentPage(1);
updateUrlParams({ per_page: newPerPage, page: 1 });
}}
className="h-10 px-3 pr-8 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{PER_PAGE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
+105 -26
View File
@@ -5,11 +5,22 @@ import { useNavigate } from 'react-router';
import { useForm, Controller } from 'react-hook-form';
import { teamCreateSchema, TTeamCreateForm, useCreateTeam, ETeamVisibility, useUploadFile } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import { CitySelect } from '../../../components/city-select';
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
const CreateTeamPage: FC = (): ReactElement => {
const navigate = useNavigate();
// Check if team features are closed
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string>('');
const [bannerFile, setBannerFile] = useState<File | null>(null);
@@ -31,6 +42,11 @@ const CreateTeamPage: FC = (): ReactElement => {
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > MAX_FILE_SIZE) {
toast.error('Logo image is too large. Maximum size is 2MB.');
e.target.value = '';
return;
}
setLogoFile(file);
const reader = new FileReader();
reader.onloadend = () => {
@@ -43,6 +59,11 @@ const CreateTeamPage: FC = (): ReactElement => {
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > MAX_FILE_SIZE) {
toast.error('Banner image is too large. Maximum size is 2MB.');
e.target.value = '';
return;
}
setBannerFile(file);
const reader = new FileReader();
reader.onloadend = () => {
@@ -73,12 +94,67 @@ const CreateTeamPage: FC = (): ReactElement => {
banner: bannerUrl,
});
toast.success('Team created successfully!');
navigate(`/teams/${result.data.id}`);
} catch (error) {
} catch (error: any) {
console.error('Failed to create team:', error);
// Handle specific error messages
const message = error?.message || '';
if (message.includes('413') || message.includes('length limit') || message.includes('too large')) {
toast.error('Image file is too large. Please use smaller images (max 2MB each).');
} else if (message.includes('already a member')) {
toast.error(message);
} else {
toast.error(message || 'Failed to create team. Please try again.');
}
}
});
// Show closed screen if team features are closed
if (isTeamFeaturesClosed) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
<div className="mb-6">
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-4">
<Icon
icon="mdi:clock-alert"
className="text-3xl text-red-600 dark:text-red-400"
/>
</div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Team Features Closed
</h2>
<p className="text-gray-600 dark:text-gray-400">
Team creation is no longer available.
</p>
</div>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
The deadline for team features was November 30, 2025 at 23:59 WIB.
</p>
<button
onClick={() => navigate('/dashboard')}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer"
>
Back to Dashboard
</button>
<button
onClick={() => navigate('/teams/browse')}
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
>
Browse Teams
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950">
{/* Header */}
@@ -119,7 +195,7 @@ const CreateTeamPage: FC = (): ReactElement => {
<label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 dark:border-neutral-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-neutral-800">
<div className="text-center">
<p className="text-gray-500 dark:text-neutral-400">Click to upload banner</p>
<p className="text-xs text-gray-400 dark:text-neutral-500 mt-1">1200x400 recommended</p>
<p className="text-xs text-gray-400 dark:text-neutral-500 mt-1">1200x400 recommended. Max 2MB</p>
</div>
<input
type="file"
@@ -149,30 +225,33 @@ const CreateTeamPage: FC = (): ReactElement => {
</div>
)}
<div>
<label htmlFor="logo" className="cursor-pointer">
<span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
{logoPreview ? 'Change Logo' : 'Upload Logo'}
</span>
<input
id="logo"
type="file"
accept="image/*"
className="hidden"
onChange={handleLogoChange}
/>
</label>
{logoPreview && (
<button
type="button"
onClick={() => {
setLogoFile(null);
setLogoPreview('');
}}
className="ml-3 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
>
Remove
</button>
)}
<div>
<label htmlFor="logo" className="cursor-pointer">
<span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
{logoPreview ? 'Change Logo' : 'Upload Logo'}
</span>
<input
id="logo"
type="file"
accept="image/*"
className="hidden"
onChange={handleLogoChange}
/>
</label>
{logoPreview && (
<button
type="button"
onClick={() => {
setLogoFile(null);
setLogoPreview('');
}}
className="ml-3 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
>
Remove
</button>
)}
</div>
<p className="text-xs text-gray-400 dark:text-neutral-500 mt-2">Max 2MB</p>
</div>
</div>
</div>
+38 -34
View File
@@ -124,60 +124,62 @@ const UserProfilePage: FC = (): ReactElement => {
</div>
)}
{/* Teams Section */}
{/* 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">
Teams ({userTeams.length})
Team
</h2>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-4">
{userTeams.map((team: any) => (
<Link
key={team.id}
to={'/teams/' + team.id}
className="bg-white dark:bg-gray-800 rounded-lg shadow-md dark:shadow-gray-950/50 overflow-hidden hover:shadow-lg transition-shadow"
className="block bg-white dark:bg-gray-800 rounded-lg shadow-md dark:shadow-gray-950/50 overflow-hidden hover:shadow-lg transition-shadow border dark:border-gray-700"
>
<div className="w-full aspect-3/1">
<img
src={team.banner}
alt={team.name}
className="w-full h-full object-cover"
/>
</div>
<img
src={team.banner || '/images/banner-imphnen.webp'}
alt={team.name}
className="w-full aspect-3/1 object-cover"
/>
<div className="p-4">
<div className="flex items-center space-x-3 mb-2">
{team.logo && (
<div className="flex items-center space-x-3 mb-3">
{team.logo ? (
<img
src={team.logo}
alt={team.name}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<Icon icon="mdi:account-group" className="text-gray-500 dark:text-gray-400 text-xl" />
</div>
)}
<div className="flex-1 min-w-0">
<h3 className="font-bold text-gray-900 dark:text-white line-clamp-2 mb-1">
<h3 className="font-bold text-gray-900 dark:text-white line-clamp-1 text-lg">
{team.name}
</h3>
<div className="text-sm text-gray-600 dark:text-gray-400 flex gap-2 font-sans">
<p className="truncate flex-1 min-w-0">
<Icon
icon="mdi:map-marker"
className="inline-block w-4 h-4 mr-1"
/>
{team.city}
</p>
<p className="whitespace-nowrap shrink-0">
<Icon
icon="mdi:account-group"
className="inline-block w-4 h-4 mr-1"
/>
{team.members?.length || 0} members
</p>
<div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-3 font-sans mt-1">
{team.has_submission && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0">
<Icon icon="mdi:check-circle" className="text-sm" />
Submitted
</span>
)}
{team.city && (
<span className="flex items-center gap-1 truncate">
<Icon icon="mdi:map-marker" className="shrink-0" />
<span className="truncate">{team.city}</span>
</span>
)}
</div>
</div>
</div>
<p className="text-gray-600 dark:text-gray-400 text-base line-clamp-2 font-sans">
{team.description}
</p>
{team.description && (
<p className="text-gray-600 dark:text-gray-400 text-sm line-clamp-2 font-sans">
{team.description}
</p>
)}
</div>
</Link>
))}
@@ -186,10 +188,12 @@ const UserProfilePage: FC = (): ReactElement => {
) : (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
Teams
Team
</h2>
<div className="text-center py-8">
<div className="text-4xl mb-3">👥</div>
<div className="text-4xl mb-3">
<Icon icon="mdi:account-group-outline" className="inline-block text-gray-400" />
</div>
<p className="text-gray-600 dark:text-gray-400">
Not in any team yet
</p>
+318
View File
@@ -0,0 +1,318 @@
import { FC, ReactElement } from 'react';
import { useNavigate } from 'react-router';
import { useWinners } from '@imphnen-frontend-service/service';
const WinnerPage: FC = (): ReactElement => {
const navigate = useNavigate();
const { data, isLoading, error } = useWinners();
const winners = data?.data ?? [];
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<div className="text-gray-600 dark:text-gray-400">
Loading winners...
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-6xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Error Loading Winners
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Unable to load winners at this time. Please try again later.
</p>
</div>
);
}
// Sort winners by rank
const sortedWinners = [...winners].sort((a, b) => a.rank - b.rank);
// Medal emojis for top 3
const getMedalEmoji = (rank: number) => {
switch (rank) {
case 1:
return '🥇';
case 2:
return '🥈';
case 3:
return '🥉';
default:
return '🏆';
}
};
// Get rank color
const getRankColor = (rank: number) => {
switch (rank) {
case 1:
return 'from-yellow-400 to-yellow-600';
case 2:
return 'from-gray-300 to-gray-500';
case 3:
return 'from-amber-600 to-amber-800';
default:
return 'from-blue-500 to-blue-700';
}
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
{/* Header */}
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="text-center">
<div className="text-6xl mb-4">🏆</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-2">
Hackathon Winners
</h1>
<p className="text-gray-600 dark:text-gray-400">
Congratulations to all the winning teams!
</p>
</div>
</div>
</div>
{/* Winners List */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{winners.length === 0 ? (
<div className="text-center py-16">
<div className="text-6xl mb-4">🎯</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
No Winners Announced Yet
</h2>
<p className="text-gray-600 dark:text-gray-400">
Winners will be announced here once the hackathon concludes.
</p>
</div>
) : (
<div className="space-y-8">
{/* Top 3 Winners - Mobile View */}
<div className="md:hidden space-y-6">
{[1, 2, 3].map((position) => {
const winner = sortedWinners[position - 1];
if(!winner) return null;
return (
<div
key={winner.id}
className={`bg-white dark:bg-gray-800 rounded-lg p-3 shadow-lg ${
getRankColor(winner.rank).includes('yellow')
? 'border-4 border-yellow-400 dark:border-yellow-600'
: getRankColor(winner.rank).includes('gray')
? 'border-4 border-gray-400 dark:border-gray-600'
: 'border-4 border-amber-600 dark:border-amber-500'
}}`}
>
<div className="flex items-center gap-4">
<div
className={`aspect-square p-1 rounded-full bg-linear-to-br ${getRankColor(
winner.rank
)} flex items-center justify-center text-white font-bold text-2xl`}
>
<div className="text-4xl">
{getMedalEmoji(winner.rank)}
</div>
</div>
{winner.team.logo && (
<img
src={winner.team.logo}
alt={`${winner.team.name} logo`}
className="w-20 h-20 rounded-full object-cover border-3 border-white dark:border-gray-700 shadow-lg"
/>
)}
<div className="flex-1">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
{winner.team.name}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
{winner.team.city}
</p>
</div>
</div>
</div>
);
})}
</div>
{/* Top 3 Winners - Tablet/Desktop Podium View */}
<div className="hidden md:flex gap-8 items-end justify-center w-full">
{[2, 1, 3].map((position) => {
const winner = sortedWinners[position - 1];
if(!winner) return null;
return (
<div
key={winner.id}
className="w-full max-w-xs flex flex-col items-center space-y-4"
>
<div className="text-5xl">{getMedalEmoji(winner.rank)}</div>
{/* Team Logo */}
{winner.team.logo && (
<img
src={winner.team.logo}
alt={`${winner.team.name} logo`}
className="w-24 h-24 rounded-full object-cover border-4 border-white dark:border-gray-700 shadow-lg"
/>
)}
<div className="text-center px-2">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{winner.team.name}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{winner.team.city}
</p>
</div>
{/* Podium */}
<div
className={`${
winner.rank === 1
? 'h-42 bg-yellow-500'
: winner.rank === 2
? 'h-32 bg-gray-400'
: 'h-16 bg-amber-600'
} w-full flex items-end justify-center rounded-t-lg shadow-lg`}
>
<div className="text-white font-bold text-3xl pb-4">
#{winner.rank}
</div>
</div>
</div>
);
})}
</div>
{/* Ranks 4-23: Prize Winners */}
{sortedWinners.filter((w) => w.rank >= 4 && w.rank <= 23).length >
0 && (
<div className="mt-12">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">
Favorite
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sortedWinners
.filter((w) => w.rank >= 4 && w.rank <= 23)
.map((winner) => (
<div
key={winner.id}
className="bg-white dark:bg-gray-800 border-2 border-gray-400 dark:border-gray-600 rounded-lg p-4 hover:shadow-lg transition-shadow"
>
<div className="flex items-center gap-4">
<div className="shrink-0">
<div className="w-12 h-12 rounded-full bg-linear-to-br from-gray-400 to-gray-600 flex items-center justify-center text-white font-bold text-lg">
#{winner.rank}
</div>
</div>
{winner.team.logo && (
<img
src={winner.team.logo}
alt={`${winner.team.name} logo`}
className="w-16 h-16 rounded-full object-cover border-2 border-gray-200 dark:border-gray-700"
/>
)}
<div className="flex-1 min-w-0">
<h3 className="font-bold text-gray-900 dark:text-white truncate">
{winner.team.name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{winner.team.city}
</p>
<div className="flex items-center gap-1 mt-1">
<span className="text-xs bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 px-2 py-1 rounded-full">
🎁 Prize Winner
</span>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Rank 24+: Remaining Participants */}
{sortedWinners.filter((w) => w.rank >= 24).length > 0 && (
<div className="mt-12">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">
All Participants
</h2>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{sortedWinners
.filter((w) => w.rank >= 24)
.map((participant) => (
<div
key={participant.id}
className="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<div className="flex items-center gap-4">
<div className="shrink-0 w-10 text-center">
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">
#{participant.rank}
</span>
</div>
{participant.team.logo && (
<img
src={participant.team.logo}
alt={`${participant.team.name} logo`}
className="w-12 h-12 rounded-full object-cover border-2 border-gray-200 dark:border-gray-700"
/>
)}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-white">
{participant.team.name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{participant.team.city}
</p>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
)}
</div>
{/* Footer Info */}
{winners.length > 0 && (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
Congratulations! 🎉
</h3>
<p className="text-sm text-blue-800 dark:text-blue-200">
Thank you to all participants for making this hackathon a success.
Every project and idea contributed to an incredible showcase of
innovation and creativity.
</p>
</div>
</div>
)}
</div>
);
};
export default WinnerPage;
+5 -14
View File
@@ -3,7 +3,6 @@ import { Link, useLocation } from 'react-router';
import {
useMyTeams,
useAuthStore,
supabase,
} from '@imphnen-frontend-service/service';
import { useNavigate } from 'react-router';
import { toast } from 'sonner';
@@ -40,19 +39,11 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
}
}, [location.pathname]);
const handleLogout = async () => {
try {
await supabase.auth.signOut();
clearSession();
localStorage.clear();
toast.success('Logged out successfully');
navigate('/auth/login');
} catch (error) {
console.error('Logout error:', error);
clearSession();
localStorage.clear();
navigate('/auth/login');
}
const handleLogout = () => {
clearSession();
localStorage.clear();
toast.success('Logged out successfully');
navigate('/auth/login');
};
const navItems: NavItem[] = [
+28 -29
View File
@@ -1,5 +1,5 @@
import { SessionUser } from '@imphnen-frontend-service/utils';
import { supabase } from '@imphnen-frontend-service/service';
import { hackathonApi, SessionToken } from '@imphnen-frontend-service/service';
import { LoaderFunctionArgs, redirect } from 'react-router';
const mappingPublicRoutes = [
@@ -37,14 +37,10 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const pathname = url.pathname;
// Get session from Supabase (authoritative source)
const { data: { session: supabaseSession }, error: sessionError } = await supabase.auth.getSession();
// Handle session errors
if (sessionError) {
console.error('[Middleware] Session error:', sessionError);
// Don't redirect on session errors, let the app handle it
}
// Get token from cookies and user from local storage
const tokenData = SessionToken.get();
const user = SessionUser.get();
const isAuthenticated = !!tokenData?.token?.access_token;
// Allow to access the hackathon pages without authentication
if (mappingPublicPrefixRoutes.some((prefix) => pathname.startsWith(prefix))) {
@@ -63,20 +59,24 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
// Auth routes (all /auth/* paths) - redirect to dashboard if already authenticated
if (pathname.startsWith('/auth')) {
if (supabaseSession) return redirect('/dashboard');
if (isAuthenticated) return redirect('/dashboard');
return null;
}
// Require authentication for all other routes - ONLY check Supabase session
if (!supabaseSession) {
// Require authentication for all other routes
if (!isAuthenticated) {
return redirect('/auth/login');
}
// Check if user has completed onboarding by querying database (not localStorage!)
// Check if user has completed onboarding
// Skip onboarding check for onboarding routes themselves
if (!mappingOnboardingRoutes.includes(pathname)) {
try {
const userId = supabaseSession.user.id;
const userId = user?.id;
if (!userId) {
return redirect('/auth/login');
}
const now = Date.now();
// Check cache first
@@ -86,20 +86,20 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
hasLocation = cached.hasLocation;
} else {
const { data: userData, error: userError } = await supabase
.from('users')
.select('location')
.eq('id', userId)
.single();
if (userError) {
console.error('[Middleware] Failed to fetch user data:', userError);
// If we can't fetch user data, allow access (don't break the app)
return null;
// First check user data (faster)
if (user?.location) {
hasLocation = true;
} else {
// Fetch from backend API
try {
const response = await hackathonApi.get('/users/me');
hasLocation = !!response.data?.data?.location;
} catch {
// If API fails, check user data as fallback
hasLocation = !!user?.location;
}
}
hasLocation = !!userData?.location;
// Update cache
onboardingCache.set(userId, { hasLocation, timestamp: now });
}
@@ -114,10 +114,9 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
}
}
// Check route permissions using fresh user data from Zustand (for UI metadata)
const session = SessionUser.get();
// Check route permissions using user data
const userPermissions =
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
user?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
const matchedRoute = mappingRoutePermissions.find(
(route) => route.path === pathname
+86
View File
@@ -0,0 +1,86 @@
const enc = new TextEncoder();
const dec = new TextDecoder();
function randBytes(len: number): Uint8Array {
const b = new Uint8Array(len);
crypto.getRandomValues(b);
return b;
}
function bufToBase64(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let s = '';
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s);
}
function base64ToBuf(b64: string): ArrayBuffer {
const s = atob(b64);
const arr = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) arr[i] = s.charCodeAt(i);
return arr.buffer;
}
async function deriveKeyFromPassphrase(passphrase: string, salt: Uint8Array, iterations = 100_000) {
const passKey = await crypto.subtle.importKey(
'raw',
enc.encode(passphrase),
{ name: 'PBKDF2' },
false,
['deriveKey']
);
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations,
hash: 'SHA-256'
},
passKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
/**
* Encrypts plaintext with passphrase -> returns base64(salt||iv||ciphertext)
*/
export async function encryptText(plaintext: string, passphrase: string) {
const salt = randBytes(16); // 128-bit salt
const iv = randBytes(12); // 96-bit IV recommended for GCM
const key = await deriveKeyFromPassphrase(passphrase, salt);
const cipher = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
enc.encode(plaintext)
);
// concat salt + iv + ciphertext
const out = new Uint8Array(salt.length + iv.length + cipher.byteLength);
out.set(salt, 0);
out.set(iv, salt.length);
out.set(new Uint8Array(cipher), salt.length + iv.length);
return bufToBase64(out.buffer);
}
/**
* Decrypts base64(salt||iv||ciphertext) with passphrase -> plaintext
*/
export async function decryptText(b64combined: string, passphrase: string) {
const combined = new Uint8Array(base64ToBuf(b64combined));
const salt = combined.slice(0, 16);
const iv = combined.slice(16, 28);
const cipher = combined.slice(28);
const key = await deriveKeyFromPassphrase(passphrase, salt);
const plainBuf = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
cipher
);
return dec.decode(plainBuf);
}
+41
View File
@@ -0,0 +1,41 @@
import { decryptText, encryptText } from "./aesclient";
const SECRET_KEY = 'imphnen-hackathon-2025';
/**
* Encode teamId and submissionId into a certificate ID
* Uses base64 encoding for simple obfuscation
* @param teamId - The team ID
* @param submissionId - The submission ID
* @returns Encoded certificate ID
*/
export const encodeCertificateId = async (teamId: string, submissionId: string): Promise<string> => {
const combined = `${teamId}::${submissionId}`;
return encryptText(combined, SECRET_KEY);
};
/**
* Decode certificate ID back to teamId and submissionId
* @param certId - The encoded certificate ID
* @returns Object containing teamId and submissionId
*/
export const decodeCertificateId = async (certId: string): Promise<{ teamId: string; submissionId: string }> => {
try {
const decoded = await decryptText(certId, SECRET_KEY);
const [teamId, submissionId] = decoded.split('::');
return { teamId, submissionId };
} catch {
throw new Error('Invalid certificate ID');
}
};
/**
* For development: Create a certId using created_at timestamp
* @param teamId - The team ID
* @param createdAt - The creation timestamp
* @returns Encoded certificate ID
*/
export const encodeCertificateIdWithTimestamp = (teamId: string, createdAt: string): string => {
const combined = `${teamId}::${createdAt}`;
return Buffer.from(combined).toString('base64');
};
@@ -6,6 +6,7 @@ import { useRef } from 'react';
import {
FaArrowRight,
FaDiscord,
FaGithub,
FaFacebook,
FaInstagram,
FaLinkedin,
@@ -45,6 +46,8 @@ export function CommunitySection() {
return FaFacebook;
case 'FaDiscord':
return FaDiscord;
case 'FaGithub':
return FaDiscord;
case 'FaInstagram':
return FaInstagram;
case 'FaTiktok':
@@ -66,6 +69,10 @@ export function CommunitySection() {
return {
iconColor: 'text-[#5865F2]',
};
case 'FaGithub':
return {
iconColor: 'text-[#000000]',
};
case 'FaInstagram':
return {
iconColor: 'text-[#E4405F]',
@@ -78,7 +78,7 @@ export function CTASection() {
<div className="flex flex-col items-center gap-6 text-white">
<LogoSimple className="w-[240px]" />
<div className="text-center space-y-2">
<h3 className="text-2xl font-bold">180.000+</h3>
<h3 className="text-2xl font-bold">250.000+</h3>
<p className="text-lg">Programmer Sudah Bergabung</p>
</div>
</div>
@@ -5,6 +5,7 @@ import Link from 'next/link';
import {
FaDiscord,
FaFacebook,
FaGithub,
FaInstagram,
FaLinkedinIn,
FaTiktok,
@@ -37,6 +38,13 @@ export default function Footer() {
>
<FaDiscord className="size-6" />
</Link>
<Link
href="https://github.com/IMPHNEN/"
className="text-muted-foreground hover:text-foreground"
target="_blank"
>
<FaGithub className="size-6" />
</Link>
<Link
href="https://www.instagram.com/imphnen.dev"
className="text-muted-foreground hover:text-foreground"
@@ -92,7 +100,7 @@ export default function Footer() {
</ul>
</div>
<div className="space-y-4">
<h3 className="text-lg font-bold">Patners</h3>
<h3 className="text-lg font-bold">Partners</h3>
<ul className="space-y-2"></ul>
</div>
</div>
+1 -1
View File
@@ -1,5 +1,5 @@
[
{ "value": "180K+", "label": "Member" },
{ "value": "250K+", "label": "Member" },
{ "value": "500+", "label": "Meme Harian" },
{ "value": "24/7", "label": "Yapping" }
]
+7
View File
@@ -13,6 +13,13 @@
"description": "Real-time collaboration chat",
"link": "https://discord.gg/imphnen"
},
{
"name": "Github",
"icon": "FaGithub",
"color": "#5865F2",
"description": "Platforms to collaborate on your code",
"link": "https://github.com/IMPHNEN/"
},
{
"name": "Instagram",
"icon": "FaInstagram",
+4607
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,397 @@
# Hackathon Backoffice API Contract
## GET /api/v1/admin/dashboard
### Purpose
Single endpoint delivering aggregated metrics for the IMPHNEN x Kolosal.ai Hackathon dashboard.
### Authentication & Authorization
- Requires admin (backoffice) scope: e.g. `role=admin`
- 401 if unauthenticated, 403 if authenticated but lacking required scope.
### Examples
```
GET /api/v1/admin/dashboard
```
### Response Schema
```jsonc
{
"data": {
"total_participants": 1261,
"total_teams": 206,
"total_submissions": 0 // Total project submitted
}
}
```
### Field Types
| Path | Type | Notes |
| ------------------------- | ------- | --------------------- |
| `data.total_participants` | integer | >= 0 |
| `data.total_teams` | integer | >= 0 |
| `data.total_submissions` | integer | <= `data.total_teams` |
### Errors
| Status | Code | Message | Notes |
| ------ | ---------------- | ----------------------------- | --------------------- |
| 401 | `unauthorized` | `authentication required` | Missing/invalid token |
| 403 | `forbidden` | `insufficient permissions` | Lacks required scope |
| 429 | `rate_limited` | `too many dashboard requests` | Rate limiting |
| 500 | `internal_error` | `unexpected server error` | Unhandled exception |
---
## GET /api/v1/admin/users
### Purpose
Retrieve paginated list of hackathon participants with filtering, searching, and sorting capabilities for backoffice user management.
### Authentication & Authorization
- Requires admin (backoffice) scope: e.g. `role=admin`
- 401 if unauthenticated, 403 if authenticated but lacking required scope.
### Query Parameters
| Parameter | Type | Required | Default | Description |
| ------------ | ------- | -------- | ------------ | ------------------------------------------------------------- |
| `page` | integer | No | 1 | Page number (1-based) |
| `limit` | integer | No | 10 | Items per page (1-100) |
| `search` | string | No | - | Search by name or location (case-insensitive) |
| `status` | string | No | `all` | Filter by status: `all`, `active`, `inactive` |
| `location` | string | No | `all` | Filter by location or `all` |
| `skills` | string | No | - | Comma-separated skill filters |
| `sort_by` | string | No | `created_at` | Sort field: `fullname`, `location`, `is_active`, `created_at` |
| `sort_order` | string | No | `desc` | Sort order: `asc`, `desc` |
### Examples
```
GET /api/v1/admin/users
GET /api/v1/admin/users?page=2&limit=10
GET /api/v1/admin/users?search=john&status=active
GET /api/v1/admin/users?location=Jakarta&skills=Frontend Developer,UI/UX Designer
GET /api/v1/admin/users?sort_by=fullname&sort_order=asc
```
### Response Schema
```jsonc
{
"data": {
"users": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"avatar": "https://example.com/avatars/user1.jpg", // Optional
"fullname": "Budi Santoso",
"bio": "Passionate developer with 5+ years experience", // Optional
"location": "Jakarta",
"is_active": true,
"skills": ["Frontend Developer", "UI/UX Designer"], // Optional
"created_at": "2024-11-15T08:30:00Z",
"updated_at": "2024-11-30T14:22:00Z"
}
// ... more users
],
"pagination": {
"current_page": 1,
"total_pages": 15,
"total_items": 287,
"items_per_page": 20,
"has_next": true,
"has_prev": false
},
"filters": {
"available_locations": ["Jakarta", "Bandung", "Surabaya", "Medan", "Yogyakarta"],
"available_skills": ["Frontend Developer", "Backend Developer", "Full Stack Developer", "DevOps Engineer", "UI/UX Designer", "Product Manager", "Data Scientist", "Mobile Developer"]
}
}
}
```
### Field Types
| Path | Type | Notes |
| ------------------------- | ------- | ------------------------------ |
| `data.users[].id` | string | UUID format |
| `data.users[].avatar` | string | URL, nullable |
| `data.users[].fullname` | string | Required |
| `data.users[].bio` | string | Optional, max 500 chars |
| `data.users[].location` | string | Required, from predefined list |
| `data.users[].is_active` | boolean | Account status |
| `data.users[].skills` | array | Array of skill strings |
| `data.users[].created_at` | string | ISO 8601 timestamp |
| `data.users[].updated_at` | string | ISO 8601 timestamp |
| `data.pagination.*` | integer | Pagination metadata |
---
## GET /api/v1/admin/users/{user_id}
### Purpose
Retrieve detailed information for a specific user by ID.
### Authentication & Authorization
- Requires admin (backoffice) scope: e.g. `role=admin`
### Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ----------- |
| `user_id` | string | Yes | User UUID |
### Examples
```
GET /api/v1/admin/users/550e8400-e29b-41d4-a716-446655440000
```
### Response Schema
```jsonc
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"avatar": "https://example.com/avatars/user1.jpg",
"fullname": "Budi Santoso",
"bio": "Passionate developer with 5+ years experience",
"location": "Jakarta",
"is_active": true,
"skills": ["Frontend Developer", "UI/UX Designer"],
"created_at": "2024-11-15T08:30:00Z",
"updated_at": "2024-11-30T14:22:00Z"
}
}
```
---
## POST /api/v1/admin/users
### Purpose
Create a new user account in the hackathon system.
### Authentication & Authorization
- Requires admin (backoffice) scope: e.g. `role=admin`
### Request Body Schema
```jsonc
{
"fullname": "Jane Doe", // Required, 1-100 chars
"bio": "Experienced developer", // Optional, max 500 chars
"location": "Jakarta", // Required, from predefined list
"is_active": true, // Required, boolean
"skills": ["Backend Developer"], // Optional, array of valid skills
"avatar": "https://example.com/images/..." // Optional, URL
}
```
### Examples
```
POST /api/v1/admin/users
Content-Type: application/json
{
"fullname": "Jane Doe",
"bio": "Experienced developer passionate about AI and machine learning",
"location": "Jakarta",
"is_active": true,
"skills": ["Backend Developer", "Data Scientist"],
"avatar": "https://example.com/images/..."
}
```
### Response Schema
```jsonc
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440001",
"avatar": "https://example.com/avatars/generated_url.jpg",
"fullname": "Jane Doe",
"bio": "Experienced developer passionate about AI and machine learning",
"location": "Jakarta",
"is_active": true,
"skills": ["Backend Developer", "Data Scientist"],
"created_at": "2024-12-01T10:30:00Z",
"updated_at": "2024-12-01T10:30:00Z"
}
}
```
---
## PUT /api/v1/admin/users/{user_id}
### Purpose
Update an existing user's profile information.
### Authentication & Authorization
- Requires admin (backoffice) scope: e.g. `role=admin`
### Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ----------- |
| `user_id` | string | Yes | User UUID |
### Request Body Schema
```jsonc
{
"fullname": "Jane Smith", // Optional, 1-100 chars
"bio": "Senior developer", // Optional, max 500 chars, null to clear
"location": "Bandung", // Optional, from predefined list
"is_active": false, // Optional, boolean
"skills": ["Full Stack Developer"], // Optional, array of valid skills
"avatar": "https://example.com/images/..." // Optional, URL, null to remove
}
```
### Examples
```
PUT /api/v1/admin/users/550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{
"fullname": "Jane Smith",
"location": "Bandung",
"is_active": false,
"skills": ["Full Stack Developer", "Product Manager"]
}
```
### Response Schema
```jsonc
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"avatar": "https://example.com/avatars/user1.jpg",
"fullname": "Jane Smith",
"bio": "Experienced developer passionate about AI and machine learning",
"location": "Bandung",
"is_active": false,
"skills": ["Full Stack Developer", "Product Manager"],
"created_at": "2024-11-15T08:30:00Z",
"updated_at": "2024-12-01T10:45:00Z"
}
}
```
---
## DELETE /api/v1/admin/users/{user_id}
### Purpose
(Soft) Delete a user account from the hackathon system.
### Authentication & Authorization
- Requires admin (backoffice) scope: e.g. `role=admin`
### Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ----------- |
| `user_id` | string | Yes | User UUID |
### Examples
```
DELETE /api/v1/admin/users/550e8400-e29b-41d4-a716-446655440000
```
### Response Schema
```jsonc
{
"data": {
"message": "User successfully deleted",
"deleted_user_id": "550e8400-e29b-41d4-a716-446655440000",
"deleted_at": "2024-12-01T10:50:00Z"
}
}
```
---
## Common Error Responses
### User Management Endpoints
| Status | Code | Message | Notes |
| ------ | --------------------- | -------------------------------- | ---------------------------- |
| 400 | `validation_error` | `Invalid request data` | Field validation failures |
| 401 | `unauthorized` | `Authentication required` | Missing/invalid token |
| 403 | `forbidden` | `Insufficient permissions` | Lacks required scope |
| 404 | `user_not_found` | `User not found` | Invalid user ID |
| 409 | `user_already_exists` | `User with email already exists` | Duplicate user creation |
| 413 | `payload_too_large` | `Avatar file too large` | Avatar exceeds size limit |
| 422 | `invalid_skill` | `Invalid skill specified` | Skill not in allowed list |
| 422 | `invalid_location` | `Invalid location specified` | Location not in allowed list |
| 429 | `rate_limited` | `Too many requests` | Rate limiting |
| 500 | `internal_error` | `Unexpected server error` | Unhandled exception |
### Validation Error Details
```jsonc
{
"error": {
"code": "validation_error",
"message": "Invalid request data",
"details": [
{
"field": "fullname",
"code": "required",
"message": "Full name is required"
},
{
"field": "location",
"code": "invalid_choice",
"message": "Location must be one of: Jakarta, Bandung, Surabaya, Medan, Yogyakarta"
}
]
}
}
```
## Rate Limiting
- **Dashboard**: 30 requests / minute / admin user
- **User Management**: 100 requests / minute / admin user
- **File Upload**: 10 avatar uploads / minute / admin user
- Return 429 with `Retry-After` header
## Avatar Handling
- **Supported formats**: JPEG, PNG, WebP
- **Max file size**: 5MB
- **Recommended dimensions**: 400x400px
- **Storage**: Uploaded avatars are processed and stored with generated URLs
- **URL response**: Always return publicly accessible HTTPS URLs
---
**Revision History**
- v1.0.0 (2025-11-30): Initial contract drafted.
- v2.0.0 (2025-12-01): Added user management endpoints with filtering, pagination, CRUD operations, and avatar handling.
+54
View File
@@ -0,0 +1,54 @@
import { api } from '../index';
import type {
TAdminUsersResponse,
TAdminTeamsResponse,
TAdminSubmissionsResponse,
} from '../../types/admin';
const ADMIN_BASE_URL = '/admin';
// Admin Users
export const getAdminUsers = async (params?: {
page?: number;
per_page?: number;
search?: string;
is_admin?: boolean;
}) => {
const response = await api.get<TAdminUsersResponse>(
`${ADMIN_BASE_URL}/users`,
{
params: {
...params,
is_admin: params?.is_admin ?? false,
},
}
);
return response.data;
};
// Admin Teams
export const getAdminTeams = async (params?: {
page?: number;
per_page?: number;
search?: string;
}) => {
const response = await api.get<TAdminTeamsResponse>(
`${ADMIN_BASE_URL}/teams`,
{ params }
);
return response.data;
};
// Admin Submissions
export const getAdminSubmissions = async (params?: {
page?: number;
per_page?: number;
search?: string;
status?: string;
}) => {
const response = await api.get<TAdminSubmissionsResponse>(
`${ADMIN_BASE_URL}/submissions`,
{ params }
);
return response.data;
};
+63
View File
@@ -0,0 +1,63 @@
import axios from 'axios';
import { useAuthStore } from '../hooks/auth';
// Backoffice Backend API Base URL
// In development, use proxy; in production, use full URL
const BACKOFFICE_API_URL = 'https://api.hackathon.imphnen.dev/api/v1';
// Create axios instance for backoffice backend
export const backofficeApi = axios.create({
baseURL: BACKOFFICE_API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Add auth token interceptor
backofficeApi.interceptors.request.use(
(config) => {
const { session } = useAuthStore.getState();
if (session?.token?.access_token) {
config.headers.Authorization = `Bearer ${session.token.access_token}`;
}
return config;
},
(error) => {
return Promise.reject(new Error(error.message || 'Request failed'));
}
);
// Error handling interceptor
backofficeApi.interceptors.response.use(
(response) => response,
(error) => {
// Handle 401 - clear session and redirect to login
if (error.response?.status === 401) {
const isAuthPage =
globalThis.window !== undefined &&
globalThis.location.pathname.startsWith('/auth');
if (!isAuthPage) {
useAuthStore.getState().clearSession();
if (globalThis.window !== undefined) {
globalThis.location.href = '/auth/login';
}
}
}
// If backend sends a message, use it
const backendMsg = error?.response?.data?.message;
if (backendMsg && typeof backendMsg === 'string') {
return Promise.reject(new Error(backendMsg));
}
// Fallback error message
return Promise.reject(new Error(error.message || 'An error occurred'));
}
);
// Response type
export interface BackofficeApiResponse<T> {
data: T;
message: string;
}
+61
View File
@@ -0,0 +1,61 @@
import axios from 'axios';
import { useAuthStore } from '../hooks/auth';
// Hackathon Backend API Base URL
const HACKATHON_API_URL = 'https://api.hackathon.imphnen.dev/api/v1';
// Create axios instance for hackathon backend
export const hackathonApi = axios.create({
baseURL: HACKATHON_API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Add auth token interceptor
hackathonApi.interceptors.request.use(
(config) => {
const { session } = useAuthStore.getState();
if (session?.token?.access_token) {
config.headers.Authorization = `Bearer ${session.token.access_token}`;
}
return config;
},
(error) => {
return Promise.reject(new Error(error.message || 'Request failed'));
}
);
// Error handling interceptor
hackathonApi.interceptors.response.use(
(response) => response,
(error) => {
// Handle 401 - clear session and redirect to login
// But skip redirect if already on auth pages (to avoid reload on login failure)
if (error.response?.status === 401) {
const isAuthPage = globalThis.window !== undefined && globalThis.location.pathname.startsWith('/auth');
// Only clear session and redirect if not on auth page
if (!isAuthPage) {
useAuthStore.getState().clearSession();
if (globalThis.window !== undefined) {
globalThis.location.href = '/auth/login';
}
}
}
// If backend sends a message, use it
const backendMsg = error?.response?.data?.message;
if (backendMsg && typeof backendMsg === 'string') {
return Promise.reject(new Error(backendMsg));
}
return Promise.reject(new Error(error.message || 'Request failed'));
}
);
// API Response wrapper type
export interface HackathonApiResponse<T> {
data: T;
message?: string;
}
+11 -3
View File
@@ -5,6 +5,8 @@ export * from './gacha';
export * from './users';
export * from './mentors';
export * from './upload';
export * from './hackathon';
export * from './admin';
// Common API response wrapper interface
export interface ApiResponse<T> {
@@ -19,7 +21,9 @@ const getSessionTokenFromCookies = () => {
if (typeof document === 'undefined') return null;
const cookies = document.cookie.split(';');
const tokenCookie = cookies.find(cookie => cookie.trim().startsWith(`${TOKEN_KEY}=`));
const tokenCookie = cookies.find((cookie) =>
cookie.trim().startsWith(`${TOKEN_KEY}=`)
);
if (!tokenCookie) return null;
@@ -31,13 +35,17 @@ const getSessionTokenFromCookies = () => {
}
};
const setSessionTokenToCookies = (tokenData: { token: { access_token: string; refresh_token: string } }) => {
const setSessionTokenToCookies = (tokenData: {
token: { access_token: string; refresh_token: string };
}) => {
if (typeof document === 'undefined') return;
const expires = new Date();
expires.setDate(expires.getDate() + 7);
document.cookie = `${TOKEN_KEY}=${encodeURIComponent(JSON.stringify(tokenData))}; expires=${expires.toUTCString()}; path=/; secure; samesite=strict`;
document.cookie = `${TOKEN_KEY}=${encodeURIComponent(
JSON.stringify(tokenData)
)}; expires=${expires.toUTCString()}; path=/; secure; samesite=strict`;
};
const removeSessionTokenFromCookies = () => {
+337 -57
View File
@@ -1,57 +1,288 @@
import { supabase } from '../../supabase';
import { useMutation } from '@tanstack/react-query';
import * as authApi from '../../api/auth';
import { useMutation, useQuery } from '@tanstack/react-query';
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
import { backofficeApi, BackofficeApiResponse } from '../../api/backoffice';
import { useAuthStore } from './use-auth-store';
export * from './use-auth-store';
// React Query hooks for auth API
export const usePostLogin = () => {
// Types matching backend response
interface TokenInfo {
access_token: string;
refresh_token: string;
}
interface User {
id: string;
email: string;
fullname: string;
phone_number?: string;
avatar?: string;
birthdate?: string;
gender?: string;
is_active: boolean;
location?: string;
bio?: string;
skills?: string[];
role_id?: string;
created_at: string;
updated_at?: string;
}
interface AuthResponse {
token: TokenInfo;
user: User;
}
interface SessionResponse {
user: User;
}
interface MessageResponse {
message: string;
}
// Login request type
interface LoginRequest {
email: string;
password: string;
}
// Signup request type
interface SignupRequest {
email: string;
password: string;
fullname: string;
}
// GitHub auth request type
interface GitHubAuthRequest {
code: string;
}
// Forgot password request type
interface ForgotPasswordRequest {
email: string;
}
// Reset password request type
interface ResetPasswordRequest {
access_token: string;
new_password: string;
}
// Backend API-based auth hooks
// Email/Password Login
export const useLogin = () => {
const { setSession } = useAuthStore();
return useMutation({
mutationFn: authApi.postLogin,
mutationFn: async (data: LoginRequest) => {
const response = await hackathonApi.post<
HackathonApiResponse<AuthResponse>
>('/auth/login', data);
return response.data.data;
},
onSuccess: (data) => {
setSession({
token: data.token,
user: {
id: data.user.id,
email: data.user.email,
fullname: data.user.fullname,
phone_number: data.user.phone_number || '',
avatar: data.user.avatar || '',
birthdate: data.user.birthdate || '',
gender: data.user.gender || '',
is_active: data.user.is_active,
location: data.user.location,
bio: data.user.bio,
skills: data.user.skills,
role: {
id: '',
name: 'user',
permissions: [],
created_at: '',
updated_at: '',
},
},
});
},
});
};
export const usePostRegister = () => {
// Email/Password Signup - returns message only (user needs to activate via email)
export const useSignup = () => {
return useMutation({
mutationFn: authApi.postRegister,
mutationFn: async (data: SignupRequest) => {
const response = await hackathonApi.post<
HackathonApiResponse<MessageResponse>
>('/auth/signup', data);
return response.data.data;
},
});
};
export const usePostVerifyEmail = () => {
// GitHub OAuth - exchange code for token
export const useGitHubCallback = () => {
const { setSession } = useAuthStore();
return useMutation({
mutationFn: authApi.postVerifyEmail,
mutationFn: async (data: GitHubAuthRequest) => {
const response = await hackathonApi.post<
HackathonApiResponse<AuthResponse>
>('/auth/github', data);
return response.data.data;
},
onSuccess: (data) => {
setSession({
token: data.token,
user: {
id: data.user.id,
email: data.user.email,
fullname: data.user.fullname,
phone_number: data.user.phone_number || '',
avatar: data.user.avatar || '',
birthdate: data.user.birthdate || '',
gender: data.user.gender || '',
is_active: data.user.is_active,
location: data.user.location,
bio: data.user.bio,
skills: data.user.skills,
role: {
id: '',
name: 'user',
permissions: [],
created_at: '',
updated_at: '',
},
},
});
},
});
};
export const usePostSendOtp = () => {
return useMutation({
mutationFn: authApi.postSendOtp,
// Get current session (protected)
export const useSession = () => {
const { session } = useAuthStore();
return useQuery({
queryKey: ['auth-session'],
queryFn: async () => {
const response = await hackathonApi.get<
HackathonApiResponse<SessionResponse>
>('/auth/session');
return response.data.data;
},
enabled: !!session?.token,
});
};
export const useGoogleCallback = () => {
// Forgot password
export const useForgotPassword = () => {
return useMutation({
mutationFn: ({ code, state }: { code: string; state: string }) =>
authApi.postGoogleCallback(code, state),
mutationFn: async (data: ForgotPasswordRequest) => {
const response = await hackathonApi.post<
HackathonApiResponse<MessageResponse>
>('/auth/forgot-password', data);
return response.data.data;
},
});
};
// Supabase GitHub OAuth hook
// Reset password
export const useResetPassword = () => {
return useMutation({
mutationFn: async (data: ResetPasswordRequest) => {
const response = await hackathonApi.post<
HackathonApiResponse<MessageResponse>
>('/auth/reset-password', data);
return response.data.data;
},
});
};
// Sign out (clears local session)
export const useSignOut = () => {
const { clearSession } = useAuthStore();
return useMutation({
mutationFn: async () => {
// No backend call needed - just clear local session
clearSession();
return { success: true };
},
});
};
// Backoffice Login
export const useBackofficeLogin = () => {
const { setSession } = useAuthStore();
return useMutation({
mutationFn: async (data: LoginRequest) => {
const response = await backofficeApi.post<
BackofficeApiResponse<AuthResponse>
>('/auth/login', data);
return response.data.data;
},
onSuccess: (data) => {
setSession({
token: data.token,
user: {
id: data.user.id,
email: data.user.email,
fullname: data.user.fullname,
phone_number: data.user.phone_number || '',
avatar: data.user.avatar || '',
birthdate: data.user.birthdate || '',
gender: data.user.gender || '',
is_active: data.user.is_active,
location: data.user.location,
bio: data.user.bio,
skills: data.user.skills,
role: {
id: data.user.role_id || '',
name: 'admin',
permissions: [],
created_at: '',
updated_at: '',
},
},
});
},
});
};
// GitHub OAuth URL helper
// The frontend needs to redirect to GitHub with the client_id
// After GitHub redirects back with a code, use useGitHubCallback
export const getGitHubOAuthUrl = (clientId: string, redirectUri: string) => {
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
scope: 'read:user user:email',
});
return `https://github.com/login/oauth/authorize?${params.toString()}`;
};
// Backward compatibility hooks - these wrap the new backend API
// GitHub OAuth hook (backward compatible)
export const useGitHubAuth = () => {
const signInWithGitHub = async () => {
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: `${globalThis.location.origin}/auth/callback`,
},
});
if (error) {
throw error;
// Get GitHub client ID from environment
const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID || '';
if (!clientId) {
throw new Error(
'GitHub Client ID not configured. Set VITE_GITHUB_CLIENT_ID environment variable.'
);
}
// Return the OAuth URL for debugging
return data;
const redirectUri = `${globalThis.location.origin}/auth/callback`;
const url = getGitHubOAuthUrl(clientId, redirectUri);
return { url };
};
return {
@@ -59,45 +290,41 @@ export const useGitHubAuth = () => {
};
};
// Supabase Email/Password authentication hook
// Email/Password auth hook (backward compatible)
export const useEmailAuth = () => {
const loginMutation = useLogin();
const signupMutation = useSignup();
const { clearSession } = useAuthStore();
const signInWithEmail = async (email: string, password: string) => {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
throw error;
}
return data;
const result = await loginMutation.mutateAsync({ email, password });
return {
user: result.user,
session: {
access_token: result.token.access_token,
refresh_token: result.token.refresh_token,
},
};
};
const signUpWithEmail = async (email: string, password: string, fullname: string) => {
const { data, error } = await supabase.auth.signUp({
const signUpWithEmail = async (
email: string,
password: string,
fullname: string
) => {
const result = await signupMutation.mutateAsync({
email,
password,
options: {
data: {
full_name: fullname,
},
},
fullname,
});
if (error) {
throw error;
}
return data;
// Signup only returns a message (user needs to verify email first)
return {
message: result.message,
};
};
const signOut = async () => {
const { error } = await supabase.auth.signOut();
if (error) {
throw error;
}
clearSession();
};
return {
@@ -106,3 +333,56 @@ export const useEmailAuth = () => {
signOut,
};
};
// Legacy hooks for old API compatibility (deprecated)
/** @deprecated Use useLogin instead */
export const usePostLogin = () => {
return useMutation({
mutationFn: async (data: LoginRequest) => {
const response = await hackathonApi.post<
HackathonApiResponse<AuthResponse>
>('/auth/login', data);
return { data: response.data.data };
},
});
};
/** @deprecated Use useSignup instead */
export const usePostRegister = () => {
return useMutation({
mutationFn: async (data: SignupRequest) => {
const response = await hackathonApi.post<
HackathonApiResponse<AuthResponse>
>('/auth/signup', data);
return { data: response.data.data };
},
});
};
/** @deprecated Not needed with new backend */
export const usePostVerifyEmail = () => {
return useMutation({
mutationFn: async () => {
throw new Error('Email verification not required with new backend');
},
});
};
/** @deprecated Not needed with new backend */
export const usePostSendOtp = () => {
return useMutation({
mutationFn: async () => {
throw new Error('OTP not required with new backend');
},
});
};
/** @deprecated Use useGitHubCallback instead */
export const useGoogleCallback = () => {
return useMutation({
mutationFn: async () => {
throw new Error('Google OAuth not supported. Use GitHub OAuth instead.');
},
});
};
+1
View File
@@ -5,3 +5,4 @@ export * from './mentors';
export * from './upload';
export * from './teams';
export * from './messages';
export * from './winners';
+18 -118
View File
@@ -1,7 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { supabase } from '../../supabase';
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
import { useAuthStore } from '../auth';
import { useEffect } from 'react';
export type Message = {
id: string;
@@ -24,101 +23,22 @@ export const messageKeys = {
team: (teamId: string) => [...messageKeys.all, 'team', teamId] as const,
};
// Fetch messages for a team
// Fetch messages for a team with polling
export const useTeamMessages = (teamId: string) => {
const queryClient = useQueryClient();
const query = useQuery({
return useQuery({
queryKey: messageKeys.team(teamId),
queryFn: async () => {
const { data: messages, error } = await supabase
.from('team_messages')
.select(`
id,
team_id,
user_id,
message,
created_at,
updated_at,
user:users(id, fullname, avatar, email)
`)
.eq('team_id', teamId)
.order('created_at', { ascending: true });
if (error) {
console.error('Failed to fetch messages:', error);
throw new Error(error.message || 'Failed to fetch messages');
}
return messages as Message[];
const response = await hackathonApi.get<HackathonApiResponse<Message[]>>(
`/chat/teams/${teamId}`
);
return response.data.data || [];
},
enabled: !!teamId,
// Poll every 3 seconds for new messages
refetchInterval: 3000,
// Keep refetching even when window loses focus
refetchIntervalInBackground: true,
});
// Subscribe to realtime updates
useEffect(() => {
if (!teamId) return;
const channel = supabase
.channel(`team_messages:${teamId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'team_messages',
filter: `team_id=eq.${teamId}`,
},
async (payload) => {
console.log('[Realtime] New message:', payload);
// Fetch the full message with user data
const { data: newMessage } = await supabase
.from('team_messages')
.select(`
id,
team_id,
user_id,
message,
created_at,
updated_at,
user:users(id, fullname, avatar, email)
`)
.eq('id', payload.new.id)
.single();
if (newMessage) {
queryClient.setQueryData<Message[]>(
messageKeys.team(teamId),
(old) => [...(old || []), newMessage as Message]
);
}
}
)
.on(
'postgres_changes',
{
event: 'DELETE',
schema: 'public',
table: 'team_messages',
filter: `team_id=eq.${teamId}`,
},
(payload) => {
console.log('[Realtime] Message deleted:', payload);
queryClient.setQueryData<Message[]>(
messageKeys.team(teamId),
(old) => old?.filter((msg) => msg.id !== payload.old.id) || []
);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [teamId, queryClient]);
return query;
};
// Send a message
@@ -132,26 +52,15 @@ export const useSendMessage = (teamId: string) => {
throw new Error('You must be logged in to send messages');
}
const { data, error } = await supabase
.from('team_messages')
.insert({
team_id: teamId,
user_id: session.user.id,
message,
})
.select()
.single();
const response = await hackathonApi.post<HackathonApiResponse<Message>>(
`/chat/teams/${teamId}`,
{ message }
);
if (error) {
console.error('Failed to send message:', error);
throw new Error(error.message || 'Failed to send message');
}
return data;
return response.data.data;
},
onSuccess: () => {
// Realtime will handle adding the message to the list
// But we can invalidate to ensure consistency
// Invalidate to trigger immediate refetch
queryClient.invalidateQueries({ queryKey: messageKeys.team(teamId) });
},
});
@@ -163,18 +72,9 @@ export const useDeleteMessage = (teamId: string) => {
return useMutation({
mutationFn: async (messageId: string) => {
const { error } = await supabase
.from('team_messages')
.delete()
.eq('id', messageId);
if (error) {
console.error('Failed to delete message:', error);
throw new Error(error.message || 'Failed to delete message');
}
await hackathonApi.delete(`/chat/messages/${messageId}`);
},
onSuccess: () => {
// Realtime will handle removing the message from the list
queryClient.invalidateQueries({ queryKey: messageKeys.team(teamId) });
},
});
+303 -505
View File
@@ -1,6 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as teamsApi from '../../api/teams';
import { supabase, getAuthenticatedClient } from '../../supabase';
import { useMutation, useQuery, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
import { useAuthStore } from '../auth';
import type {
TCreateTeamRequest,
@@ -24,6 +23,101 @@ export const teamKeys = {
myInvitations: () => [...teamKeys.all, 'my-invitations'] as const,
};
// API response types
interface TeamMember {
id: string;
team_id: string;
user_id: string;
role: string;
status: string;
joined_at: string;
user?: {
id: string;
email: string;
fullname: string;
avatar: string;
};
}
interface Team {
id: string;
name: string;
logo?: string;
banner?: string;
description?: string;
city?: string;
visibility: string;
leader_id: string;
created_at: string;
leader?: {
id: string;
email: string;
fullname: string;
avatar: string;
};
members?: TeamMember[];
member_count?: number;
has_submission?: boolean;
}
interface JoinRequest {
id: string;
team_id: string;
user_id: string;
message?: string;
status: string;
created_at: string;
user?: {
id: string;
email: string;
fullname: string;
avatar: string;
};
}
interface Invitation {
id: string;
team_id: string;
inviter_id: string;
invitee_email: string;
invitee_id?: string;
status: string;
created_at: string;
team?: Team;
inviter?: {
id: string;
fullname: string;
email: string;
avatar: string;
};
}
interface Submission {
id: string;
team_id: string;
project_name: string;
description?: string;
repository_url?: string;
demo_url?: string;
video_url?: string;
presentation_url?: string;
status: string;
submitted_at?: string;
created_at: string;
}
// List response type with pagination
interface ListResponseWithMeta<T> {
message: string;
data: T[];
meta: {
page: number;
per_page: number;
total_page: number;
total_data: number;
};
}
// Team CRUD Hooks
export const useTeams = (params?: {
page?: number;
@@ -31,100 +125,78 @@ export const useTeams = (params?: {
city?: string;
visibility?: string;
search?: string;
minMembers?: number;
maxMembers?: number;
hasSubmission?: boolean;
}) => {
return useQuery({
queryKey: teamKeys.list(params),
queryFn: async () => {
try {
// Supabase client now has auth context from setSession()
let query = supabase.from('teams').select(`
*,
members:team_members(id)
`);
const queryParams = new URLSearchParams();
if (params?.page) queryParams.append('page', String(params.page));
if (params?.limit) queryParams.append('per_page', String(params.limit));
if (params?.search) queryParams.append('search', params.search);
if (params?.city) queryParams.append('city', params.city);
if (params?.visibility) queryParams.append('visibility', params.visibility);
if (params?.minMembers) queryParams.append('min_members', String(params.minMembers));
if (params?.maxMembers) queryParams.append('max_members', String(params.maxMembers));
if (params?.hasSubmission !== undefined) queryParams.append('has_submission', String(params.hasSubmission));
// Filter by visibility
if (params?.visibility) {
query = query.eq('visibility', params.visibility);
}
const response = await hackathonApi.get<ListResponseWithMeta<Team>>(
`/teams/browse${queryParams.toString() ? `?${queryParams.toString()}` : ''}`
);
// Filter by city
if (params?.city) {
query = query.eq('city', params.city);
}
// Search by name
if (params?.search) {
query = query.ilike('name', `%${params.search}%`);
}
// Pagination
if (params?.page && params?.limit) {
const from = (params.page - 1) * params.limit;
const to = from + params.limit - 1;
query = query.range(from, to);
}
const { data, error } = await query;
if (error) {
// If error is 401/403, it means RLS policies need to be set up
// Return empty array for now
console.warn('Teams query error (RLS policies may need to be configured):', error);
return { data: [] };
}
return { data: data || [] };
} catch (err) {
console.error('Failed to fetch teams:', err);
return { data: [] };
}
const { data, meta } = response.data;
return {
teams: data || [],
total: meta?.total_data || 0,
page: meta?.page || 1,
perPage: meta?.per_page || 12,
totalPages: meta?.total_page || 1,
};
},
});
};
// Infinite scroll teams hook
const TEAMS_PAGE_SIZE = 12;
export const useInfiniteTeams = (params?: {
city?: string;
visibility?: string;
search?: string;
}) => {
return useInfiniteQuery({
queryKey: [...teamKeys.lists(), 'infinite', params],
queryFn: async ({ pageParam = 1 }) => {
const queryParams = new URLSearchParams();
queryParams.append('page', String(pageParam));
queryParams.append('limit', String(TEAMS_PAGE_SIZE));
if (params?.search) queryParams.append('search', params.search);
if (params?.city) queryParams.append('city', params.city);
if (params?.visibility) queryParams.append('visibility', params.visibility);
const response = await hackathonApi.get<HackathonApiResponse<Team[]>>(
`/teams/browse?${queryParams.toString()}`
);
const teams = response.data.data || [];
return {
data: teams,
nextPage: teams.length === TEAMS_PAGE_SIZE ? pageParam + 1 : undefined,
};
},
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
});
};
export const useTeamById = (teamId: string, enabled = true) => {
return useQuery({
queryKey: teamKeys.detail(teamId),
queryFn: async () => {
// Supabase client now has auth context from setSession()
const { data: team, error } = await supabase
.from('teams')
.select(`
*,
leader:users!leader_id(id, email, fullname, avatar),
members:team_members(
id,
role,
status,
joined_at,
user:users(id, email, fullname, avatar)
)
`)
.eq('id', teamId)
.single();
if (error) {
console.error('Failed to fetch team:', error);
throw new Error(error.message || 'Failed to fetch team');
}
// Count active members
const activeMemberCount = team?.members?.filter((m: any) => m.status === 'active').length || 0;
// Check if team has a submission
const { data: submission } = await supabase
.from('project_submissions')
.select('id')
.eq('team_id', teamId)
.maybeSingle();
return {
data: {
...team,
member_count: activeMemberCount,
has_submission: !!submission,
},
};
const response = await hackathonApi.get<HackathonApiResponse<Team>>(`/teams/${teamId}`);
return { data: response.data.data };
},
enabled: enabled && !!teamId,
});
@@ -140,50 +212,16 @@ export const useCreateTeam = () => {
throw new Error('You must be logged in to create a team');
}
// Supabase client now has auth context from setSession()
const { data: team, error: teamError } = await supabase
.from('teams')
.insert({
name: data.name,
logo: data.logo,
banner: data.banner,
description: data.description,
city: data.city,
visibility: data.visibility,
leader_id: session.user.id,
})
.select()
.single();
const response = await hackathonApi.post<HackathonApiResponse<Team>>('/teams', {
name: data.name,
logo: data.logo,
banner: data.banner,
description: data.description,
city: data.city,
visibility: data.visibility,
});
if (teamError) {
console.error('Failed to create team:', teamError);
throw new Error(teamError.message || 'Failed to create team');
}
// Insert team creator as leader in team_members table
const { error: memberError } = await supabase
.from('team_members')
.insert({
team_id: team.id,
user_id: session.user.id,
role: 'leader',
status: 'active',
});
if (memberError) {
// If duplicate key error (23505), it means leader is already a member (possibly by trigger)
// This is acceptable, so we can ignore it
if (memberError.code === '23505') {
console.log('Team leader already exists in team_members (likely added by trigger)');
} else {
// For other errors, clean up and throw
console.error('Failed to add team leader as member:', memberError);
await supabase.from('teams').delete().eq('id', team.id);
throw new Error('Failed to set up team membership');
}
}
return { data: team };
return { data: response.data.data };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
@@ -202,27 +240,16 @@ export const useUpdateTeam = (teamId: string) => {
throw new Error('You must be logged in to update a team');
}
// Supabase client now has auth context from setSession()
const { data: team, error } = await supabase
.from('teams')
.update({
name: data.name,
logo: data.logo,
banner: data.banner,
description: data.description,
city: data.city,
visibility: data.visibility,
})
.eq('id', teamId)
.select()
.single();
const response = await hackathonApi.put<HackathonApiResponse<Team>>(`/teams/${teamId}`, {
name: data.name,
logo: data.logo,
banner: data.banner,
description: data.description,
city: data.city,
visibility: data.visibility,
});
if (error) {
console.error('Failed to update team:', error);
throw new Error(error.message || 'Failed to update team');
}
return { data: team };
return { data: response.data.data };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
@@ -231,32 +258,13 @@ export const useUpdateTeam = (teamId: string) => {
});
};
// Team Members Hooks
// Team Members Hooks - using team detail endpoint which includes members
export const useTeamMembers = (teamId: string, enabled = true) => {
return useQuery({
queryKey: teamKeys.members(teamId),
queryFn: async () => {
// Supabase client now has auth context from setSession()
const { data: members, error } = await supabase
.from('team_members')
.select(`
id,
team_id,
user_id,
role,
status,
joined_at,
user:users(id, email, fullname, avatar)
`)
.eq('team_id', teamId)
.order('joined_at', { ascending: true });
if (error) {
console.error('Failed to fetch team members:', error);
throw new Error(error.message || 'Failed to fetch team members');
}
return { data: members || [] };
const response = await hackathonApi.get<HackathonApiResponse<Team>>(`/teams/${teamId}`);
return { data: response.data.data?.members || [] };
},
enabled: enabled && !!teamId,
});
@@ -272,24 +280,12 @@ export const useInviteMember = (teamId: string) => {
throw new Error('You must be logged in to invite a member');
}
// Insert invitation into team_invitations table
const { data: invitation, error } = await supabase
.from('team_invitations')
.insert({
team_id: teamId,
inviter_id: session.user.id,
invitee_email: data.email,
status: 'pending',
})
.select()
.single();
const response = await hackathonApi.post<HackathonApiResponse<Invitation>>(
`/teams/${teamId}/invite`,
{ invitee_email: data.email }
);
if (error) {
console.error('Failed to create invitation:', error);
throw new Error(error.message || 'Failed to send invitation');
}
return { data: invitation };
return { data: response.data.data };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.members(teamId) });
@@ -301,8 +297,11 @@ export const useManageMember = (teamId: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ userId, data }: { userId: string; data: any }) =>
teamsApi.manageMember(teamId, userId, data),
mutationFn: async ({ userId, data }: { userId: string; data: { role?: string; status?: string } }) => {
// This endpoint may not exist in the backend yet
// For now, we'll throw an error indicating it's not implemented
throw new Error('Manage member functionality not yet implemented in backend');
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.members(teamId) });
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
@@ -320,38 +319,7 @@ export const useRemoveMember = (teamId: string) => {
throw new Error('You must be logged in to remove a member');
}
// Verify the current user is the team leader
const { data: team, error: teamError } = await supabase
.from('teams')
.select('leader_id')
.eq('id', teamId)
.single();
if (teamError || !team) {
throw new Error('Team not found');
}
if (team.leader_id !== session.user.id) {
throw new Error('Only the team leader can remove members');
}
// Cannot remove the leader
if (userId === team.leader_id) {
throw new Error('Cannot remove the team leader');
}
// Delete the team member record
const { error: deleteError } = await supabase
.from('team_members')
.delete()
.eq('team_id', teamId)
.eq('user_id', userId);
if (deleteError) {
console.error('Failed to remove member:', deleteError);
throw new Error(deleteError.message || 'Failed to remove member');
}
await hackathonApi.delete(`/teams/${teamId}/members/${userId}`);
return { success: true };
},
onSuccess: () => {
@@ -372,24 +340,12 @@ export const useJoinTeam = () => {
throw new Error('You must be logged in to join a team');
}
// Insert join request into team_join_requests table
const { data: joinRequest, error } = await supabase
.from('team_join_requests')
.insert({
team_id: teamId,
user_id: session.user.id,
message: data.message,
status: 'pending',
})
.select()
.single();
const response = await hackathonApi.post<HackathonApiResponse<JoinRequest>>(
`/join-requests/teams/${teamId}`,
{ message: data.message }
);
if (error) {
console.error('Failed to create join request:', error);
throw new Error(error.message || 'Failed to send join request');
}
return { data: joinRequest };
return { data: response.data.data };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
@@ -401,28 +357,10 @@ export const useTeamJoinRequests = (teamId: string, enabled = true) => {
return useQuery({
queryKey: teamKeys.joinRequests(teamId),
queryFn: async () => {
// Fetch join requests with user information
const { data: requests, error } = await supabase
.from('team_join_requests')
.select(`
id,
team_id,
user_id,
message,
status,
created_at,
user:users(id, email, fullname, avatar)
`)
.eq('team_id', teamId)
.eq('status', 'pending')
.order('created_at', { ascending: false });
if (error) {
console.error('Failed to fetch join requests:', error);
throw new Error(error.message || 'Failed to fetch join requests');
}
return { data: requests || [] };
const response = await hackathonApi.get<HackathonApiResponse<JoinRequest[]>>(
`/join-requests/teams/${teamId}/pending`
);
return { data: response.data.data || [] };
},
enabled: enabled && !!teamId,
});
@@ -438,76 +376,14 @@ export const useRespondToJoinRequest = (teamId: string) => {
throw new Error('You must be logged in to respond to join requests');
}
// First, get the join request details
const { data: joinRequest, error: fetchError } = await supabase
.from('team_join_requests')
.select('id, team_id, user_id, status')
.eq('id', requestId)
.single();
// Backend uses 'accept' instead of 'approve'
const backendAction = action === 'approve' ? 'accept' : 'reject';
if (fetchError || !joinRequest) {
throw new Error('Join request not found');
}
await hackathonApi.post(`/join-requests/${requestId}/respond`, {
action: backendAction,
});
if (joinRequest.status !== 'pending') {
throw new Error('Join request has already been processed');
}
if (action === 'approve') {
// Update join request status
const { error: updateError } = await supabase
.from('team_join_requests')
.update({ status: 'accepted' })
.eq('id', requestId);
if (updateError) {
throw new Error('Failed to update join request: ' + updateError.message);
}
// Add user as team member
const { error: memberError } = await supabase
.from('team_members')
.insert({
team_id: joinRequest.team_id,
user_id: joinRequest.user_id,
role: 'member',
status: 'active',
});
if (memberError) {
// If member creation fails, rollback join request update
await supabase
.from('team_join_requests')
.update({ status: 'pending' })
.eq('id', requestId);
// Parse Supabase error for user-friendly message
let errorMsg = memberError.message;
if (errorMsg.includes('Team already has 5 members') || errorMsg.includes('Team cannot have more than 5 members')) {
errorMsg = 'Team is full! Maximum 5 members allowed.';
} else if (errorMsg.includes('already in a team') || errorMsg.includes('User is already in a team')) {
errorMsg = 'This user is already in another team.';
} else if (errorMsg.includes('Bulk insert')) {
errorMsg = 'Invalid operation detected.';
}
throw new Error(errorMsg);
}
return { success: true, action: 'accepted' };
} else {
// Reject join request
const { error: updateError } = await supabase
.from('team_join_requests')
.update({ status: 'rejected' })
.eq('id', requestId);
if (updateError) {
throw new Error('Failed to update join request: ' + updateError.message);
}
return { success: true, action: 'rejected' };
}
return { success: true, action };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.joinRequests(teamId) });
@@ -524,49 +400,10 @@ export const useMyInvitations = () => {
return useQuery({
queryKey: teamKeys.myInvitations(),
queryFn: async () => {
if (!session?.user?.email) {
return { data: [] };
}
// Query team_invitations where invitee_email matches current user's email
const { data: invitations, error } = await supabase
.from('team_invitations')
.select(`
id,
team_id,
inviter_id,
invitee_email,
invitee_id,
status,
created_at,
team:teams(
id,
name,
logo,
banner,
description,
city,
visibility,
leader_id
),
inviter:users!team_invitations_inviter_id_fkey(
id,
fullname,
email,
avatar
)
`)
.eq('invitee_email', session.user.email)
.eq('status', 'pending');
if (error) {
console.error('Failed to fetch invitations:', error);
return { data: [] };
}
return { data: invitations || [] };
const response = await hackathonApi.get<HackathonApiResponse<Invitation[]>>('/invitations/my');
return { data: response.data.data || [] };
},
enabled: !!session?.user?.email,
enabled: !!session?.user?.id,
});
};
@@ -580,75 +417,9 @@ export const useRespondToInvitation = () => {
throw new Error('User not authenticated');
}
// First, get the invitation details
const { data: invitation, error: fetchError } = await supabase
.from('team_invitations')
.select('id, team_id, invitee_email, status')
.eq('id', invitationId)
.single();
await hackathonApi.post(`/invitations/${invitationId}/respond`, { action });
if (fetchError || !invitation) {
throw new Error('Invitation not found');
}
if (invitation.status !== 'pending') {
throw new Error('Invitation has already been responded to');
}
if (action === 'accept') {
// Update invitation status and set invitee_id
const { error: updateError } = await supabase
.from('team_invitations')
.update({
status: 'accepted',
invitee_id: session.user.id,
})
.eq('id', invitationId);
if (updateError) {
throw new Error('Failed to update invitation: ' + updateError.message);
}
// Create team_members record
const { error: memberError } = await supabase
.from('team_members')
.insert({
team_id: invitation.team_id,
user_id: session.user.id,
role: 'member',
status: 'active',
});
if (memberError) {
// If member creation fails, rollback invitation update
await supabase
.from('team_invitations')
.update({
status: 'pending',
invitee_id: null,
})
.eq('id', invitationId);
throw new Error('Failed to add member to team: ' + memberError.message);
}
return { success: true, action: 'accepted' };
} else {
// Reject invitation
const { error: updateError } = await supabase
.from('team_invitations')
.update({
status: 'rejected',
invitee_id: session.user.id,
})
.eq('id', invitationId);
if (updateError) {
throw new Error('Failed to update invitation: ' + updateError.message);
}
return { success: true, action: 'rejected' };
}
return { success: true, action };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.myInvitations() });
@@ -665,38 +436,10 @@ export const useMyTeams = () => {
return useQuery({
queryKey: teamKeys.myTeams(),
queryFn: async () => {
if (!session?.user?.id) {
return { data: [] };
}
// Query team_members to find teams where user is a member
const { data: memberships, error: membershipsError } = await supabase
.from('team_members')
.select(`
team_id,
team:teams(
id,
name,
logo,
banner,
description,
city,
visibility,
leader_id,
created_at
)
`)
.eq('user_id', session.user.id)
.eq('status', 'active');
if (membershipsError) {
console.error('Failed to fetch user teams:', membershipsError);
return { data: [] };
}
// Extract teams from memberships
const teams = memberships?.map((m: any) => m.team).filter(Boolean) || [];
const response = await hackathonApi.get<HackathonApiResponse<any[]>>('/teams/my');
const rawData = response.data.data || [];
// Unwrap nested team data if present (API returns [{team: {...}}] or [{id, name, ...}])
const teams = rawData.map((item: any) => item.team || item);
return { data: teams };
},
enabled: !!session?.user?.id,
@@ -709,7 +452,60 @@ export const useSubmitProject = (teamId: string) => {
return useMutation({
mutationFn: async (data: TSubmitProjectRequest) => {
return await teamsApi.submitProject(teamId, data);
let submissionId: string;
// First, check if submission exists
try {
const existingResponse = await hackathonApi.get<HackathonApiResponse<Submission | null>>(
`/submissions/teams/${teamId}`
);
if (existingResponse.data.data?.id) {
// Update existing submission
const response = await hackathonApi.put<HackathonApiResponse<Submission>>(
`/submissions/${existingResponse.data.data.id}`,
{
project_name: data.project_name,
description: data.description,
repository_url: data.repository_url,
demo_url: data.demo_url,
video_url: data.video_url,
presentation_url: data.presentation_url,
screenshots: data.screenshots,
}
);
submissionId = response.data.data.id;
} else {
throw new Error('No existing submission');
}
} catch {
// No existing submission, create new one
const response = await hackathonApi.post<HackathonApiResponse<Submission>>(
`/submissions/teams/${teamId}`,
{
project_name: data.project_name,
description: data.description,
repository_url: data.repository_url,
demo_url: data.demo_url,
video_url: data.video_url,
presentation_url: data.presentation_url,
screenshots: data.screenshots,
}
);
submissionId = response.data.data.id;
}
// Step 2: Submit the project (draft -> pending_verification)
await hackathonApi.post<HackathonApiResponse<Submission>>(
`/submissions/${submissionId}/submit`
);
// Step 3: Confirm the submission (pending_verification -> submitted)
const finalResponse = await hackathonApi.post<HackathonApiResponse<Submission>>(
`/submissions/${submissionId}/confirm`
);
return { data: finalResponse.data.data };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.submission(teamId) });
@@ -722,8 +518,10 @@ export const useTeamSubmission = (teamId: string, enabled = true) => {
return useQuery({
queryKey: teamKeys.submission(teamId),
queryFn: async () => {
const submission = await teamsApi.getTeamSubmission(teamId);
return { data: submission };
const response = await hackathonApi.get<HackathonApiResponse<Submission | null>>(
`/submissions/teams/${teamId}`
);
return { data: response.data.data };
},
enabled: enabled && !!teamId,
});
@@ -732,10 +530,17 @@ export const useTeamSubmission = (teamId: string, enabled = true) => {
// Leave Team Hook
export const useLeaveTeam = () => {
const queryClient = useQueryClient();
const { session } = useAuthStore();
return useMutation({
mutationFn: async (teamId: string) => {
return await teamsApi.leaveTeam(teamId);
if (!session?.user?.id) {
throw new Error('You must be logged in to leave a team');
}
// Use the dedicated leave team endpoint
await hackathonApi.post(`/teams/${teamId}/leave`);
return { success: true };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.myTeams() });
@@ -744,43 +549,36 @@ export const useLeaveTeam = () => {
});
};
// Get Teams by User ID
// Delete Team Hook (Leader only)
export const useDeleteTeam = () => {
const queryClient = useQueryClient();
const { session } = useAuthStore();
return useMutation({
mutationFn: async (teamId: string) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to delete a team');
}
await hackathonApi.delete(`/teams/${teamId}`);
return { success: true };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.myTeams() });
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
},
});
};
// Get Teams by User ID - uses /users/{user_id}/teams
export const useTeamsByUserId = (userId: string) => {
return useQuery({
queryKey: ['teams-by-user', userId],
queryFn: async () => {
if (!userId) {
return { data: [] };
}
// Query team_members to find teams where user is a member
const { data: memberships, error: membershipsError } = await supabase
.from('team_members')
.select(`
team_id,
team:teams(
id,
name,
logo,
banner,
description,
city,
visibility,
leader_id,
created_at
)
`)
.eq('user_id', userId)
.eq('status', 'active');
if (membershipsError) {
console.error('Failed to fetch user teams:', membershipsError);
return { data: [] };
}
// Extract teams from memberships
const teams = memberships?.map((m: any) => m.team).filter(Boolean) || [];
const response = await hackathonApi.get<HackathonApiResponse<any[]>>(`/users/${userId}/teams`);
const rawData = response.data.data || [];
// Unwrap nested team data if present (API returns [{team: {...}}] or [{id, name, ...}])
const teams = rawData.map((item: any) => item.team || item);
return { data: teams };
},
enabled: !!userId,
+169 -66
View File
@@ -1,8 +1,27 @@
import { useMutation } from '@tanstack/react-query';
import { supabase, getAuthenticatedClient } from '../../supabase';
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
import { useAuthStore } from '../auth';
// Supabase Storage-based upload hooks
// Upload response type from backend
interface UploadResponse {
url: string;
}
// Helper function to convert File to base64
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
// Remove the data:image/xxx;base64, prefix
const base64 = (reader.result as string).split(',')[1];
resolve(base64);
};
reader.onerror = (error) => reject(error);
});
};
// Backend API-based upload hooks
export const useUploadFile = () => {
const { session } = useAuthStore();
@@ -14,30 +33,18 @@ export const useUploadFile = () => {
throw new Error('You must be logged in to upload files');
}
// Generate a unique file name
const fileExt = file.name.split('.').pop();
const fileName = `${session.user.id}-${Date.now()}.${fileExt}`;
const filePath = `teams/${fileName}`;
const base64Data = await fileToBase64(file);
// Supabase client now has auth context from setSession()
const { error } = await supabase.storage
.from('hackathon-uploads')
.upload(filePath, file, {
cacheControl: '3600',
upsert: true,
});
const response = await hackathonApi.post<HackathonApiResponse<UploadResponse>>(
'/upload/team',
{
filename: file.name,
content_type: file.type,
data: base64Data,
}
);
if (error) {
console.error('Failed to upload file:', error);
throw new Error(error.message || 'Failed to upload file');
}
// Get public URL
const { data: publicUrlData } = supabase.storage
.from('hackathon-uploads')
.getPublicUrl(filePath);
return { data: { url: publicUrlData.publicUrl } };
return { data: { url: response.data.data.url } };
},
});
};
@@ -52,34 +59,131 @@ export const useUploadAvatar = () => {
throw new Error('You must be logged in to upload avatar');
}
// Generate a unique file name
const fileExt = file.name.split('.').pop();
const fileName = `${session.user.id}-${Date.now()}.${fileExt}`;
const filePath = `avatars/${fileName}`;
// Supabase client now has auth context from setSession()
const { error } = await supabase.storage
.from('hackathon-uploads')
.upload(filePath, file, {
cacheControl: '3600',
upsert: true,
});
if (error) {
console.error('Failed to upload avatar:', error);
throw new Error(error.message || 'Failed to upload avatar');
// Validate file type
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
throw new Error('Invalid file type. Allowed types: JPEG, PNG, WebP, GIF');
}
// Get public URL
const { data: publicUrlData } = supabase.storage
.from('hackathon-uploads')
.getPublicUrl(filePath);
// Validate file size (max 5MB)
const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
throw new Error('File too large. Maximum size: 5MB');
}
return { data: { url: publicUrlData.publicUrl } };
const base64Data = await fileToBase64(file);
const response = await hackathonApi.post<HackathonApiResponse<UploadResponse>>(
'/upload/avatar',
{
filename: file.name,
content_type: file.type,
data: base64Data,
}
);
return { data: { url: response.data.data.url } };
},
});
};
export const useUploadTeamFile = () => {
const { session } = useAuthStore();
return useMutation({
mutationKey: ['upload-team-file'],
mutationFn: async (file: File) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to upload files');
}
// Validate file type
const allowedTypes = [
'image/jpeg',
'image/jpg',
'image/png',
'image/webp',
'image/gif',
'application/pdf',
];
if (!allowedTypes.includes(file.type)) {
throw new Error('Invalid file type. Allowed types: JPEG, PNG, WebP, GIF, PDF');
}
// Validate file size (max 20MB)
const maxSize = 20 * 1024 * 1024;
if (file.size > maxSize) {
throw new Error('File too large. Maximum size: 20MB');
}
const base64Data = await fileToBase64(file);
const response = await hackathonApi.post<HackathonApiResponse<UploadResponse>>(
'/upload/team',
{
filename: file.name,
content_type: file.type,
data: base64Data,
}
);
return { data: { url: response.data.data.url } };
},
});
};
export const useUploadSubmission = () => {
const { session } = useAuthStore();
return useMutation({
mutationKey: ['upload-submission'],
mutationFn: async (file: File) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to upload submissions');
}
// Validate file type
const allowedTypes = [
'image/jpeg',
'image/jpg',
'image/png',
'image/webp',
'image/gif',
'application/pdf',
'application/zip',
'application/x-zip-compressed',
'video/mp4',
'video/webm',
];
if (!allowedTypes.includes(file.type)) {
throw new Error(
'Invalid file type. Allowed types: Images, PDF, ZIP, MP4, WebM'
);
}
// Validate file size (max 50MB)
const maxSize = 50 * 1024 * 1024;
if (file.size > maxSize) {
throw new Error('File too large. Maximum size: 50MB');
}
const base64Data = await fileToBase64(file);
const response = await hackathonApi.post<HackathonApiResponse<UploadResponse>>(
'/upload/submission',
{
filename: file.name,
content_type: file.type,
data: base64Data,
}
);
return { data: { url: response.data.data.url } };
},
});
};
// Keep useUploadCV for compatibility, using team upload endpoint
export const useUploadCV = () => {
const { session } = useAuthStore();
@@ -90,30 +194,29 @@ export const useUploadCV = () => {
throw new Error('You must be logged in to upload CV');
}
// Generate a unique file name
const fileExt = file.name.split('.').pop();
const fileName = `${session.user.id}-${Date.now()}.${fileExt}`;
const filePath = `cvs/${fileName}`;
// Supabase client now has auth context from setSession()
const { error } = await supabase.storage
.from('hackathon-uploads')
.upload(filePath, file, {
cacheControl: '3600',
upsert: true,
});
if (error) {
console.error('Failed to upload CV:', error);
throw new Error(error.message || 'Failed to upload CV');
// Validate file type
if (file.type !== 'application/pdf') {
throw new Error('CV must be a PDF file');
}
// Get public URL
const { data: publicUrlData } = supabase.storage
.from('hackathon-uploads')
.getPublicUrl(filePath);
// Validate file size (max 20MB)
const maxSize = 20 * 1024 * 1024;
if (file.size > maxSize) {
throw new Error('File too large. Maximum size: 20MB');
}
return { data: { url: publicUrlData.publicUrl } };
const base64Data = await fileToBase64(file);
const response = await hackathonApi.post<HackathonApiResponse<UploadResponse>>(
'/upload/team',
{
filename: file.name,
content_type: file.type,
data: base64Data,
}
);
return { data: { url: response.data.data.url } };
},
});
};
+49 -43
View File
@@ -1,17 +1,41 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { userService } from '../../api/users';
import { supabase, getAuthenticatedClient } from '../../supabase';
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
import { useAuthStore } from '../auth';
// Supabase-based user hooks
// User type
interface User {
id: string;
email: string;
fullname: string;
bio?: string;
location?: string;
avatar?: string;
skills?: string[];
created_at: string;
updated_at?: string;
}
// Update user request type
interface UpdateUserRequest {
fullname?: string;
bio?: string;
location?: string;
avatar?: string;
skills?: string[];
}
// Backend API-based user hooks
export const useUserMe = () => {
const { session } = useAuthStore();
return useQuery({
queryKey: ['user-me'],
queryFn: async () => {
const user = await userService.getUserMe();
return { data: user };
const response = await hackathonApi.get<HackathonApiResponse<User>>('/users/me');
return { data: response.data.data };
},
enabled: !!session?.user?.id,
});
};
@@ -19,8 +43,8 @@ export const useUserById = (id: string) => {
return useQuery({
queryKey: ['user-by-id', id],
queryFn: async () => {
const user = await userService.getUserById(id);
return { data: user };
const response = await hackathonApi.get<HackathonApiResponse<User>>(`/users/${id}`);
return { data: response.data.data };
},
enabled: !!id,
});
@@ -32,35 +56,24 @@ export const useUpdateUserMe = () => {
return useMutation({
mutationKey: ['update-user-me'],
mutationFn: async (data: any) => {
mutationFn: async (data: UpdateUserRequest) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to update profile');
}
// Supabase client now has auth context from setSession()
const { data: updatedUser, error } = await supabase
.from('users')
.update({
fullname: data.fullname,
bio: data.bio,
location: data.location,
avatar: data.avatar,
skills: data.skills,
updated_at: new Date().toISOString(),
})
.eq('id', session.user.id)
.select()
.single();
const response = await hackathonApi.put<HackathonApiResponse<User>>('/users/me', {
fullname: data.fullname,
bio: data.bio,
location: data.location,
avatar: data.avatar,
skills: data.skills,
});
if (error) {
console.error('Failed to update user profile:', error);
throw new Error(error.message || 'Failed to update profile');
}
return { data: updatedUser };
return { data: response.data.data };
},
onSuccess: (result) => {
// Update Zustand session store with new user data
if (session && result.data) {
if (session?.user && result.data) {
setSession({
token: session.token,
user: {
@@ -83,9 +96,11 @@ export const useUpdateUserById = () => {
return useMutation({
mutationKey: ['update-user-by-id'],
mutationFn: async ({ id, data }: { id: string; data: any }) => {
const updated = await userService.updateUserById(id, data);
return { data: updated };
mutationFn: async ({ id, data }: { id: string; data: UpdateUserRequest }) => {
// Note: This might not be supported by backend (only /users/me for updates)
// Keeping for API compatibility but it will likely fail
const response = await hackathonApi.put<HackathonApiResponse<User>>(`/users/${id}`, data);
return { data: response.data.data };
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['user-by-id', variables.id] });
@@ -95,19 +110,10 @@ export const useUpdateUserById = () => {
export const useUserDetailsById = (userId: string) => {
return useQuery({
queryKey: ['user-supabase', userId],
queryKey: ['user-details', userId],
queryFn: async () => {
const { data, error } = await supabase
.from('users')
.select('*')
.eq('id', userId)
.single();
if (error) {
throw new Error(error.message || 'Failed to fetch user');
}
return { data };
const response = await hackathonApi.get<HackathonApiResponse<User>>(`/users/${userId}`);
return { data: response.data.data };
},
enabled: !!userId,
});
+43
View File
@@ -0,0 +1,43 @@
import { useQuery } from '@tanstack/react-query';
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
// Query keys
export const winnerKeys = {
all: ['winners'] as const,
lists: () => [...winnerKeys.all, 'list'] as const,
};
// API response types
interface Team {
id: string;
name: string;
description: string;
city: string;
visibility: string;
logo: string;
banner: string;
leader_id: string;
created_at: string;
updated_at: string;
}
interface Winner {
id: string;
team_id: string;
team: Team;
rank: number;
prize: string;
announced_at: string;
created_at: string;
updated_at: string;
}
export const useWinners = () => {
return useQuery<HackathonApiResponse<Winner[]>>({
queryKey: winnerKeys.lists(),
queryFn: async () => {
const response = await hackathonApi.get('/winners');
return response.data;
},
});
};
+1 -1
View File
@@ -2,5 +2,5 @@ export * from './api';
export * from './hooks';
export * from './types';
export * from './schemas';
export * from './supabase';
export * from './storage';
// Note: Supabase export removed - using backend API instead
+64
View File
@@ -0,0 +1,64 @@
export type TAdminMetaResponse = {
page: number;
per_page: number;
total_data: number;
total_page: number;
};
export type TAdminListResponse<T = unknown> = {
data: T[];
meta: TAdminMetaResponse;
};
// Admin Users
export type TAdminUserItem = {
id: string;
email: string;
fullname: string;
avatar: string | null;
phone_number: string | null;
location: string | null;
bio: string;
skills: string[];
is_active: boolean;
created_at: string;
updated_at: string;
};
export type TAdminUsersResponse = TAdminListResponse<TAdminUserItem>;
// Admin Teams
export type TAdminTeamItem = {
id: string;
name: string;
description: string;
city: string;
visibility: string;
logo: string | null;
banner: string | null;
leader_id: string;
created_at: string;
updated_at: string;
};
export type TAdminTeamsResponse = TAdminListResponse<TAdminTeamItem>;
// Admin Submissions
export type TAdminSubmissionItem = {
id: string;
team_id: string;
project_name: string;
description: string;
repository_url: string;
demo_url: string | null;
presentation_url: string | null;
screenshots: string[];
status: string;
submitted_at: string;
submitted_by: string;
created_at: string;
updated_at: string;
};
export type TAdminSubmissionsResponse =
TAdminListResponse<TAdminSubmissionItem>;
+1
View File
@@ -5,3 +5,4 @@ export * from './roles';
export * from './permissions';
export * from './mentors';
export * from './teams';
export * from './admin';
+2 -1
View File
@@ -36,6 +36,7 @@ export type TTeamItem = {
city: string;
visibility: ETeamVisibility;
leader_id: string;
member_count: number;
created_at: string;
updated_at: string;
};
@@ -43,7 +44,6 @@ export type TTeamItem = {
export type TTeamDetailItem = TTeamItem & {
leader: TUserItem;
members: TTeamMemberItem[];
member_count: number;
has_submission: boolean;
};
@@ -125,6 +125,7 @@ export type TSubmitProjectRequest = {
description: string;
repository_url: string;
demo_url?: string;
video_url?: string;
presentation_url?: string;
screenshots?: string[];
};
@@ -3,64 +3,257 @@ import {
AuditOutlined,
BookOutlined,
CommentOutlined,
DownOutlined,
InboxOutlined,
LogoutOutlined,
ReadOutlined,
ReloadOutlined,
RightOutlined,
ScheduleOutlined,
SettingOutlined,
StockOutlined,
UsergroupAddOutlined,
UserOutlined,
UserSwitchOutlined,
} from '@ant-design/icons';
import { Button } from '../../atoms';
import { FC, ReactElement } from 'react';
import { FC, ReactElement, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn, For, useSession } from '@imphnen-frontend-service/utils';
const MENUS = [
{ label: 'Dashboard & Set Gacha', href: '/dashboard', icon: <AppstoreOutlined className="text-[20px]" /> },
{ label: 'Dashboard - Dimentorin', href: '/dashboard-dimentorin', icon: <AppstoreOutlined className="text-[20px]" /> },
{ label: 'Gacha Roll', href: '/gacha-roll', icon: <ReloadOutlined className="text-[20px]" /> },
{ label: 'Permissions', href: '/permissions', icon: <UserSwitchOutlined className="text-[20px]" /> },
{ label: 'Roles', href: '/roles', icon: <UsergroupAddOutlined className="text-[20px]" /> },
{ label: 'Data Akun', href: '/accounts', icon: <UserOutlined className="text-[20px]" /> },
{ label: 'Validasi Transaksi', href: '/transactions', icon: <AuditOutlined className="text-[20px]" /> },
{ label: 'Data Pengiriman Hadiah', href: '/prizes', icon: <InboxOutlined className="text-[20px]" /> },
{ label: 'User - Dimentorin', href: '/users-dimentorin', icon: <UserSwitchOutlined className="text-[20px]" /> },
{ label: 'Session - Dimentorin', href: '/session-dimentorin', icon: <ScheduleOutlined className="text-[20px]" /> },
{ label: 'Content & Roadmap', href: '/roadmap-dimentorin', icon: <BookOutlined className="text-[20px]" /> },
{ label: 'Feedback & Review', href: '/feedback-review-dimentorin', icon: <CommentOutlined className="text-[20px]" /> },
{ label: 'Settings - Dimentorin', href: '/settings-dimentorin', icon: <SettingOutlined className="text-[20px]" /> },
]
type MenuItem = {
label: string;
href?: string;
icon?: ReactElement;
children?: Array<{ label: string; href: string; icon?: ReactElement }>;
};
export const BackofficeSidebar: FC = (): ReactElement => {
const MENUS: MenuItem[] = [
{
label: 'Hackathon',
icon: <StockOutlined className="text-p3" />,
children: [
{
label: 'Dashboard',
href: '/hackathon-dashboard',
icon: <AppstoreOutlined className="text-p3" />,
},
{
label: 'Users',
href: '/hackathon-users',
icon: <UserOutlined className="text-p3" />,
},
{
label: 'Teams',
href: '/hackathon-teams',
icon: <UsergroupAddOutlined className="text-p3" />,
},
{
label: 'Submissions',
href: '/hackathon-submissions',
icon: <AuditOutlined className="text-p3" />,
},
],
},
{
label: 'Dimentorin',
icon: <ReadOutlined className="text-p3" />,
children: [
{
label: 'Dashboard - Dimentorin',
href: '/dashboard-dimentorin',
icon: <AppstoreOutlined className="text-[20px]" />,
},
{
label: 'User - Dimentorin',
href: '/users-dimentorin',
icon: <UserSwitchOutlined className="text-[20px]" />,
},
{
label: 'Session - Dimentorin',
href: '/session-dimentorin',
icon: <ScheduleOutlined className="text-[20px]" />,
},
{
label: 'Content & Roadmap',
href: '/roadmap-dimentorin',
icon: <BookOutlined className="text-[20px]" />,
},
{
label: 'Feedback & Review',
href: '/feedback-review-dimentorin',
icon: <CommentOutlined className="text-[20px]" />,
},
{
label: 'Settings - Dimentorin',
href: '/settings-dimentorin',
icon: <SettingOutlined className="text-[20px]" />,
},
],
},
{
label: 'Gacha',
icon: <ReloadOutlined className="text-[20px]" />,
children: [
{
label: 'Dashboard & Set Gacha',
href: '/dashboard',
icon: <AppstoreOutlined className="text-[20px]" />,
},
{
label: 'Gacha Roll',
href: '/gacha-roll',
icon: <ReloadOutlined className="text-[20px]" />,
},
{
label: 'Validasi Transaksi',
href: '/transactions',
icon: <AuditOutlined className="text-[20px]" />,
},
{
label: 'Data Pengiriman Hadiah',
href: '/prizes',
icon: <InboxOutlined className="text-[20px]" />,
},
],
},
{
label: 'Permissions',
href: '/permissions',
icon: <UserSwitchOutlined className="text-[20px]" />,
},
{
label: 'Roles',
href: '/roles',
icon: <UsergroupAddOutlined className="text-[20px]" />,
},
{
label: 'Data Akun',
href: '/accounts',
icon: <UserOutlined className="text-[20px]" />,
},
];
interface SidebarProps {
isOpen?: boolean;
onClose?: () => void;
}
export const BackofficeSidebar: FC<SidebarProps> = ({
isOpen = false,
onClose,
}): ReactElement => {
const { signOut } = useSession();
const location = useLocation();
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
const isActive = (path: string) => {
if (path === '/dashboard' && location.pathname === '/dashboard-dimentorin') return false
return location.pathname.includes(path)
if (path === '/dashboard' && location.pathname === '/dashboard-dimentorin')
return false;
return location.pathname.includes(path);
};
return (
<aside className="sticky top-0 left-0 w-[280px] bg-white h-svh py-[60px] px-[28px] shadow-xl flex flex-col justify-between">
<div className="flex flex-col gap-20 justify-between items-center">
<img src="/logos/simple.svg" alt="IMPHNEN Logo" className="w-[150px]" />
const toggleGroup = (groupLabel: string) => {
setOpenGroups((prev) => ({ ...prev, [groupLabel]: !prev[groupLabel] }));
};
const sidebarContent = (
<div className="w-[280px] bg-white h-svh py-10 lg:py-[60px] px-7 shadow-xl flex flex-col justify-between">
<div className="flex flex-col gap-10 lg:gap-20 justify-between items-center">
<div className="flex justify-around lg:justify-center items-center w-full">
<img
src="/logos/simple.svg"
alt="IMPHNEN Logo"
className="w-[150px]"
/>
{onClose && (
<button
onClick={onClose}
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
aria-label="Close sidebar"
>
<svg
className="w-5 h-5 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
<nav className="flex flex-col gap-4 w-full h-[calc(100svh-20rem)] overflow-y-auto">
<For data={MENUS}>
{({ label, href, icon }) => (
<Link
key={href}
to={href}
className={cn(
"flex items-center justify-items-start gap-3 px-[8px] py-[10px]",
isActive(href) ? "bg-primary-500 text-white rounded-md" : "text-gray-700 hover:bg-gray-100"
)}
>
{icon}
<span className="text-p3 font-medium">{label}</span>
</Link>
)}
{(menu) =>
menu.children && menu.children.length > 0 ? (
<div key={menu.label} className="w-full">
<button
type="button"
onClick={() => toggleGroup(menu.label)}
className={cn(
'flex items-center justify-between w-full gap-3 px-2 py-2.5 rounded-md cursor-pointer',
openGroups[menu.label]
? 'bg-primary-400 hover:bg-primary-500 text-white'
: 'text-gray-700 hover:bg-gray-100'
)}
>
<div className="flex items-center gap-3">
{menu.icon}
<span className="text-p3 font-medium">{menu.label}</span>
</div>
<span className="text-label2">
{openGroups[menu.label] ? (
<DownOutlined className="text-label1" />
) : (
<RightOutlined className="text-label1" />
)}
</span>
</button>
{openGroups[menu.label] && (
<div className="mt-2 ml-6 flex flex-col gap-2">
{menu.children.map((child) => (
<Link
key={child.href}
to={child.href}
className={cn(
'flex items-center gap-3 px-2 py-2.5 rounded-md',
isActive(child.href)
? 'bg-primary-100 text-primary-700 hover:bg-primary-200'
: 'text-gray-700 hover:bg-gray-100'
)}
>
{child.icon}
<span className="text-label1 font-medium">
{child.label}
</span>
</Link>
))}
</div>
)}
</div>
) : (
<Link
key={menu.href ?? menu.label}
to={menu.href ?? '#'}
className={cn(
'flex items-center justify-items-start gap-3 px-2 py-2.5',
menu.href && isActive(menu.href)
? 'bg-primary-500 text-white rounded-md'
: 'text-gray-700 hover:bg-gray-100'
)}
>
{menu.icon}
<span className="text-p3 font-medium">{menu.label}</span>
</Link>
)
}
</For>
</nav>
</div>
@@ -70,12 +263,36 @@ export const BackofficeSidebar: FC = (): ReactElement => {
<Button
onClick={signOut}
variant="text"
className="items-start justify-start gap-3 px-[8px] py-[10px] text-gray-700 hover:text-red-500 transition-colors w-full"
className="items-start justify-start gap-3 px-2 py-2.5 text-gray-700 hover:text-red-500 transition-colors w-full"
>
<LogoutOutlined className="text-[20px]" />
<LogoutOutlined className="text-p3" />
<span className="text-p3 font-medium">Log Out</span>
</Button>
</div>
</aside>
</div>
);
return (
<>
{/* Desktop Sidebar - visible on lg+, sticky */}
<div className="hidden lg:block sticky top-0 h-screen overflow-y-auto shadow">
{sidebarContent}
</div>
{/* Mobile Sidebar - overlay */}
{isOpen && (
<div className="lg:hidden fixed inset-0 z-50">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 transition-opacity"
onClick={onClose}
/>
{/* Sidebar */}
<div className="fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out">
{sidebarContent}
</div>
</div>
)}
</>
);
};
@@ -1,15 +1,16 @@
import { cn } from "@imphnen-frontend-service/utils"
import { Icon } from '@iconify/react'
import { FC, ReactElement, ReactNode } from "react"
import { Button } from "../../atoms"
import { cn } from '@imphnen-frontend-service/utils';
import { Icon } from '@iconify/react';
import { FC, ReactElement, ReactNode } from 'react';
import { Button } from '../../atoms';
import { useAuthStore } from '@imphnen-frontend-service/service';
export type TBackofficeWrapperProps = {
children: ReactNode
title?: string
className?: string
classHeader?: string
classTitle?: string
}
children: ReactNode;
title?: string;
className?: string;
classHeader?: string;
classTitle?: string;
};
export const BackofficeWrapper: FC<TBackofficeWrapperProps> = ({
children,
@@ -18,34 +19,52 @@ export const BackofficeWrapper: FC<TBackofficeWrapperProps> = ({
classHeader,
classTitle,
}): ReactElement => {
const { session } = useAuthStore();
const user = session?.user;
return (
<main className={cn("w-full px-[48px] py-[40px] flex flex-col gap-8", className)}>
<header className={cn("bg-white py-5 px-7 rounded-md shadow flex items-center justify-between", classHeader)}>
<h1 className={cn("text-[19px] text-primary-500 font-semibold", classTitle)}>{title}</h1>
<main
className={cn(
'w-full px-[48px] py-[40px] flex flex-col gap-8',
className
)}
>
<header
className={cn(
'bg-white py-5 px-7 rounded-md shadow flex items-center justify-between',
classHeader
)}
>
<h1
className={cn(
'text-[19px] text-primary-500 font-semibold',
classTitle
)}
>
{title}
</h1>
<div className="flex items-center gap-x-6">
<Button
type="button"
variant="secondary"
className="max-h-full p-3"
>
{/* <Button type="button" variant="secondary" className="max-h-full p-3">
<Icon icon="mdi:bell-outline" className="size-6" />
</Button>
</Button> */}
<div className="flex items-center gap-x-6">
<div className="text-neutral-600 font-medium">
<p className="text-p3">Rizal Syaepulloh</p>
<p className="text-label1">Super Admin</p>
<p className="text-p3">{user?.fullname || 'Full Name'}</p>
<p className="text-label1">Admin</p>
</div>
<div className="size-12 rounded-full overflow-hidden">
<img src="/images/asd687hwq6nds4dfjj2983.webp" alt="Profile" className="size-full object-cover" />
<img
src={user?.avatar || '/images/asd687hwq6nds4dfjj2983.webp'}
alt="Profile"
className="size-full object-cover"
/>
</div>
</div>
</div>
</header>
<section>
{children}
</section>
<section>{children}</section>
</main>
)
}
);
};
+247 -31
View File
@@ -1,57 +1,132 @@
import {
PaginationState,
SortingState,
useReactTable,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
ColumnDef,
Table,
RowData,
TableOptions,
} from '@tanstack/react-table';
import { Pagination } from '../../molecules';
import React from 'react';
import { cn } from '@imphnen-frontend-service/utils';
interface DataTableProps<T> {
data: T[];
columns: ColumnDef<T>[];
table: Table<T>;
interface DataTableProps<T extends RowData> {
table?: Table<T>;
data?: T[];
columns?: ColumnDef<T, unknown>[];
pageSize?: number;
className?: string;
// server-side pagination props
manualPagination?: boolean;
pageCount?: number;
currentPage?: number;
onPageChange?: (page: number) => void;
}
export const DataTable = <T extends RowData>({
data,
columns,
table,
data = [],
columns = [],
pageSize = 9,
className,
manualPagination = false,
pageCount,
currentPage = 1,
onPageChange,
}: DataTableProps<T>) => {
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize,
});
const [sorting, setSorting] = React.useState<SortingState>([]);
const table = useReactTable({
data,
columns,
state: {
pagination,
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
});
// Update pagination state when pageSize prop changes
React.useEffect(() => {
setPagination((prev) => ({
...prev,
pageSize,
}));
}, [pageSize]);
// Reset pagination when data changes to prevent out-of-bounds errors
React.useEffect(() => {
if (data.length > 0) {
setPagination((prev) => ({
...prev,
pageIndex: 0, // Reset to first page when data changes
}));
}
}, [data.length]);
// Memoize data and columns to prevent unnecessary re-renders
const memoizedData = React.useMemo(() => data, [data]);
const memoizedColumns = React.useMemo(() => columns, [columns]);
// Memoize table configuration to prevent recreation on every render
const tableConfig = React.useMemo(() => {
const config: TableOptions<T> = {
data: memoizedData,
columns: memoizedColumns,
state: {
pagination,
sorting,
},
onPaginationChange: setPagination,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
// server-side pagination config
manualPagination,
pageCount: manualPagination ? pageCount : undefined,
};
return config;
}, [
memoizedData,
memoizedColumns,
pagination,
sorting,
manualPagination,
pageCount,
]);
// Prefer external table instance if provided; otherwise create an internal one
const internalTable = useReactTable(tableConfig);
const t = table ?? internalTable;
// Handle empty data state
const isEmpty = t.getRowModel().rows.length === 0;
return (
<div className="flex flex-col gap-8">
<div className={cn('flex flex-col gap-8', className)}>
<div className="w-full overflow-x-auto">
<table className="w-full min-w-full text-base">
<thead className="bg-primary-50 mb-3 text-left text-nowrap">
{table.getHeaderGroups().map((headerGroup) => (
{t.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className={cn("py-4 px-5 font-normal first:rounded-l-lg last:rounded-r-lg", header?.column?.columnDef?.meta?.headerClassName)}
onClick={
header.column.getCanSort()
? header.column.getToggleSortingHandler()
: undefined
}
className={cn(
'py-4 px-5 font-normal first:rounded-l-lg last:rounded-r-lg',
header.column.getCanSort() &&
'cursor-pointer select-none hover:bg-primary-100 transition-colors',
header?.column?.columnDef?.meta?.headerClassName
)}
>
{header.isPlaceholder
? null
@@ -59,28 +134,169 @@ export const DataTable = <T extends RowData>({
header.column.columnDef.header,
header.getContext()
)}
{header.column.getCanSort() && (
<span className="ml-2 text-xs text-gray-500">
{header.column.getIsSorted() === 'asc' && '▲'}
{header.column.getIsSorted() === 'desc' && '▼'}
{!header.column.getIsSorted() && <span></span>}
</span>
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="bg-primary-100 odd:bg-white">
{row.getVisibleCells().map((cell, index) => (
<td
key={cell.id}
className={cn("py-3 px-5 first:rounded-l-lg last:rounded-r-lg", cell?.column?.columnDef?.meta?.cellClassName)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
{isEmpty ? (
<tr>
<td
colSpan={t.getAllColumns().length}
className="py-8 px-5 text-center text-neutral-500"
>
No data available
</td>
</tr>
))}
) : (
t.getRowModel().rows.map((row, rowIndex) => (
<tr
key={row.id}
className={cn(
'hover:bg-primary-50 transition-colors',
rowIndex % 2 === 0 ? 'bg-white' : 'bg-primary-100'
)}
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className={cn(
'py-3 px-5 first:rounded-l-lg last:rounded-r-lg',
cell?.column?.columnDef?.meta?.cellClassName
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination table={table} />
{manualPagination && onPageChange && pageCount ? (
// Server-side pagination controls with numbered pages
<div className="flex items-center justify-center gap-10">
<button
className="disabled:opacity-50 cursor-pointer"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Previous page"
>
<svg
className="w-4 h-4 text-neutral-800"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<div className="flex gap-4 items-baseline">
{pageCount <= 8 ? (
// Show all pages if 8 or fewer
Array.from({ length: pageCount }, (_, index) => (
<button
key={index}
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
currentPage === index + 1
? 'bg-primary-500 text-white'
: 'bg-primary-100 hover:bg-primary-200'
}`}
onClick={() => onPageChange(index + 1)}
>
{index + 1}
</button>
))
) : (
// Show ellipsis for many pages
<>
<button
onClick={() => onPageChange(1)}
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
currentPage === 1
? 'bg-primary-500 text-white'
: 'bg-primary-100 hover:bg-primary-200'
}`}
>
1
</button>
{currentPage > 3 && <span>...</span>}
{Array.from(
{ length: 5 },
(_, index) => currentPage - 2 + index
)
.filter((page) => page > 1 && page < pageCount)
.map((page) => (
<button
key={page}
onClick={() => onPageChange(page)}
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
currentPage === page
? 'bg-primary-500 text-white'
: 'bg-primary-100 hover:bg-primary-200'
}`}
>
{page}
</button>
))}
{currentPage < pageCount - 2 && <span>...</span>}
<button
onClick={() => onPageChange(pageCount)}
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
currentPage === pageCount
? 'bg-primary-500 text-white'
: 'bg-primary-100 hover:bg-primary-200'
}`}
>
{pageCount}
</button>
</>
)}
</div>
<button
className="disabled:opacity-50 cursor-pointer"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === pageCount}
aria-label="Next page"
>
<svg
className="w-4 h-4 text-neutral-800"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</div>
) : (
// Client-side pagination (default)
<Pagination table={t} />
)}
</div>
);
};
+3 -3
View File
@@ -1,4 +1,4 @@
import { supabase, useAuthStore } from '@imphnen-frontend-service/service';
import { useAuthStore } from '@imphnen-frontend-service/service';
import { useNavigate } from 'react-router';
export const useSession = () => {
@@ -6,9 +6,9 @@ export const useSession = () => {
const { clearSession, session, status } = useAuthStore();
const isAuthenticated = status === 'authenticated';
const signOut = async () => {
await supabase.auth.signOut();
const signOut = () => {
clearSession();
localStorage.clear();
navigate('/auth/login');
};
+3
View File
@@ -49,11 +49,13 @@
"dayjs": "^1.11.13",
"framer-motion": "^12.9.2",
"graphql": "^16.11.0",
"html2canvas": "^1.4.1",
"js-cookie": "^3.0.5",
"next": "~16.0.3",
"next-themes": "^0.4.6",
"openapi-fetch": "^0.15.0",
"openapi-react-query": "^0.5.0",
"qrcode": "^1.5.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.4",
@@ -96,6 +98,7 @@
"@testing-library/user-event": "^14.6.1",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.12.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.2.0",