Compare commits

...
Author SHA1 Message Date
Hafid Nur 5f9a537c20 Merge branch 'develop' into hackathon/update 2025-12-16 13:56:05 +07:00
Hafid Nur 040f414e6d feat(hackathon): update certificate
- add submission project name to the certificate
2025-12-16 13:52:55 +07:00
Hafid Nur fe172cc128 feat(hackathon): winner certificate (#73)
* update hackathon landing page

* feat(hackathon): draft winner certificate

* feat(hackathon): refine winner certificate
2025-12-16 12:33:23 +07:00
Hafid Nur 3eb86c6ef4 feat(hackathon): refine winner certificate 2025-12-16 12:06:29 +07:00
Hafid Nur 48d97af044 feat(hackathon): draft winner certificate 2025-12-16 11:26:00 +07:00
Hafid Nur 38ee9a9549 update hackathon landing page (#72) 2025-12-15 14:43:11 +07:00
Hafid Nur 36f7d10e7a update hackathon landing page 2025-12-15 14:40:49 +07:00
Maulana SodiqinandClaude 3ad828e759 feat: update certificate design with SVG template and Poppins font
- Changed certificate background from PNG to SVG for better quality
- Added Poppins font import to index.html
- Updated certificate layout with Team Name and Person Name in blue (#59bef5)
- Repositioned QR code to fit in designated white box placeholder
- Removed generated copyright and date overlays (now part of SVG template)
- Adjusted text positioning for better alignment with new design
- Increased canvas scale to 4x and wait time to 1500ms for sharper rendering

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-13 23:41:56 +07:00
Maulana SodiqinandClaude 782cce820b feat: hide certificate buttons for non-team members
- Added isTeamMember check (session user ID === certificate user ID)
- Hide all action buttons (Download, Print, View Submission) for non-members
- Hide "Back to Submission" button in header for non-members
- Unauthenticated users and other users can view certificate but cannot interact with buttons

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 00:48:21 +07:00
Maulana SodiqinandClaude f5882e3cdc feat: integrate new certificate endpoint from backend
- Updated certificate page to use useCertificatePublicData hook
- Removed old multi-API-call approach
- Now uses single /certificates/{userId} endpoint
- Simplified data fetching for user, team, submission data
- Certificate now works for unauthenticated users
- Removed duplicate certificate hook file

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 00:16:29 +07:00
Maulana SodiqinandClaude afaaff4a7f feat(service): add public certificate data hook
Add new useCertificatePublicData hook to fetch certificate data from public endpoint without authentication. This hook combines user, team, submission, and winner data in a single request.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 00:02:31 +07:00
Maulana SodiqinandClaude da8b08cd73 fix: allow unauthenticated users to view certificates
- Updated API error interceptor to skip redirect on certificate pages
- Prevents 401 errors from redirecting to login on public certificate pages
- Certificate pages can now be viewed without authentication

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 22:01:37 +07:00
Maulana SodiqinandClaude 1dbe704230 feat: implement persistent personalized certificates
- Modified encodeCertificateId to include userId parameter
- Updated decodeCertificateId to extract and return userId
- Added backward compatibility for old 2-part certificate IDs
- Certificate page now fetches and displays certificate owner's name
- Submission page passes current user's ID when generating certificate
- Fixed certificate template to use certificate owner's name instead of team leader
- Certificates now show the encoded user's name regardless of who views them

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 21:40:34 +07:00
Maulana SodiqinandClaude 59c1bb0366 feat: make certificate page publicly accessible
- Add /certificate/* to public routes in root layout
- Display team leader name instead of current user
- Remove authentication requirement for viewing certificates
- Certificates now work without login

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 21:30:28 +07:00
Maulana SodiqinandClaude c518784810 fix: encode certificate ID in QR code URL
- Use encodeURIComponent to properly encode certId
- Fixes QR code URLs with special characters like /, +, =
- QR codes now generate with properly encoded URLs (%2F, %2B, %3D)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 21:23:35 +07:00
Maulana Sodiqin 0267de664b Delete .github/workflows/deploy-landing.yml 2025-12-10 12:28:05 +07:00
Maulana Sodiqin 134ff14946 Delete .github/workflows/deploy-hackathon.yml 2025-12-10 12:27:55 +07:00
Maulana Sodiqin ed3b711c89 Delete .github/workflows/deploy-gacha.yml 2025-12-10 12:27:44 +07:00
Maulana Sodiqin 6b7b1c4d20 Delete .github/workflows/deploy-dimentorin.yml 2025-12-10 12:27:31 +07:00
Maulana Sodiqin b4b19e02fa Delete .github/workflows/deploy-backoffice.yml 2025-12-10 12:27:20 +07:00
Denta e61979cbb6 chore(landing): adjust landing page (#71)
* chore: adjust landing page
- 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

* chore: adjust landing page
- fix logo github in community section
2025-12-10 11:22:37 +07:00
Hafid Nur 0c7887268c feat(backoffice): FE integration (data fetch) for dashboard, teams, submission, and user management page (#70)
* 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

* 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

* feat(backoffice): add searchable city filter

- add component for city filter
- apply to user management and team management pages

* 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

* feat(backoffice): authentication middleware, error pages, and 404 page

* feat(backoffice): hackathon dashboard integration

* 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

* 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

* feat(backoffice): submission page integration

- Fetch submissions data from API
- Move submission modal to hackathon-submissions page
2025-12-10 08:41:30 +07:00
Maulana Sodiqin 76933c0be7 chore: upgrade react version 2025-12-09 16:28:32 +07:00
Denta abc4c26d5d chore: adjust landing page (#69)
- change typo text in footer "patners" -> "partnerts"
- add github in community section, and footer
- update the value of stat members in hero and cta section
2025-12-09 14:16:21 +07:00
Anka Tama a3d35716a6 Feat/certificate (#68)
* feat: implement certificate encoding and decoding functionality with AES encryption

* feat: blank cert

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

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

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

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

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

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

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

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

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

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

* test datatable with mock data

* base UI for Hackathon backoffice

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

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

* update endpoint

* feat(backoffice): update page hackathon user management

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

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

* feat(backoffice): little adjustment in hackathon users management UI and API contract
2025-12-05 11:55:28 +07:00
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
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
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
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 Nur 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
bc5b072ad8 feat(hackathon): Improve dark mode and UI/UX in Hackathon website (#64)
* 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

---------

Co-authored-by: Maulana Sodiqin <sodiqincahyana1@gmail.com>
Co-authored-by: Muhammad Zakir Ramadhan <61570975+zakirkun@users.noreply.github.com>
2025-11-27 15:12:45 +07:00
98 changed files with 16792 additions and 2842 deletions
+7 -1
View File
@@ -10,7 +10,13 @@
"Bash(ren page.tsx page-original.tsx)", "Bash(ren page.tsx page-original.tsx)",
"Bash(ren:*)", "Bash(ren:*)",
"Bash(npx supabase:*)", "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": [], "deny": [],
"ask": [] "ask": []
-44
View File
@@ -1,44 +0,0 @@
name: Deploy Backoffice
on:
push:
branches:
- develop
paths:
- 'apps/backoffice/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: |
set -e
if [ ! -d ~/imphnen-frontend-service-backoffice ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-backoffice
else
cd ~/imphnen-frontend-service-backoffice && git fetch origin && git reset --hard origin/develop && git pull
fi
cat > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml down || true
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build --progress=plain
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d
docker image prune -af
-44
View File
@@ -1,44 +0,0 @@
name: Deploy Dimentorin
on:
push:
branches:
- develop
paths:
- 'apps/dimentorin/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: |
set -e
if [ ! -d ~/imphnen-frontend-service-dimentorin ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-dimentorin
else
cd ~/imphnen-frontend-service-dimentorin && git fetch origin && git reset --hard origin/develop && git pull
fi
cat > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml down || true
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build --progress=plain
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d
docker image prune -af
-44
View File
@@ -1,44 +0,0 @@
name: Deploy Gacha
on:
push:
branches:
- develop
paths:
- 'apps/gacha/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: |
set -e
if [ ! -d ~/imphnen-frontend-service-gacha ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-gacha
else
cd ~/imphnen-frontend-service-gacha && git fetch origin && git reset --hard origin/develop && git pull
fi
cat > ~/imphnen-frontend-service-gacha/apps/gacha/.env << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml down || true
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build --progress=plain
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d
docker image prune -af
-45
View File
@@ -1,45 +0,0 @@
name: Deploy Hackathon
on:
push:
branches:
- develop
paths:
- 'apps/hackathon/**' # Auto deploy when changes pushed to apps/hackathon
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: |
set -e
if [ ! -d ~/imphnen-frontend-service-hackathon ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-hackathon
else
cd ~/imphnen-frontend-service-hackathon && git fetch origin && git reset --hard origin/develop && git pull
fi
cat > ~/imphnen-frontend-service-hackathon/.env.local << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml down || true
docker builder prune -af || true
DOCKER_BUILDKIT=1 docker compose --env-file ~/imphnen-frontend-service-hackathon/.env.local -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml build --progress=plain
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml up -d
docker image prune -af
-44
View File
@@ -1,44 +0,0 @@
name: Deploy Landing
on:
push:
branches:
- develop
paths:
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: |
set -e
if [ ! -d ~/imphnen-frontend-service ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
else
cd ~/imphnen-frontend-service && git fetch origin && git reset --hard origin/develop && git pull
fi
cat > ~/imphnen-frontend-service/apps/landing/.env << EOF
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build --progress=plain
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
docker image prune -af
+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> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-theme="light">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Backoffice</title> <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 { Outlet } from 'react-router-dom';
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms'; import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
export const AppLayout: FC = (): ReactElement => { export const AppLayout: FC = (): ReactElement => {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
return ( return (
<div className="bg-primary-50 min-h-screen flex justify-center"> <div className="bg-primary-50 min-h-screen flex justify-center">
<div className="bg-primary-50 min-h-screen w-full flex"> <div className="bg-primary-50 min-h-screen w-full flex">
<BackofficeSidebar /> <BackofficeSidebar
isOpen={mobileSidebarOpen}
onClose={() => setMobileSidebarOpen(false)}
/>
<div className="flex-1 overflow-auto"> <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 /> <Outlet />
</div> </div>
</div> </div>
@@ -1,19 +1,29 @@
import { SearchOutlined } from "@ant-design/icons"; import { SearchOutlined } from '@ant-design/icons';
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms"; import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms';
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms"; import {
import { cn, For } from "@imphnen-frontend-service/utils"; BackofficeWrapper,
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table"; DataTable,
import { ReactElement, useState } from "react"; } from '@imphnen-frontend-service/ui/organisms';
import { ModalDetailUser } from "./_components/modal/detail"; 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'; type UserStatus = 'active' | 'inactive';
interface UserType { interface UserType {
id: number id: number;
name: string name: string;
email: string email: string;
rating: number rating: number;
status: UserStatus status: UserStatus;
} }
const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({ const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
@@ -22,15 +32,15 @@ const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
email: 'fullname23@gmail.com', email: 'fullname23@gmail.com',
rating: 4.5, rating: 4.5,
status: i % 2 === 0 ? 'active' : 'inactive', status: i % 2 === 0 ? 'active' : 'inactive',
})) }));
export default function Components(): ReactElement { export default function Components(): ReactElement {
const TABS = ['mentor', 'mentee'] as const const TABS = ['mentor', 'mentee'] as const;
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor') const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor');
const [showDetail, setShowDetail] = useState(false) const [showDetail, setShowDetail] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<number | null>(null) const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({ const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 9, pageSize: 9,
@@ -39,7 +49,7 @@ export default function Components(): ReactElement {
const columns: ColumnDef<UserType>[] = [ const columns: ColumnDef<UserType>[] = [
{ {
id: 'select', id: 'select',
meta: { cellClassName: cn("w-20") }, meta: { cellClassName: cn('w-20') },
header: ({ table }) => ( header: ({ table }) => (
<input <input
type="checkbox" type="checkbox"
@@ -97,7 +107,7 @@ export default function Components(): ReactElement {
}, },
{ {
header: 'Action', header: 'Action',
meta: { cellClassName: cn("w-72") }, meta: { cellClassName: cn('w-72') },
cell: ({ row }) => ( cell: ({ row }) => (
<Button <Button
variant="primary" variant="primary"
@@ -113,7 +123,7 @@ export default function Components(): ReactElement {
</Button> </Button>
), ),
}, },
] ];
const table = useReactTable({ const table = useReactTable({
data: mockData, data: mockData,
@@ -134,14 +144,19 @@ export default function Components(): ReactElement {
return ( return (
<BackofficeWrapper title="Dimentorin.dev"> <BackofficeWrapper title="Dimentorin.dev">
<div className="mb-8 flex justify-between items-center"> <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"> <div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
<For data={TABS}> <For data={TABS}>
{(tab) => ( {(tab) => (
<Button <Button
key={tab} key={tab}
variant="text" 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)} onClick={() => setActiveTab(tab)}
> >
{tab} {tab}
@@ -163,12 +178,16 @@ export default function Components(): ReactElement {
</div> </div>
</div> </div>
<Select> <Select>
<option selected disabled>Rating</option> <option selected disabled>
Rating
</option>
<option value="4.5">4.5</option> <option value="4.5">4.5</option>
<option value="5">5</option> <option value="5">5</option>
</Select> </Select>
<Select> <Select>
<option selected disabled>Status</option> <option selected disabled>
Status
</option>
<option value="active">Active</option> <option value="active">Active</option>
<option value="inactive">Inactive</option> <option value="inactive">Inactive</option>
</Select> </Select>
@@ -2,22 +2,39 @@ import { useForm } from 'react-hook-form';
import { import {
authLoginSchema, authLoginSchema,
TLoginRequest, TLoginRequest,
useBackofficeLogin,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod'; 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 = () => { export const useLogin = () => {
const navigate = useNavigate();
const loginMutation = useBackofficeLogin();
const form = useForm<TLoginRequest>({ const form = useForm<TLoginRequest>({
resolver: zodResolver(authLoginSchema), resolver: zodResolver(authLoginSchema),
mode: 'all', mode: 'all',
defaultValues: {
email: '',
password: '',
},
}); });
const { signIn } = useSession(); const onSubmit = form.handleSubmit(async (data) => {
try {
const onSubmit = form.handleSubmit((data) => signIn(data)); 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 { return {
form, form,
onSubmit, onSubmit,
isLoading: loginMutation.isPending,
}; };
}; };
@@ -4,7 +4,7 @@ import { useLogin } from './_hooks/use-login';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms'; import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
export const Components: FC = (): ReactElement => { export const Components: FC = (): ReactElement => {
const { form, onSubmit } = useLogin(); const { form, onSubmit, isLoading } = useLogin();
return ( return (
<div className="flex justify-center items-center min-h-screen"> <div className="flex justify-center items-center min-h-screen">
@@ -22,6 +22,7 @@ export const Components: FC = (): ReactElement => {
name="email" name="email"
size="lg" size="lg"
className="w-full" className="w-full"
disabled={isLoading}
/> />
<ControlledInputField <ControlledInputField
control={form.control} control={form.control}
@@ -31,10 +32,11 @@ export const Components: FC = (): ReactElement => {
name="password" name="password"
size="lg" size="lg"
className="w-full" className="w-full"
disabled={isLoading}
/> />
<Button <Button
disabled={ disabled={
form.formState.isSubmitting || isLoading ||
form.formState.isValidating || form.formState.isValidating ||
!form.formState.isValid !form.formState.isValid
} }
@@ -42,7 +44,7 @@ export const Components: FC = (): ReactElement => {
size="md" size="md"
className="w-full" className="w-full"
> >
Login {isLoading ? 'Loading...' : 'Login'}
</Button> </Button>
</form> </form>
</div> </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;
+1 -1
View File
@@ -130,7 +130,7 @@
html { html {
font-family: 'Bai Jamjuree', sans-serif; font-family: 'Bai Jamjuree', sans-serif;
font-weight: 400; font-weight: 400;
font-size: 12px; font-size: 14px;
line-height: 1.2; 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) ?? []; session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
if (mappingPublicRoutes.includes(pathname)) { if (mappingPublicRoutes.includes(pathname)) {
if (token) return redirect('/dashboard'); if (token) return redirect('/hackathon-dashboard');
return null; return null;
} }
@@ -100,7 +100,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
matchedRoute.permissions.some((perm) => userPermissions.includes(perm)); matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
if (!hasPermission) { if (!hasPermission) {
return '/dashboard'; return '/hackathon-dashboard';
} }
} }
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig(() => ({
root: __dirname, root: __dirname,
cacheDir: '../../node_modules/.vite/apps/backoffice', cacheDir: '../../node_modules/.vite/apps/backoffice',
server: { server: {
port: 3000, port: 3003,
host: 'localhost', host: 'localhost',
}, },
preview: { preview: {
+3
View File
@@ -18,6 +18,9 @@
type="image/svg+xml" type="image/svg+xml"
href="/images/imphnen-logo-simple.svg" href="/images/imphnen-logo-simple.svg"
/> />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/src/index.css" /> <link rel="stylesheet" href="/src/index.css" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:url" content="https://hackathon.imphnen.dev/" /> <meta property="og:url" content="https://hackathon.imphnen.dev/" />
Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 506 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 818 KiB

+3 -3
View File
@@ -2,13 +2,13 @@ import { Link } from 'react-router-dom';
export default function NotFoundPage() { export default function NotFoundPage() {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50"> <div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
<div className="text-center"> <div className="text-center">
<h1 className="text-9xl font-bold text-gray-200 mb-4">404</h1> <h1 className="text-9xl font-bold text-gray-200 mb-4">404</h1>
<h2 className="text-3xl font-semibold text-gray-900 mb-4"> <h2 className="text-3xl font-semibold text-gray-900 dark:text-gray-300 mb-4">
Page Not Found Page Not Found
</h2> </h2>
<p className="text-gray-600 mb-8"> <p className="text-gray-600 dark:text-gray-400 mb-8">
The page you are looking for doesn't exist or has been moved. The page you are looking for doesn't exist or has been moved.
</p> </p>
<Link <Link
+95 -114
View File
@@ -1,11 +1,11 @@
import { FC, ReactElement, useEffect, useState, useRef } from 'react'; import { FC, ReactElement, useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useAuthStore, supabase } from '@imphnen-frontend-service/service'; import { useGitHubCallback } from '@imphnen-frontend-service/service';
import { toast } from 'sonner'; import { toast } from 'sonner';
const CallbackPage: FC = (): ReactElement => { const CallbackPage: FC = (): ReactElement => {
const navigate = useNavigate(); const navigate = useNavigate();
const { setSession } = useAuthStore(); const { mutateAsync: exchangeGitHubCode } = useGitHubCallback();
const [isProcessing, setIsProcessing] = useState(true); const [isProcessing, setIsProcessing] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const hasRunRef = useRef(false); const hasRunRef = useRef(false);
@@ -13,134 +13,68 @@ const CallbackPage: FC = (): ReactElement => {
useEffect(() => { useEffect(() => {
const handleCallback = async () => { const handleCallback = async () => {
if (hasRunRef.current) { if (hasRunRef.current) {
// console.log('[Callback] Already processed, skipping...');
return; return;
} }
hasRunRef.current = true; hasRunRef.current = true;
try { try {
// console.log('[Callback] Processing OAuth callback...'); // Check URL hash for Supabase email confirmation callback
// console.log('[Callback] Current URL:', globalThis.location.href); const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
const urlParams = new URLSearchParams(globalThis.location.search);
// Supabase client is configured with detectSessionInUrl: true const type = hashParams.get('type') || urlParams.get('type');
// This means Supabase automatically detects and processes OAuth tokens from the URL hash const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
// We just need to wait a moment for it to complete, then check for the session
// console.log('[Callback] Waiting for Supabase to process OAuth callback...'); // Debug: log what we received
await new Promise((resolve) => setTimeout(resolve, 1000)); 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 // Handle Supabase email callbacks (has access_token in hash or query)
const { // This includes: signup confirmation, email confirmation, password recovery
data: { session: sessionData }, if (accessToken) {
error: sessionError, setIsProcessing(false);
} = await supabase.auth.getSession();
if (sessionError) { // Password recovery - type is 'recovery' or we have access_token from reset email
// console.error('[Callback] Session error:', sessionError); if (type === 'recovery' || type === 'magiclink') {
throw new Error(sessionError.message || 'Failed to get session'); toast.success('Email verified! Please set your new password.');
navigate('/auth/reset-password?access_token=' + accessToken);
return;
} }
if (!sessionData || !sessionData.user) { // Signup/Email confirmation
throw new Error( if (type === 'signup' || type === 'email_confirmation') {
'No session found after OAuth callback. Please try logging in again.' toast.success('Email verified successfully! Please log in to continue.');
); navigate('/auth/login');
return;
} }
// console.log('[Callback] Supabase session established:', { // If we have access_token but unknown type, assume it's password recovery
// userId: sessionData.user.id, // (Supabase sometimes sends without explicit type)
// email: sessionData.user.email, toast.success('Email verified! Please set your new password.');
// }); navigate('/auth/reset-password?access_token=' + accessToken);
return;
// 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 // Get the code from URL query params (GitHub OAuth)
// Supabase now manages the actual auth session const code = urlParams.get('code');
// 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({ if (!code) {
token: { throw new Error('No authorization code received');
access_token: sessionData.access_token, }
refresh_token: sessionData.refresh_token || '',
}, // Exchange the code for tokens using backend API (GitHub OAuth)
user: { const result = await exchangeGitHubCode({ code });
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!'); toast.success('Login successful!');
setIsProcessing(false); setIsProcessing(false);
// Check if user has completed onboarding (has location) // Check if user has completed onboarding (has location)
// Use globalThis.location.replace for hard redirect to prevent history issues if (result.user.location) {
if (userRecord.location) {
// console.log('[Callback] User has completed onboarding, redirecting to dashboard...');
globalThis.location.replace('/dashboard'); globalThis.location.replace('/dashboard');
} else { } else {
// console.log('[Callback] User needs onboarding, redirecting...');
globalThis.location.replace('/onboarding/user'); globalThis.location.replace('/onboarding/user');
} }
} catch (err) { } catch (err) {
// console.error('[Callback] Error:', err); console.error('[Callback] Error:', err);
setError((err as Error).message); setError((err as Error).message);
setIsProcessing(false); setIsProcessing(false);
toast.error('An error occurred during login'); toast.error('An error occurred during login');
@@ -153,21 +87,68 @@ const CallbackPage: FC = (): ReactElement => {
handleCallback(); handleCallback();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Run only once on mount }, []);
if (error) { 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 ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 px-4"> <div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 px-4">
<div className="bg-white w-full max-w-2xl p-8 rounded-2xl shadow-lg border border-red-200"> <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-center mb-6">
<div className="text-red-500 text-5xl mb-4"></div> <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 GitHub Login Failed
</h2> </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> </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... Redirecting to login page in 3 seconds...
</p> </p>
</div> </div>
@@ -176,13 +157,13 @@ const CallbackPage: FC = (): ReactElement => {
} }
return ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50"> <div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-center"> <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> <div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
<h2 className="text-xl font-semibold text-gray-900 mb-2"> <h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
Completing login... Completing login...
</h2> </h2>
<p className="text-gray-600">Please wait</p> <p className="text-gray-600 dark:text-gray-400">Please wait</p>
</div> </div>
</div> </div>
); );
@@ -1,14 +1,15 @@
import { useState } from 'react'; 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 { Link, useNavigate } from 'react-router';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import ThemeToggle from '../../../components/theme-toggle';
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [emailSent, setEmailSent] = useState(false); const [emailSent, setEmailSent] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const forgotPasswordMutation = useForgotPassword();
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -19,44 +20,33 @@ export default function ForgotPasswordPage() {
} }
try { try {
setIsLoading(true); await forgotPasswordMutation.mutateAsync({ email });
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/reset-password`,
});
if (error) {
throw error;
}
setEmailSent(true); setEmailSent(true);
toast.success('Password reset email sent! Check your inbox.'); toast.success('Password reset email sent! Check your inbox.');
} catch (err) { } catch (err) {
// console.error('Failed to send reset email:', err);
toast.error((err as Error).message || 'Failed to send reset email'); toast.error((err as Error).message || 'Failed to send reset email');
} finally {
setIsLoading(false);
} }
}; };
if (emailSent) { if (emailSent) {
return ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4"> <div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 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="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="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> <span className="text-3xl"></span>
</div> </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 Check Your Email
</h2> </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> We've sent a password reset link to <strong>{email}</strong>
</p> </p>
</div> </div>
<div className="space-y-4"> <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 Click the link in the email to reset your password. The link will
expire in 1 hour. expire in 1 hour.
</p> </p>
@@ -69,7 +59,7 @@ export default function ForgotPasswordPage() {
<button <button
onClick={() => setEmailSent(false)} 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 Send another email
</button> </button>
@@ -80,13 +70,23 @@ export default function ForgotPasswordPage() {
} }
return ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4"> <div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200"> <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">
<div className="flex items-center justify-between mb-6">
<button
onClick={() => navigate('/auth/login')}
className="cursor-pointer text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300 text-base font-sans flex items-center"
>
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />
Back to Login
</button>
<ThemeToggle />
</div>
<div className="text-center mb-8"> <div className="text-center mb-8">
<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">
Forgot Password? Forgot Password?
</h2> </h2>
<p className="text-gray-600"> <p className="text-gray-600 dark:text-gray-400">
No worries, we'll send you reset instructions No worries, we'll send you reset instructions
</p> </p>
</div> </div>
@@ -95,7 +95,7 @@ export default function ForgotPasswordPage() {
<div> <div>
<label <label
htmlFor="email" htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
> >
Email Address Email Address
</label> </label>
@@ -105,30 +105,20 @@ export default function ForgotPasswordPage() {
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com" placeholder="your@email.com"
disabled={isLoading} disabled={forgotPasswordMutation.isPending}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed" 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 required
/> />
</div> </div>
<button <button
type="submit" 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" 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> </button>
</form> </form>
<div className="mt-8">
<Link
to="/auth/login"
className="text-primary-500 hover:text-primary-600 flex items-center"
>
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />
<span> Back to Login</span>
</Link>
</div>
</div> </div>
</div> </div>
); );
+77 -82
View File
@@ -1,9 +1,7 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { import {
useGitHubAuth, useGitHubAuth,
useEmailAuth, useLogin,
supabase,
useAuthStore,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { GithubOutlined } from '@ant-design/icons'; import { GithubOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router'; import { useNavigate, Link } from 'react-router';
@@ -12,17 +10,34 @@ import { Icon } from '@iconify/react';
import { ThemeToggle } from '../../../components/theme-toggle'; import { ThemeToggle } from '../../../components/theme-toggle';
export default function LoginPage() { export default function LoginPage() {
// console.log('[LoginPage] Rendering...');
const navigate = useNavigate(); const navigate = useNavigate();
const { setSession } = useAuthStore();
const { signInWithGitHub } = useGitHubAuth(); const { signInWithGitHub } = useGitHubAuth();
const { signInWithEmail } = useEmailAuth(); const loginMutation = useLogin();
const [isGithubLoading, setIsGithubLoading] = useState(false); const [isGithubLoading, setIsGithubLoading] = useState(false);
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null); 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) => { const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -34,52 +49,12 @@ export default function LoginPage() {
} }
try { try {
setIsEmailLoading(true); const result = await loginMutation.mutateAsync({ email, password });
// 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: '',
},
},
});
toast.success('Login successful!'); toast.success('Login successful!');
// Redirect based on onboarding status // Redirect based on onboarding status
if (userData?.location) { if (result.user.location) {
navigate('/dashboard'); navigate('/dashboard');
} else { } else {
navigate('/onboarding/user'); navigate('/onboarding/user');
@@ -87,36 +62,32 @@ export default function LoginPage() {
} catch (err) { } catch (err) {
console.error('[Login] Email login failed:', err); console.error('[Login] Email login failed:', err);
setError((err as Error).message || 'Login failed'); setError((err as Error).message || 'Login failed');
setIsEmailLoading(false);
} }
}; };
const handleGithubLogin = async () => { const handleGithubLogin = async () => {
try { try {
setIsGithubLoading(true); setIsGithubLoading(true);
// console.log('[Login] Initiating GitHub OAuth...');
const result = await signInWithGitHub(); const result = await signInWithGitHub();
// console.log('[Login] OAuth result:', result);
// Check if we got a redirect URL // Check if we got a redirect URL
if (result?.url) { if (result?.url) {
// console.log('[Login] Redirecting to GitHub OAuth:', result.url);
// Manually redirect immediately
globalThis.location.href = result.url; globalThis.location.href = result.url;
} else { } else {
// console.error('[Login] No OAuth URL returned');
setIsGithubLoading(false); setIsGithubLoading(false);
setError('Failed to get GitHub OAuth URL');
} }
} catch (error) { } catch (err) {
// console.error('[Login] GitHub login failed'); console.error('[Login] GitHub login failed:', err);
setError((err as Error).message || 'GitHub login failed');
setIsGithubLoading(false); setIsGithubLoading(false);
} }
}; };
return ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-neutral-950 p-4"> <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-neutral-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-neutral-700"> <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">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<button <button
onClick={() => navigate('/')} onClick={() => navigate('/')}
@@ -132,7 +103,7 @@ export default function LoginPage() {
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2"> <h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Welcome Back Welcome Back
</h2> </h2>
<p className="text-gray-600 dark:text-neutral-400 font-sans"> <p className="text-gray-600 dark:text-gray-400 font-sans">
Sign in to join or create your hackathon team Sign in to join or create your hackathon team
</p> </p>
</div> </div>
@@ -147,7 +118,7 @@ export default function LoginPage() {
<div> <div>
<label <label
htmlFor="email" htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-1" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
> >
Email Email
</label> </label>
@@ -157,8 +128,8 @@ export default function LoginPage() {
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com" placeholder="your@email.com"
disabled={isEmailLoading} disabled={loginMutation.isPending}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-neutral-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-neutral-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-neutral-500 disabled:bg-gray-100 dark:disabled:bg-neutral-700 disabled:cursor-not-allowed" 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 required
/> />
</div> </div>
@@ -167,7 +138,7 @@ export default function LoginPage() {
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<label <label
htmlFor="password" htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-neutral-300" className="block text-sm font-medium text-gray-700 dark:text-gray-300"
> >
Password Password
</label> </label>
@@ -178,38 +149,49 @@ export default function LoginPage() {
Forgot password? Forgot password?
</Link> </Link>
</div> </div>
<div className="relative">
<input <input
id="password" id="password"
type="password" type={showPassword ? 'text' : 'password'}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
disabled={isEmailLoading} disabled={loginMutation.isPending}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-neutral-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-neutral-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-neutral-500 disabled:bg-gray-100 dark:disabled:bg-neutral-700 disabled:cursor-not-allowed" 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 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> </div>
<button <button
type="submit" 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-neutral-900 disabled:bg-gray-400 dark:disabled:bg-neutral-600 disabled:cursor-not-allowed transition-colors cursor-pointer" 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> </button>
</form> </form>
<div className="my-6 flex items-center"> <div className="my-6 flex items-center">
<div className="flex-1 border-t border-gray-300 dark:border-neutral-600"></div> <div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
<span className="px-4 text-sm text-gray-500 dark:text-neutral-400">OR</span> <span className="px-4 text-sm text-gray-500 dark:text-gray-400">
<div className="flex-1 border-t border-gray-300 dark:border-neutral-600"></div> OR
</span>
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
</div> </div>
<button <button
onClick={handleGithubLogin} onClick={handleGithubLogin}
disabled={isGithubLoading} disabled={isGithubLoading}
type="button" type="button"
className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 dark:bg-neutral-800 border border-gray-300 dark:border-neutral-600 rounded-lg font-semibold text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 dark:focus:ring-offset-neutral-900 disabled:bg-gray-100 dark:disabled:bg-neutral-800 disabled:cursor-not-allowed transition-colors cursor-pointer" className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg font-semibold text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:bg-gray-100 dark:disabled:bg-gray-800 disabled:cursor-not-allowed transition-colors cursor-pointer"
> >
<GithubOutlined className="text-xl" /> <GithubOutlined className="text-xl" />
<span> <span>
@@ -217,20 +199,33 @@ export default function LoginPage() {
</span> </span>
</button> </button>
<div className="mt-6 text-center"> <p className="mt-3 text-xs text-center text-gray-500 dark:text-gray-500 font-sans">
<p className="text-gray-600 dark:text-neutral-400 text-sm"> Make sure your GitHub email is{' '}
Don't have an account?{' '}
<a <a
href="/auth/signup" 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?{' '}
<Link
to="/auth/signup"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold" className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
> >
Sign up Sign up
</a> </Link>
</p> </p>
</div> </div>
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<p className="text-gray-500 dark:text-neutral-500 text-xs"> <p className="text-gray-500 dark:text-gray-500 text-xs">
By signing in, you agree to our Terms of Service and Privacy Policy By signing in, you agree to our Terms of Service and Privacy Policy
</p> </p>
</div> </div>
@@ -1,25 +1,32 @@
import { useState, useEffect } from 'react'; 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 { useNavigate } from 'react-router';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Icon } from '@iconify/react';
export default function ResetPasswordPage() { export default function ResetPasswordPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { clearSession } = useAuthStore();
const resetPasswordMutation = useResetPassword();
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false); const [accessToken, setAccessToken] = useState<string | null>(null);
const [isValidToken, setIsValidToken] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
useEffect(() => { useEffect(() => {
// Check if we have a valid session (from the reset link) // Get the access_token from URL hash (Supabase sends it as hash fragment)
supabase.auth.getSession().then(({ data: { session } }) => { // or from query params (when redirected from callback page)
if (session) { const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
setIsValidToken(true); const queryParams = new URLSearchParams(globalThis.location.search);
const token = hashParams.get('access_token') || queryParams.get('access_token');
if (token) {
setAccessToken(token);
} else { } else {
toast.error('Invalid or expired reset link'); toast.error('Invalid or expired reset link');
setTimeout(() => navigate('/auth/forgot-password'), 2000); setTimeout(() => navigate('/auth/forgot-password'), 2000);
} }
});
}, [navigate]); }, [navigate]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@@ -35,98 +42,117 @@ export default function ResetPasswordPage() {
return; return;
} }
try { if (!accessToken) {
setIsLoading(true); toast.error('Invalid reset token');
return;
const { error } = await supabase.auth.updateUser({
password: password,
});
if (error) {
throw error;
} }
try {
await resetPasswordMutation.mutateAsync({
access_token: accessToken,
new_password: password,
});
toast.success('Password updated successfully!'); toast.success('Password updated successfully!');
// Sign out and redirect to login // Clear session and redirect to login
await supabase.auth.signOut(); clearSession();
navigate('/auth/login'); navigate('/auth/login');
} catch (err) { } catch (err) {
// console.error('Failed to reset password:', err);
toast.error((err as Error).message || 'Failed to reset password'); toast.error((err as Error).message || 'Failed to reset password');
} finally {
setIsLoading(false);
} }
}; };
if (!isValidToken) { if (!accessToken) {
return ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50"> <div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-center"> <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> <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">Verifying reset link...</p> <p className="text-gray-600 dark:text-gray-400">
Verifying reset link...
</p>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4"> <div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200"> <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">
<div className="text-center mb-8"> <div className="text-center mb-8">
<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">
Set New Password Set New Password
</h2> </h2>
<p className="text-gray-600">Enter your new password below</p> <p className="text-gray-600 dark:text-gray-400">
Enter your new password below
</p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label <label
htmlFor="password" htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
> >
New Password New Password
</label> </label>
<div className="relative">
<input <input
id="password" id="password"
type="password" type={showPassword ? 'text' : 'password'}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
disabled={isLoading} disabled={resetPasswordMutation.isPending}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed" 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 required
minLength={6} 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>
<div> <div>
<label <label
htmlFor="confirmPassword" htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
> >
Confirm New Password Confirm New Password
</label> </label>
<div className="relative">
<input <input
id="confirmPassword" id="confirmPassword"
type="password" type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
disabled={isLoading} disabled={resetPasswordMutation.isPending}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed" 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 required
minLength={6} 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> </div>
<button <button
type="submit" 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" 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> </button>
</form> </form>
</div> </div>
+263 -148
View File
@@ -1,145 +1,203 @@
import { useState } from 'react'; import { useState } from 'react';
import { import { useGitHubAuth, useSignup } from '@imphnen-frontend-service/service';
useGitHubAuth,
useEmailAuth,
supabase,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { GithubOutlined } from '@ant-design/icons'; import { GithubOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router'; import { useNavigate, Link } from 'react-router';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { ThemeToggle } from '../../../components/theme-toggle'; 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() { export default function SignupPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { setSession } = useAuthStore();
// Check if registration is closed
const isRegistrationClosed = new Date() >= REGISTRATION_DEADLINE;
const { signInWithGitHub } = useGitHubAuth(); const { signInWithGitHub } = useGitHubAuth();
const { signUpWithEmail } = useEmailAuth(); const signupMutation = useSignup();
const [isGithubLoading, setIsGithubLoading] = useState(false); 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 [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 handleEmailSignup = async (e: React.FormEvent) => { const {
e.preventDefault(); register,
setError(null); handleSubmit,
formState: { errors, isValid },
if (!fullname || !email || !password || !confirmPassword) { } = useForm<SignupFormData>({
setError('Please fill in all fields'); resolver: zodResolver(signupSchema),
return; mode: 'onChange',
}
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!'); const onSubmit = async (data: SignupFormData) => {
navigate('/onboarding/user'); setError(null);
} else {
// Email confirmation is enabled try {
toast.success( const result = await signupMutation.mutateAsync({
'Account created! Please check your email to verify your account.' email: data.email,
); password: data.password,
setTimeout(() => { fullname: data.fullname,
navigate('/auth/login'); });
}, 2000); toast.success(result.message);
} setRegisteredEmail(data.email);
setRegistrationSuccess(true);
} catch (err) { } catch (err) {
// console.error('[Signup] Email signup failed:', err); console.error('[Signup] Email signup failed:', err);
setError((err as Error).message || 'Signup failed'); 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 () => { const handleGithubLogin = async () => {
try { try {
setIsGithubLoading(true); setIsGithubLoading(true);
// console.log('[Signup] Initiating GitHub OAuth...');
const result = await signInWithGitHub(); const result = await signInWithGitHub();
// console.log('[Signup] OAuth result:', result);
if (result?.url) { if (result?.url) {
// console.log('[Signup] Redirecting to GitHub OAuth:', result.url);
globalThis.location.href = result.url; globalThis.location.href = result.url;
} else { } else {
// console.error('[Signup] No OAuth URL returned');
setIsGithubLoading(false); setIsGithubLoading(false);
setError('Failed to get GitHub OAuth URL');
} }
} catch (error) { } catch (err) {
// console.error('[Signup] GitHub login failed:', error); console.error('[Signup] GitHub login failed:', err);
setError((err as Error).message || 'GitHub login failed');
setIsGithubLoading(false); 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 ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-neutral-950 p-4"> <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-neutral-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-neutral-700"> <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">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<button <button
onClick={() => navigate('/')} onClick={() => navigate('/')}
@@ -155,7 +213,9 @@ export default function SignupPage() {
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2"> <h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Create Account Create Account
</h2> </h2>
<p className="text-gray-600 dark:text-neutral-400">Join the hackathon community</p> <p className="text-gray-600 dark:text-gray-400">
Join the hackathon community
</p>
</div> </div>
{error && ( {error && (
@@ -164,103 +224,145 @@ export default function SignupPage() {
</div> </div>
)} )}
<form onSubmit={handleEmailSignup} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div> <div>
<label <label
htmlFor="fullname" htmlFor="fullname"
className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-1" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
> >
Full Name Full Name
</label> </label>
<input <input
id="fullname" id="fullname"
type="text" type="text"
value={fullname} {...register('fullname')}
onChange={(e) => setFullname(e.target.value)}
placeholder="John Doe" placeholder="John Doe"
disabled={isEmailLoading} disabled={signupMutation.isPending}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-neutral-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-neutral-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-neutral-500 disabled:bg-gray-100 dark:disabled:bg-neutral-700 disabled:cursor-not-allowed" className={`${inputBaseClass} ${
required 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>
<div> <div>
<label <label
htmlFor="email" htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-1" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
> >
Email Email
</label> </label>
<input <input
id="email" id="email"
type="email" type="email"
value={email} {...register('email')}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com" placeholder="your@email.com"
disabled={isEmailLoading} disabled={signupMutation.isPending}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-neutral-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-neutral-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-neutral-500 disabled:bg-gray-100 dark:disabled:bg-neutral-700 disabled:cursor-not-allowed" className={`${inputBaseClass} ${
required 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>
<div> <div>
<label <label
htmlFor="password" htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-1" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
> >
Password Password
</label> </label>
<div className="relative">
<input <input
id="password" id="password"
type="password" type={showPassword ? 'text' : 'password'}
value={password} {...register('password')}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
disabled={isEmailLoading} disabled={signupMutation.isPending}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-neutral-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-neutral-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-neutral-500 disabled:bg-gray-100 dark:disabled:bg-neutral-700 disabled:cursor-not-allowed" className={`${inputBaseClass} pr-12 ${
required 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>
<div> <div>
<label <label
htmlFor="confirmPassword" htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-1" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
> >
Confirm Password Confirm Password
</label> </label>
<div className="relative">
<input <input
id="confirmPassword" id="confirmPassword"
type="password" type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword} {...register('confirmPassword')}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
disabled={isEmailLoading} disabled={signupMutation.isPending}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-neutral-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-neutral-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-neutral-500 disabled:bg-gray-100 dark:disabled:bg-neutral-700 disabled:cursor-not-allowed" className={`${inputBaseClass} pr-12 ${
required 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> </div>
<button <button
type="submit" 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-neutral-900 disabled:bg-gray-400 dark:disabled:bg-neutral-600 disabled:cursor-not-allowed transition-colors cursor-pointer" 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> </button>
</form> </form>
<div className="my-6 flex items-center"> <div className="my-6 flex items-center">
<div className="flex-1 border-t border-gray-300 dark:border-neutral-600"></div> <div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
<span className="px-4 text-sm text-gray-500 dark:text-neutral-400">OR</span> <span className="px-4 text-sm text-gray-500 dark:text-gray-400">
<div className="flex-1 border-t border-gray-300 dark:border-neutral-600"></div> OR
</span>
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
</div> </div>
<button <button
onClick={handleGithubLogin} onClick={handleGithubLogin}
disabled={isGithubLoading} disabled={isGithubLoading}
type="button" type="button"
className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 dark:bg-neutral-800 border border-gray-300 dark:border-neutral-600 rounded-lg font-semibold text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 dark:focus:ring-offset-neutral-900 disabled:bg-gray-100 dark:disabled:bg-neutral-800 disabled:cursor-not-allowed transition-colors cursor-pointer" className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg font-semibold text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:bg-gray-100 dark:disabled:bg-gray-800 disabled:cursor-not-allowed transition-colors cursor-pointer"
> >
<GithubOutlined className="text-xl" /> <GithubOutlined className="text-xl" />
<span> <span>
@@ -268,20 +370,33 @@ export default function SignupPage() {
</span> </span>
</button> </button>
<div className="mt-6 text-center"> <p className="mt-3 text-xs text-center text-gray-500 dark:text-gray-500 font-sans">
<p className="text-gray-600 dark:text-neutral-400 text-sm"> Make sure your GitHub email is{' '}
Already have an account?{' '}
<a <a
href="/auth/login" 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?{' '}
<Link
to="/auth/login"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold" className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
> >
Sign in Sign in
</a> </Link>
</p> </p>
</div> </div>
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<p className="text-gray-500 dark:text-neutral-500 text-xs"> <p className="text-gray-500 dark:text-gray-500 text-xs">
By signing up, you agree to our Terms of Service and Privacy Policy By signing up, you agree to our Terms of Service and Privacy Policy
</p> </p>
</div> </div>
@@ -0,0 +1,470 @@
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 {
useCertificatePublicData,
useAuthStore,
} from '@imphnen-frontend-service/service';
import QRCode from 'qrcode';
import html2canvas from 'html2canvas';
interface DecodedCert {
teamId: string;
submissionId: string;
userId: 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]);
// Fetch certificate data using the new endpoint
const { data: certificateData, isLoading: isLoadingCertificate } =
useCertificatePublicData(decodedInfo?.userId || '', !!decodedInfo?.userId);
// Generate QR Code
useEffect(() => {
if (certId) {
// Use encodeURIComponent to properly encode the certId for the URL
const encodedCertId = encodeURIComponent(certId);
const certificateUrl = `${window.location.origin}/certificate/${encodedCertId}`;
QRCode.toDataURL(certificateUrl, {
width: 200,
margin: 1,
color: {
dark: '#000000',
light: '#ffffff',
},
})
.then(setQrCodeUrl)
.catch((err) => console.error('QR Code generation failed:', err));
}
}, [certId]);
const certificate = certificateData?.data;
const team = certificate?.team;
const submission = certificate?.submission;
const certificateUser = certificate?.user;
const isLoading = (!decodedInfo && !error) || isLoadingCertificate;
// Certificate name from the user data
const certificateName = certificateUser?.fullname;
// Check if current user is viewing their own certificate (team member)
const isTeamMember = session?.user?.id === decodedInfo?.userId;
// Dynamic font sizing: shrink by 2px if height exceeds 80px
useEffect(() => {
const adjustFontSize = (
element: HTMLElement | null,
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, certificateName]);
// Generate certificate canvas screenshot
useEffect(() => {
const generateCertificate = async () => {
if (!certificateRef.current || !team || !submission || !qrCodeUrl) return;
setIsGenerating(true);
try {
// Wait longer for fonts and images to load properly
await new Promise((resolve) => setTimeout(resolve, 1500));
const canvas = await html2canvas(certificateRef.current, {
scale: 4,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
width: 1000,
height: (1000 * 2480) / 3508,
allowTaint: true,
imageTimeout: 0,
removeContainer: true,
});
const imageUrl = canvas.toDataURL('image/png', 1.0);
setCertificateImage(imageUrl);
setShowTemplate(false);
} catch (error) {
console.error('Failed to generate certificate:', error);
} finally {
setIsGenerating(false);
}
};
generateCertificate();
}, [team, submission, qrCodeUrl]);
// 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 (!certificateUser) {
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 user 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>
{team && isTeamMember && (
<Button
variant="secondary"
onClick={() => navigate(`/teams/${team.id}/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.svg)',
backgroundSize: 'cover',
backgroundPosition: 'center',
width: '1000px',
height: `${(1000 * 2480) / 3508}px`,
}}
>
{/* Team Name - positioned in middle between "Diberikan Kepada" and "Telah Berpartisipasi" */}
<div
style={{
position: 'absolute',
top: '41%',
left: '3.5%',
width: '55%',
}}
>
<h3
ref={teamNameRef}
style={{
fontFamily: 'Poppins, sans-serif',
fontWeight: 'bold',
color: '#59bef5',
textAlign: 'left',
fontSize: '32px',
lineHeight: '1.2',
wordBreak: 'break-word',
margin: 0,
}}
>
{team?.name}
</h3>
</div>
{/* User Name - positioned below team name */}
<div
style={{
position: 'absolute',
top: '45%',
left: '3.5%',
width: '55%',
}}
>
<h3
ref={userNameRef}
style={{
fontFamily: 'Poppins, sans-serif',
fontWeight: 'bold',
color: '#59bef5',
textAlign: 'left',
fontSize: '40px',
lineHeight: '1.2',
wordBreak: 'break-word',
margin: 0,
}}
>
{certificateName || 'N/A'}
</h3>
</div>
{/* QR Code - positioned in the white box area */}
<div
style={{
position: 'absolute',
top: '33%',
right: '9.3%',
width: '190px',
height: '190px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{qrCodeUrl && (
<img
src={qrCodeUrl}
alt="Certificate QR Code"
style={{ width: '190px', height: '190px', display: 'block' }}
/>
)}
</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 */}
{isTeamMember && (
<div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
<Button
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>
{team && (
<Button
onClick={() => navigate(`/teams/${team.id}/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;
@@ -0,0 +1,605 @@
import { FC, ReactElement, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Icon } from '@iconify/react';
import { decodeWinnerCertificateId } from '../../../../utils/certificate';
import {
useAuthStore,
useMyTeams,
useTeamById,
useTeamSubmission,
useWinners,
} from '@imphnen-frontend-service/service';
import QRCode from 'qrcode';
import html2canvas from 'html2canvas';
type WinnerEntry = {
team_id: string;
rank: number;
team?: {
id: string;
name: string;
};
};
const formatOrdinalRank = (rank: number): string => {
const mod100 = rank % 100;
if (mod100 >= 11 && mod100 <= 13) return `${rank}th`;
switch (rank % 10) {
case 1:
return `${rank}st`;
case 2:
return `${rank}nd`;
case 3:
return `${rank}rd`;
default:
return `${rank}th`;
}
};
// Keep the template size aligned with the SVG native size (842x595) using an integer multiplier.
// This reduces sub-pixel scaling artifacts (blur) on thin lines when rasterizing with html2canvas.
const CERT_WIDTH = 842 * 2;
const CERT_HEIGHT = 595 * 2;
// Balance between output sharpness and file size.
// Output resolution will be (CERT_WIDTH * EXPORT_SCALE) x (CERT_HEIGHT * EXPORT_SCALE).
const EXPORT_SCALE = 2;
// The original layout was tuned around a ~1000px-wide template.
// We keep the same visual proportions by scaling fixed pixel values.
const LAYOUT_BASE_WIDTH = 1000;
const LAYOUT_SCALE = CERT_WIDTH / LAYOUT_BASE_WIDTH;
const s = (px: number) => Math.round(px * LAYOUT_SCALE);
const CertificateWinnerPage: FC = (): ReactElement => {
const { certId } = useParams<{ certId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const { data: myTeamsData } = useMyTeams();
const {
data: winnersResponse,
isLoading: isLoadingWinners,
isError: isWinnersError,
} = useWinners();
const [decodedTeamId, setDecodedTeamId] = useState<string>('');
const [error, setError] = useState<string | null>(null);
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) return;
decodeWinnerCertificateId(certId)
.then((decoded) => setDecodedTeamId(decoded.teamId))
.catch(() => setError('Invalid certificate ID'));
}, [certId]);
const winners = useMemo(
() => (winnersResponse?.data || []) as WinnerEntry[],
[winnersResponse?.data]
);
const winnerEntry = useMemo(() => {
if (!decodedTeamId) return undefined;
return winners.find((w) => w.team_id === decodedTeamId);
}, [decodedTeamId, winners]);
const rankLabel = useMemo(() => {
if (!winnerEntry?.rank) return '';
return formatOrdinalRank(winnerEntry.rank);
}, [winnerEntry?.rank]);
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
decodedTeamId,
!!decodedTeamId
);
const { data: submissionData, isLoading: isLoadingSubmission } =
useTeamSubmission(decodedTeamId, !!decodedTeamId);
const team = teamData?.data;
const submission = submissionData?.data;
const submissionName = submission?.project_name || '(Submission unavailable)';
const memberNames = useMemo(() => {
const members = team?.members || [];
return members
.map((m) => m.user?.fullname)
.filter((name): name is string => !!name);
}, [team?.members]);
const isWinnerTeam = !!winnerEntry;
const isTeamMember =
!!session?.user?.id &&
!!decodedTeamId &&
(myTeamsData?.data || []).some(
(t) => (t as { id?: string } | null | undefined)?.id === decodedTeamId
);
// Generate QR Code (public link)
useEffect(() => {
if (!certId) return;
const encodedCertId = encodeURIComponent(certId);
const certificateUrl = `${window.location.origin}/certificate/winner/${encodedCertId}`;
QRCode.toDataURL(certificateUrl, {
width: s(200),
margin: 1,
color: {
dark: '#000000',
light: '#ffffff',
},
})
.then(setQrCodeUrl)
.catch((err) => console.error('QR Code generation failed:', err));
}, [certId]);
// Generate certificate canvas screenshot
useEffect(() => {
const generateCertificate = async () => {
if (!certificateRef.current) return;
if (!team?.name) return;
if (!qrCodeUrl) return;
if (!winnerEntry?.rank) return;
if (isLoadingSubmission) return;
setIsGenerating(true);
try {
setShowTemplate(true);
await new Promise((resolve) => setTimeout(resolve, 1500));
const canvas = await html2canvas(certificateRef.current, {
scale: EXPORT_SCALE,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
width: CERT_WIDTH,
height: CERT_HEIGHT,
allowTaint: true,
imageTimeout: 0,
removeContainer: true,
});
const imageUrl = canvas.toDataURL('image/png', 1.0);
setCertificateImage(imageUrl);
setShowTemplate(false);
} catch (e) {
console.error('Failed to generate certificate:', e);
} finally {
setIsGenerating(false);
}
};
generateCertificate();
}, [
team?.name,
memberNames,
qrCodeUrl,
winnerEntry?.rank,
isLoadingSubmission,
submissionName,
]);
const handleDownloadCertificate = () => {
if (!certificateImage) return;
const link = document.createElement('a');
link.href = certificateImage;
link.download = `winner-certificate-${team?.name || 'hackathon'}.png`;
link.click();
};
const handlePrintCertificate = () => {
if (!certificateImage) return;
const printWindow = window.open('', '_blank');
if (!printWindow) return;
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">
<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 (!decodedTeamId || isLoadingTeam) {
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 (isLoadingWinners) {
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 (isWinnersError) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Unable to Load Winners
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Please try again later.
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</div>
);
}
if (!isWinnerTeam) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<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">
This team is not listed as a hackathon winner.
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</div>
);
}
if (!team?.name) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Team Not Found
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
The team associated with this certificate could not be loaded.
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
{/* Header */}
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 no-print">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Winner Certificate
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{team.name}
</p>
</div>
{isTeamMember && (
<Button
variant="secondary"
onClick={() => navigate('/dashboard')}
>
Back to Dashboard
</Button>
)}
</div>
</div>
</div>
<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_winner_cert.svg)',
backgroundSize: '100% 100%',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
width: `${CERT_WIDTH}px`,
height: `${CERT_HEIGHT}px`,
}}
>
<style>{`
#winner-members li::marker {
color: #59bef5;
}
`}</style>
{/* Team Name */}
<div
style={{
position: 'absolute',
top: '35%',
left: '3.5%',
width: '55%',
}}
>
<h3
style={{
fontFamily: 'Poppins, sans-serif',
fontWeight: 'bold',
color: '#59bef5',
textAlign: 'left',
fontSize: `${s(28)}px`,
lineHeight: '1.2',
wordBreak: 'break-word',
margin: 0,
}}
>
{team.name}
</h3>
</div>
{/* Members list */}
<div
style={{
position: 'absolute',
top: '40.5%',
left: '3.5%',
width: '55%',
}}
>
<ul
id="winner-members"
style={{
margin: 0,
fontFamily: 'Poppins, sans-serif',
fontSize: `${s(18)}px`,
lineHeight: '1.35',
color: '#59bef5',
}}
>
{(memberNames.length
? memberNames
: ['(Members unavailable)']
).map((name) => (
<li key={name}> {name}</li>
))}
</ul>
</div>
{/* Award text */}
<div
style={{
position: 'absolute',
top: '60%',
left: '3.5%',
width: '60%',
}}
>
<p
style={{
margin: 0,
fontFamily: 'Poppins, sans-serif',
fontSize: `${s(18)}px`,
lineHeight: '1.35',
color: '#6B6B6B',
}}
>
Diberikan sebagai penghargaan atas pencapaian meraih
<br />
<b>JUARA {winnerEntry.rank}</b> pada Hackathon IMPHNEN x
Kolosal.ai
<br />
<span>
dengan nama project: <b>{submissionName}</b>
</span>
</p>
</div>
{/* Rank badge */}
{!!rankLabel && (
<div
style={{
position: 'absolute',
top: '8%',
right: '8.5%',
width: `${s(190)}px`,
display: 'flex',
justifyContent: 'center',
}}
>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: `${s(6)}px ${s(16)}px`,
textAlign: 'center',
fontFamily: 'Poppins, sans-serif',
fontWeight: 700,
color: '#78350F',
fontSize: `${s(24)}px`,
lineHeight: '1',
}}
>
{rankLabel}
</div>
</div>
)}
{/* QR Code (same placement as existing certificate page) */}
<div
style={{
position: 'absolute',
top: '33%',
right: '9.3%',
width: `${s(190)}px`,
height: `${s(190)}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{qrCodeUrl && (
<img
src={qrCodeUrl}
alt="Certificate QR Code"
style={{
width: `${s(190)}px`,
height: `${s(190)}px`,
display: 'block',
}}
/>
)}
</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="Winner Certificate"
className="w-full h-auto"
style={{ maxWidth: '100%', height: 'auto' }}
/>
)}
{/* Actions */}
{isTeamMember && (
<div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
<Button
variant="secondary"
onClick={handleDownloadCertificate}
className="flex items-center gap-2"
disabled={isGenerating}
>
{isGenerating ? (
<>
<Icon
icon="svg-spinners:ring-resize"
width="18"
height="18"
/>
Generating...
</>
) : (
<>
<Icon
icon="heroicons:arrow-down-tray"
width="18"
height="18"
/>
Download
</>
)}
</Button>
<Button
variant="secondary"
onClick={handlePrintCertificate}
className="flex items-center gap-2"
disabled={isGenerating}
>
{isGenerating ? (
<>
<Icon
icon="svg-spinners:ring-resize"
width="18"
height="18"
/>
Generating...
</>
) : (
<>
<Icon icon="mdi:printer" width="18" height="18" />
Print
</>
)}
</Button>
<Button
onClick={() => navigate('/dashboard')}
variant="secondary"
className="col-span-2 flex items-center gap-2 xl:col-span-1"
>
Back to Dashboard
</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 achievement.
</p>
</div>
</div>
</div>
);
};
export default CertificateWinnerPage;
+4 -4
View File
@@ -6,18 +6,18 @@ const DashboardLayout: FC = (): ReactElement => {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
return ( return (
<div className="flex min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} /> <Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
{/* Mobile Header with Hamburger */} {/* Mobile Header with Hamburger */}
<div className="lg:hidden sticky top-0 bg-white dark:bg-neutral-900 border-b dark:border-neutral-700 px-4 py-3 flex items-center"> <div className="lg:hidden sticky top-0 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 flex items-center">
<button <button
onClick={() => setSidebarOpen(true)} onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors" className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
> >
<svg <svg
className="w-6 h-6 text-gray-600 dark:text-neutral-400" className="w-6 h-6 text-gray-600 dark:text-gray-400"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
+284 -52
View File
@@ -1,24 +1,114 @@
import { FC, ReactElement } from 'react'; import { FC, ReactElement, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router'; import { Link, useNavigate } from 'react-router';
import { import {
useMyTeams, useMyTeams,
useMyInvitations, useMyInvitations,
useRespondToInvitation, useRespondToInvitation,
useAuthStore, useAuthStore,
useWinners,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import ProfilePage from '../profile/page';
import { encodeWinnerCertificateId } from '../../utils/certificate';
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
// Submission deadline: 2025-12-07 23:59:00 WIB (UTC+7)
const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z');
type Invitation = {
id: string;
team: {
id?: string;
name?: string;
logo?: string;
banner?: string;
description?: string;
city?: string;
visibility?: string;
leader_id?: string;
};
inviter: {
id?: string;
fullname?: string;
email?: string;
avatar?: string;
};
};
const DashboardPage: FC = (): ReactElement => { const DashboardPage: FC = (): ReactElement => {
const { session } = useAuthStore(); const { session } = useAuthStore();
const navigate = useNavigate();
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) {
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow || '';
};
}
}, [showProfileModal]);
const { data: teamsData } = useMyTeams(); const { data: teamsData } = useMyTeams();
const { data: winnersResponse } = useWinners();
const { data: invitationsData } = useMyInvitations(); const { data: invitationsData } = useMyInvitations();
const { mutateAsync: respondToInvitation } = useRespondToInvitation(); const { mutateAsync: respondToInvitation } = useRespondToInvitation();
const user = session?.user; const user = session?.user;
const myTeams = teamsData?.data || []; const myTeams = teamsData?.data || [];
const invitations = invitationsData?.data || []; const invitations: Invitation[] = (invitationsData?.data ||
[]) as Invitation[];
const winnerEntry = useMemo(() => {
const team = (myTeams[0] as { id?: string } | null | undefined) || null;
const winners = winnersResponse?.data || [];
if (!team?.id) return null;
return winners.find((w) => w.team_id === team.id) || null;
}, [myTeams, winnersResponse?.data]);
const handleAcceptInvitation = async (invitationId: string) => { const handleAcceptInvitation = async (invitationId: string) => {
try { try {
@@ -41,38 +131,132 @@ const DashboardPage: FC = (): ReactElement => {
}; };
return ( return (
<div className="p-8 max-w-7xl mx-auto"> <>
<div className="p-6 md:p-8 md:max-w-7xl w-full mx-auto">
<div className="mb-8"> <div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white"> <h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Welcome, {user?.fullname || user?.email?.split('@')[0] || 'User'}! Welcome, {user?.fullname || user?.email?.split('@')[0] || 'User'}!
</h1> </h1>
{user?.location && ( {user?.location && (
<p className="text-gray-600 dark:text-neutral-400 mt-1 font-sans flex items-center space-x-1"> <p className="text-gray-600 dark:text-gray-400 mt-1 font-sans flex items-center space-x-1">
<Icon icon="heroicons:map-pin-16-solid" width="24" height="24" /> <Icon icon="heroicons:map-pin-16-solid" width="24" height="24" />
<span>{user.location}</span> <span>{user.location}</span>
</p> </p>
)} )}
</div> </div>
{/* Winner Banner */}
{winnerEntry && myTeams.length > 0 && (
<div className="mb-8 bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-lg p-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<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">
Selamat! Tim Anda meraih JUARA {winnerEntry.rank}
</h3>
<p className="text-amber-700 dark:text-amber-300 text-sm">
Anda dapat generate sertifikat penghargaan dan
membagikannya.
</p>
</div>
</div>
<button
onClick={async () => {
const team = myTeams[0] as { id?: string } | null | undefined;
if (!team?.id) return;
const certId = await encodeWinnerCertificateId(team.id);
navigate(`/certificate/winner/${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 cursor-pointer"
>
Generate Sertifikat Juara
</button>
</div>
</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 && ( {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"> <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"> <h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
Team Invitations ({invitations.length}) Team Invitations ({invitations.length})
</h2> </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"> <div className="space-y-3 font-sans">
{invitations.map((invitation) => ( {invitations.map((invitation) => (
<div <div
key={invitation.id} key={invitation.id}
className="bg-white dark:bg-neutral-800 p-4 rounded-lg shadow-sm flex items-center justify-between" className="bg-white dark:bg-gray-900 p-4 rounded-lg shadow-sm flex items-center justify-between"
> >
<div> <div>
<p className="font-medium text-gray-900 dark:text-white"> <p className="font-medium text-gray-900 dark:text-white">
{invitation.team.name} {invitation.team?.name ?? 'Unnamed Team'}
</p> </p>
<p className="text-sm text-gray-600 dark:text-neutral-400"> <p className="text-sm text-gray-600 dark:text-gray-400">
Invited by {invitation.inviter.fullname} Invited by{' '}
{invitation.inviter?.fullname ?? 'Unknown User'}
</p> </p>
</div> </div>
{!isTeamFeaturesClosed && (
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button <Button
size="sm" size="sm"
@@ -88,6 +272,7 @@ const DashboardPage: FC = (): ReactElement => {
Decline Decline
</Button> </Button>
</div> </div>
)}
</div> </div>
))} ))}
</div> </div>
@@ -95,78 +280,114 @@ const DashboardPage: FC = (): ReactElement => {
)} )}
{myTeams.length > 0 ? ( {myTeams.length > 0 ? (
(() => {
const team = myTeams[0] as any;
return (
<div className="mb-6 md:mb-8"> <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"> <h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
My Team My Team
</h2> </h2>
<div className="grid gap-4 md:gap-6 md:grid-cols-2 lg:grid-cols-3">
{myTeams.map((team) => (
<Link <Link
key={team.id}
to={'/teams/' + team.id} to={'/teams/' + team.id}
className="bg-white dark:bg-neutral-800 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 && (
<img <img
src={team.banner} src={team.banner || '/images/banner-imphnen.webp'}
alt={team.name} alt={team.name}
className="w-full h-32 object-cover" className="w-full aspect-3/1 object-cover"
/> />
)}
<div className="p-4 md:p-6"> <div className="p-4 md:p-6">
<div className="flex items-center space-x-3 mb-2 md:mb-3"> <div className="flex items-center space-x-4 mb-4">
{team.logo && ( {team.logo ? (
<img <img
src={team.logo} src={team.logo}
alt={team.name} alt={team.name}
className="w-10 h-10 md:w-12 md:h-12 rounded-full object-cover" 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> <div className="flex-1 min-w-0">
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white"> <h3 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white leading-tight line-clamp-1">
{team.name} {team.name}
</h3> </h3>
<p className="text-xs md:text-sm text-gray-600 dark:text-neutral-400"> <div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-3 font-sans mt-2">
City: {team.city} {team.has_submission && (
</p> <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> </div>
<p className="text-gray-600 dark:text-neutral-400 text-xs md:text-sm line-clamp-2"> </div>
{team.description && (
<p className="text-gray-600 dark:text-gray-400 line-clamp-3 font-sans">
{team.description} {team.description}
</p> </p>
)}
</div> </div>
</Link> </Link>
))}
</div>
</div> </div>
);
})()
) : ( ) : (
<div className="mb-6 md:mb-8 bg-white dark:bg-neutral-800 rounded-lg shadow-md p-6 md:p-8"> <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"> <div className="text-center">
<div className="text-3xl md:text-4xl mb-2 md:mb-3">👋</div> <div className="text-3xl md:text-4xl mb-2 md:mb-3">👋</div>
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-1 md:mb-2"> <h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-1 md:mb-2">
You are not in a team yet You are not in a team yet
</h2> </h2>
<p className="text-sm md:text-base text-gray-600 dark:text-neutral-400 font-sans"> <p className="text-sm md:text-base text-gray-600 dark:text-gray-400 font-sans">
Use the sidebar to browse teams or create your own Use the sidebar to browse teams or create your own
</p> </p>
</div> </div>
</div> </div>
)} )}
<div className="mt-8 bg-linear-to-br from-white-50 to-primary-50 dark:from-neutral-800 dark:to-neutral-900 rounded-xl shadow-lg"> <div className="mt-8 bg-white dark:bg-gray-900 rounded-xl shadow-lg">
<div className="bg-linear-to-r from-primary-600 to-indigo-600 h-24 rounded-t-xl"></div> <div className="bg-linear-to-r from-primary-600 to-primary-500 h-24 rounded-t-xl"></div>
<div className="px-4 md:px-8 pb-4 md:pb-8"> <div className="px-4 md:px-8 pb-4 md:pb-8 max-w-7xl">
<div className="flex flex-col md:flex-row md:items-start -mt-12 mb-4 md:mb-6"> <div className="flex flex-col md:flex-row md:items-start -mt-12 mb-4 md:mb-6">
<div className="flex flex-col md:flex-row items-start"> <div className="flex flex-col md:flex-row items-start">
{user?.avatar ? ( {user?.avatar ? (
<img <img
src={user.avatar} src={user.avatar}
alt={user.fullname || 'User'} alt={user.fullname || 'User'}
className="w-20 h-20 md:w-24 md:h-24 rounded-full border-4 border-white dark:border-neutral-700 shadow-lg object-cover" className="w-20 h-20 md:w-24 md:h-24 rounded-full border-4 border-white dark:border-gray-700 shadow-lg object-cover"
/> />
) : ( ) : (
<div className="w-20 h-20 md:w-24 md:h-24 rounded-full border-4 border-white dark:border-neutral-700 shadow-lg bg-gray-200 dark:bg-neutral-700 flex items-center justify-center"> <div className="w-20 h-20 md:w-24 md:h-24 rounded-full border-4 border-white dark:border-gray-700 shadow-lg bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-gray-400 dark:text-neutral-500 text-3xl md:text-4xl font-sans"> <span className="text-gray-400 dark:text-gray-500 text-3xl md:text-4xl font-sans">
U U
</span> </span>
</div> </div>
@@ -179,8 +400,13 @@ const DashboardPage: FC = (): ReactElement => {
</h2> </h2>
{user?.location ? ( {user?.location ? (
<p className="text-gray-600 dark:text-neutral-400 flex items-center mt-1 text-sm md:text-base font-sans"> <p className="text-gray-600 dark:text-gray-400 flex items-center mt-1 text-sm md:text-base font-sans">
{user.location} <Icon
icon="heroicons:map-pin-16-solid"
width="16"
height="16"
/>
<span className="ml-1">{user.location}</span>
</p> </p>
) : ( ) : (
<Link <Link
@@ -198,45 +424,46 @@ const DashboardPage: FC = (): ReactElement => {
</div> </div>
</div> </div>
{user?.location && ( {user?.location && (
<Link <button
to="/profile" type="button"
className="mt-3 md:mt-14 md:ml-auto inline-flex items-center justify-center px-3 py-1.5 md:px-4 md:py-2 bg-primary-500 text-white rounded-lg text md:text-sm font-medium shadow-sm hover:bg-primary-600 transition-colors w-full md:w-auto" onClick={() => setShowProfileModal(true)}
className="mt-3 md:mt-14 md:ml-auto inline-flex items-center justify-center px-3 py-1.5 md:px-4 md:py-2 bg-primary-500 text-white rounded-lg text md:text-sm font-medium shadow-sm hover:bg-primary-600 transition-colors w-full md:w-auto cursor-pointer"
> >
Edit Profile Edit Profile
</Link> </button>
)} )}
</div> </div>
<div className="space-y-4 md:space-y-6"> <div className="space-y-4 md:space-y-6 w-full max-w-full">
{user?.bio && ( {user?.bio && (
<div className="bg-white dark:bg-neutral-800 rounded-lg p-4 shadow-sm"> <div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm w-full max-w-full overflow-x-hidden">
<h3 className="text-sm font-semibold text-gray-700 dark:text-neutral-300 uppercase tracking-wide mb-2"> <h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
About About
</h3> </h3>
<p className="text-gray-800 dark:text-neutral-200 leading-relaxed line-clamp-3 wrap-break-word"> <p className="text-gray-800 dark:text-gray-200 leading-relaxed line-clamp-3 wrap-break-word break-all overflow-hidden w-full max-w-full">
{user.bio} {user.bio}
</p> </p>
</div> </div>
)} )}
<div className="bg-white dark:bg-neutral-800 rounded-lg p-4 shadow-sm"> <div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm">
<h3 className="font-semibold text-gray-700 dark:text-neutral-300 tracking-wide mb-3"> <h3 className="font-semibold text-gray-700 dark:text-gray-300 tracking-wide mb-3">
Contact Contact
</h3> </h3>
<div className="flex items-center text-gray-700 dark:text-neutral-300"> <div className="flex items-center text-gray-700 dark:text-gray-300">
<span className="text-gray-900 dark:text-white"> <span className="text-gray-900 dark:text-white">
{user?.email} {user?.email}
</span> </span>
</div> </div>
</div> </div>
{user?.skills && user.skills.length > 0 && ( {user?.skills && user.skills.length > 0 && (
<div className="bg-white dark:bg-neutral-800 rounded-lg p-4 shadow-sm"> <div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm">
<h3 className="font-semibold text-gray-700 dark:text-neutral-300 tracking-wide mb-3"> <h3 className="font-semibold text-gray-700 dark:text-gray-300 tracking-wide mb-3">
Skills Skills
</h3> </h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{user.skills.map((skill: string) => ( {user.skills.map((skill: string) => (
<span <span
key={skill} key={skill}
className="inline-flex items-center px-4 py-2 border border-primary-600 text-primary-600 rounded-4xl text-xs font-medium " className="inline-flex items-center px-4 py-2 border border-primary-600 dark:border-gray-500 text-primary-600 dark:text-white rounded-4xl text-xs font-medium dark:bg-gray-600"
> >
{skill} {skill}
</span> </span>
@@ -248,6 +475,11 @@ const DashboardPage: FC = (): ReactElement => {
</div> </div>
</div> </div>
</div> </div>
<ProfilePage
open={showProfileModal}
onClose={() => setShowProfileModal(false)}
/>
</>
); );
}; };
+24 -31
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 { useEffect, useState } from 'react';
import { supabase } from '@imphnen-frontend-service/service'; import { useAuthStore, useUserMe } from '@imphnen-frontend-service/service';
// Define onboarding routes // Define onboarding routes
const ONBOARDING_ROUTES = new Set(['/onboarding/user']); const ONBOARDING_ROUTES = new Set(['/onboarding/user']);
@@ -8,6 +13,8 @@ const ONBOARDING_ROUTES = new Set(['/onboarding/user']);
export default function RootLayout() { export default function RootLayout() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { session } = useAuthStore();
const { data: userData, isLoading: isUserLoading } = useUserMe();
const [isChecking, setIsChecking] = useState(true); const [isChecking, setIsChecking] = useState(true);
useEffect(() => { useEffect(() => {
@@ -26,24 +33,15 @@ export default function RootLayout() {
return; return;
} }
// Check Supabase session // Public auth pages - allow unauthenticated access
const { data: { session }, error } = await supabase.auth.getSession(); if (pathname.startsWith('/auth')) {
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) {
// If already authenticated and not on password reset pages, redirect to dashboard // If already authenticated and not on password reset pages, redirect to dashboard
if (session && pathname !== '/auth/reset-password') { if (session && pathname !== '/auth/reset-password') {
navigate('/dashboard', { replace: true }); navigate('/dashboard', { replace: true });
setIsChecking(false); setIsChecking(false);
return; return;
} }
// Allow unauthenticated access // Allow unauthenticated access to auth pages
setIsChecking(false); setIsChecking(false);
return; return;
} }
@@ -54,6 +52,12 @@ export default function RootLayout() {
return; return;
} }
// Certificate page - allow public access
if (pathname.startsWith('/certificate/')) {
setIsChecking(false);
return;
}
// Require authentication for all other routes // Require authentication for all other routes
if (!session) { if (!session) {
navigate('/auth/login', { replace: true }); navigate('/auth/login', { replace: true });
@@ -61,38 +65,27 @@ export default function RootLayout() {
return; return;
} }
// Check if user has completed onboarding (skip for onboarding routes) // Wait for user data to load before checking onboarding
if (!ONBOARDING_ROUTES.has(pathname)) { if (isUserLoading) {
try {
const { data: userData, error: userError } = await supabase
.from('users')
.select('location')
.eq('id', session.user.id)
.single();
if (userError) {
console.error('[Layout] Failed to fetch user data:', userError);
setIsChecking(false);
return; return;
} }
const hasLocation = !!userData?.location; // Check if user has completed onboarding (skip for onboarding routes)
if (!ONBOARDING_ROUTES.has(pathname)) {
const hasLocation = !!userData?.data?.location || !!session?.user?.location;
if (!hasLocation) { if (!hasLocation) {
navigate('/onboarding/user', { replace: true }); navigate('/onboarding/user', { replace: true });
setIsChecking(false); setIsChecking(false);
return; return;
} }
} catch (error) {
// Silently handle error
}
} }
setIsChecking(false); setIsChecking(false);
}; };
checkAuth(); checkAuth();
}, [location.pathname, navigate]); }, [location.pathname, navigate, session, userData, isUserLoading]);
// Show loading state while checking auth // Show loading state while checking auth
if (isChecking) { 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>
);
}
+28 -14
View File
@@ -60,6 +60,20 @@ const UserOnboardingPage: FC = (): ReactElement => {
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { 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); setAvatarFile(file);
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => { reader.onloadend = () => {
@@ -104,13 +118,13 @@ const UserOnboardingPage: FC = (): ReactElement => {
}); });
return ( return (
<div className="flex flex-col justify-center items-center min-h-screen bg-gray-50 dark:bg-neutral-950 px-4 py-8"> <div className="flex flex-col justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 px-4 py-8">
<div className="bg-white dark:bg-neutral-900 w-full max-w-2xl p-8 rounded-xl shadow-lg dark:shadow-neutral-950/50"> <div className="bg-white dark:bg-gray-900 w-full max-w-2xl p-8 rounded-xl shadow-lg dark:shadow-gray-950/50">
<div className="mb-6"> <div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2"> <h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Complete Your Profile Complete Your Profile
</h1> </h1>
<p className="text-gray-600 font-sans dark:text-neutral-400"> <p className="text-gray-600 font-sans dark:text-gray-400">
Tell us more about yourself to get started Tell us more about yourself to get started
</p> </p>
</div> </div>
@@ -123,15 +137,15 @@ const UserOnboardingPage: FC = (): ReactElement => {
<img <img
src={avatarPreview} src={avatarPreview}
alt="Avatar preview" alt="Avatar preview"
className="w-32 h-32 rounded-full object-cover border-4 border-gray-200 dark:border-neutral-700" className="w-32 h-32 rounded-full object-cover border-4 border-gray-200 dark:border-gray-700"
/> />
) : ( ) : (
<div className="w-32 h-32 rounded-full bg-gray-200 dark:bg-neutral-700 flex items-center justify-center"> <div className="w-32 h-32 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<Icon <Icon
icon="ic:baseline-person" icon="ic:baseline-person"
width="48" width="48"
height="48" height="48"
className="text-gray-400 dark:text-neutral-500" className="text-gray-400 dark:text-gray-500"
/> />
</div> </div>
)} )}
@@ -149,8 +163,8 @@ const UserOnboardingPage: FC = (): ReactElement => {
onChange={handleAvatarChange} onChange={handleAvatarChange}
/> />
</label> </label>
<p className="text-xs text-gray-500 dark:text-neutral-500 mt-2 text-center"> <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> </p>
</div> </div>
</div> </div>
@@ -168,7 +182,7 @@ const UserOnboardingPage: FC = (): ReactElement => {
{/* City */} {/* City */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-base font-medium text-gray-700 dark:text-neutral-300"> <label className="block text-base font-medium text-gray-700 dark:text-gray-300">
City <span className="text-red-500">*</span> City <span className="text-red-500">*</span>
</label> </label>
<Controller <Controller
@@ -187,7 +201,7 @@ const UserOnboardingPage: FC = (): ReactElement => {
{/* Role/Skills */} {/* Role/Skills */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-base font-medium text-gray-700 dark:text-neutral-300"> <label className="block text-base font-medium text-gray-700 dark:text-gray-300">
Role / Skills Role / Skills
</label> </label>
<Controller <Controller
@@ -210,9 +224,9 @@ const UserOnboardingPage: FC = (): ReactElement => {
: (field.value || []).filter((v) => v !== role); : (field.value || []).filter((v) => v !== role);
field.onChange(newValue); field.onChange(newValue);
}} }}
className="rounded border-gray-300 dark:border-neutral-600 text-blue-600 dark:text-primary-500 focus:ring-blue-500 dark:focus:ring-primary-500 dark:bg-neutral-800" className="rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-primary-500 focus:ring-blue-500 dark:focus:ring-primary-500 dark:bg-gray-800"
/> />
<span className="text-sm dark:text-neutral-300"> <span className="text-sm dark:text-gray-300">
{role} {role}
</span> </span>
</label> </label>
@@ -225,9 +239,9 @@ const UserOnboardingPage: FC = (): ReactElement => {
{/* Bio */} {/* Bio */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-base font-medium text-gray-700 dark:text-neutral-300"> <label className="block text-base font-medium text-gray-700 dark:text-gray-300">
Bio{' '} Bio{' '}
<span className="text-gray-400 dark:text-neutral-500"> <span className="text-gray-400 dark:text-gray-500">
(Optional) (Optional)
</span> </span>
</label> </label>
+69 -71
View File
@@ -1,4 +1,4 @@
import { useNavigate } from 'react-router'; import { useNavigate, Link } from 'react-router';
import { useState } from 'react'; import { useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -66,10 +66,10 @@ export default function HomePage() {
]; ];
return ( return (
<main className="min-h-screen bg-white dark:bg-neutral-950"> <main className="min-h-screen bg-white dark:bg-gray-950">
{/* Navigation */} {/* Navigation */}
<div id="#top" className="hidden"></div> <div id="#top" className="hidden"></div>
<nav className="border-b border-gray-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 sticky top-0 z-50"> <nav className="border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 sticky top-0 z-50">
<div className="flex items-center justify-between max-w-7xl mx-auto px-4 md:px-8 py-4"> <div className="flex items-center justify-between max-w-7xl mx-auto px-4 md:px-8 py-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-lg md:text-xl font-bold dark:text-white"> <span className="text-lg md:text-xl font-bold dark:text-white">
@@ -86,19 +86,19 @@ export default function HomePage() {
<div className="hidden md:flex text-label1 items-center gap-4 lg:gap-8"> <div className="hidden md:flex text-label1 items-center gap-4 lg:gap-8">
<a <a
href="#timeline" href="#timeline"
className="text-gray-600 dark:text-neutral-400 hover:text-gray-900 dark:hover:text-white transition-colors" className="text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white transition-colors"
> >
Timeline Timeline
</a> </a>
<a <a
href="#hadiah" href="#hadiah"
className="text-gray-600 dark:text-neutral-400 hover:text-gray-900 dark:hover:text-white transition-colors" className="text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white transition-colors"
> >
Hadiah Hadiah
</a> </a>
<a <a
href="#faq" href="#faq"
className="text-gray-600 dark:text-neutral-400 hover:text-gray-900 dark:hover:text-white transition-colors" className="text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white transition-colors"
> >
FAQ FAQ
</a> </a>
@@ -117,7 +117,7 @@ export default function HomePage() {
onClick={() => navigate('/auth/login')} onClick={() => navigate('/auth/login')}
size="sm" size="sm"
variant="bordered" variant="bordered"
className="rounded-lg text-base" className="rounded-lg text-base dark:bg-gray-800"
> >
Masuk Masuk
</Button> </Button>
@@ -158,23 +158,23 @@ export default function HomePage() {
{/* Mobile Menu */} {/* Mobile Menu */}
{mobileMenuOpen && ( {mobileMenuOpen && (
<nav className="md:hidden sticky top-18 border-b border-gray-200 dark:border-neutral-800 bg-white z-40 dark:bg-neutral-900"> <nav className="md:hidden sticky top-18 border-b border-gray-200 dark:border-gray-800 bg-white z-40 dark:bg-gray-900">
<div className="flex flex-col items-start gap-4 px-4 py-4"> <div className="flex flex-col items-start gap-4 px-4 py-4">
<a <a
href="#timeline" href="#timeline"
className="ms-3 text-gray-600 dark:text-neutral-400 hover:text-gray-900 dark:hover:text-white" className="ms-3 text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white"
> >
Timeline Timeline
</a> </a>
<a <a
href="#hadiah" href="#hadiah"
className="ms-3 text-gray-600 dark:text-neutral-400 hover:text-gray-900 dark:hover:text-white" className="ms-3 text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white"
> >
Hadiah Hadiah
</a> </a>
<a <a
href="#faq" href="#faq"
className="ms-3 text-gray-600 dark:text-neutral-400 hover:text-gray-900 dark:hover:text-white" className="ms-3 text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white"
> >
FAQ FAQ
</a> </a>
@@ -187,12 +187,12 @@ export default function HomePage() {
</button> </button>
) : ( ) : (
<> <>
<a <button
href="#masuk" onClick={() => navigate('/auth/login')}
className="ms-3 text-gray-600 dark:text-neutral-400 hover:text-gray-900 dark:hover:text-white" className="ms-3 text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white cursor-pointer"
> >
Masuk Masuk
</a> </button>
<button <button
onClick={() => navigate('/auth/login')} onClick={() => navigate('/auth/login')}
className="px-4 py-2 bg-primary-500 text-white text-base rounded-lg hover:bg-primary-600 transition-colors text-center cursor-pointer" className="px-4 py-2 bg-primary-500 text-white text-base rounded-lg hover:bg-primary-600 transition-colors text-center cursor-pointer"
@@ -228,7 +228,7 @@ export default function HomePage() {
className="h-12 md:h-16" className="h-12 md:h-16"
/> />
</div> </div>
<span className="text-3xl md:text-5xl font-bold text-gray-400 dark:text-neutral-500"> <span className="text-3xl md:text-5xl font-bold text-gray-400 dark:text-gray-500">
× ×
</span> </span>
<div className="flex items-center"> <div className="flex items-center">
@@ -248,23 +248,23 @@ export default function HomePage() {
"Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif" "Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif"
</p> </p>
{/* Description */} {/* Description */}
<p className="text-p3 text-gray-600 dark:text-neutral-400 max-w-lg md:max-w-xl text-center mb-8 md:mb-12 px-4 font-sans"> <p className="text-p3 text-gray-600 dark:text-gray-200 max-w-lg md:max-w-xl text-center mb-8 md:mb-12 px-4 font-sans">
Kompetisi pengembangan teknologi untuk menciptakan solusi inovatif Kompetisi pengembangan teknologi untuk menciptakan solusi inovatif
yang menghadirkan dampak nyata yang menghadirkan dampak nyata
</p> </p>
{/* Status */} {/* Status */}
<div className="flex items-center gap-4 md:gap-8 mb-10 md:mb-16 text-base text-gray-600 dark:text-neutral-400"> <div className="flex flex-col md:flex-row items-center gap-4 md:gap-8 mb-10 md:mb-16 text-base text-gray-600 dark:text-gray-200">
<div className="flex items-center gap-2 md:gap-3"> <div className="flex items-center gap-2 md:gap-3">
<Icon icon="streamline-plump:web" width="16" height="16" /> <Icon icon="streamline-plump:web" className="w-4 h-4" />
<span>Online</span> <span>Online</span>
</div> </div>
<div className="flex items-center gap-2 md:gap-3 text-center"> <div className="flex items-center gap-2 md:gap-3 text-center">
<Icon icon="heroicons:clock" width="16" height="16" /> <Icon icon="heroicons:clock" className="w-4 h-4" />
<span>Pendaftaran hingga 30 November 2025</span> <span>Pendaftaran hingga 30 November 2025</span>
</div> </div>
</div> </div>
{/* CTA Buttons */} {/* CTA Buttons */}
<div className="flex items-center gap-4 px-4"> <div className="flex flex-col md:flex-row items-center gap-4 px-4">
<Button <Button
onClick={() => navigate('/auth/signup')} onClick={() => navigate('/auth/signup')}
className="rounded-lg text-base max-h-auto" className="rounded-lg text-base max-h-auto"
@@ -275,7 +275,7 @@ export default function HomePage() {
href="https://chat.whatsapp.com/BlxrYh9uSC37d7VPhJslGL" href="https://chat.whatsapp.com/BlxrYh9uSC37d7VPhJslGL"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center justify-center px-4 py-2.5 border-2 border-gray-300 dark:border-neutral-600 hover:border-gray-400 dark:hover:border-neutral-500 transition-colors text-center bg-transparent hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-neutral-800 text-base text-gray-600 dark:text-neutral-400 rounded-lg font-bai-jamjuree font-semibold" className="inline-flex items-center justify-center px-4 py-2.5 border-2 border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500 transition-colors text-center bg-transparent hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-800 text-base text-gray-600 dark:text-gray-200 dark:bg-gray-800 rounded-lg font-bai-jamjuree font-semibold"
> >
Gabung Grup WA Hackathon Gabung Grup WA Hackathon
</a> </a>
@@ -283,7 +283,7 @@ export default function HomePage() {
{/* Scroll indicator */} {/* Scroll indicator */}
<div className="mt-12 md:mt-20"> <div className="mt-12 md:mt-20">
<svg <svg
className="w-6 h-6 text-gray-400 dark:text-neutral-500 animate-bounce" className="w-6 h-6 text-gray-400 dark:text-gray-500 animate-bounce"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -300,19 +300,19 @@ export default function HomePage() {
</section> </section>
{/* About Section */} {/* About Section */}
<section className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-neutral-950"> <section className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-gray-950">
<div className="max-w-4xl mx-auto text-center"> <div className="max-w-4xl mx-auto text-center">
<h2 className="text-3xl md:text-5xl font-bold mb-10 dark:text-white"> <h2 className="text-3xl md:text-5xl font-bold mb-10 dark:text-white">
Tentang <span className="text-primary-500">Hackathon</span> Tentang <span className="text-primary-500">Hackathon</span>
</h2> </h2>
<p className="font-sans text-lg md:text-xl text-left md:text-center text-gray-600 dark:text-neutral-400 mb-6"> <p className="font-sans text-lg md:text-xl text-left md:text-center text-gray-600 dark:text-gray-200 mb-6">
<span className="font-semibold text-gray-900 dark:text-white"> <span className="font-semibold text-gray-900 dark:text-white">
Hackathon Hackathon
</span>{' '} </span>{' '}
adalah kompetisi pengembangan teknologi yang mengajak talenta muda adalah kompetisi pengembangan teknologi yang mengajak talenta muda
untuk berkolaborasi dalam menciptakan solusi inovatif. untuk berkolaborasi dalam menciptakan solusi inovatif.
</p> </p>
<p className="font-sans text-lg md:text-xl text-left md:text-center text-gray-600 dark:text-neutral-400"> <p className="font-sans text-lg md:text-xl text-left md:text-center text-gray-600 dark:text-gray-200">
IMPHNEN bersama Kolosal.ai mengadakan Hackathon dengan tema lomba{' '} IMPHNEN bersama Kolosal.ai mengadakan Hackathon dengan tema lomba{' '}
<span className="font-semibold text-primary-500"> <span className="font-semibold text-primary-500">
"Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif" "Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif"
@@ -325,7 +325,7 @@ export default function HomePage() {
{/* Prizes Section */} {/* Prizes Section */}
<section <section
id="hadiah" id="hadiah"
className="py-16 md:py-24 px-4 md:px-8 bg-gray-50 dark:bg-neutral-900" className="py-16 md:py-24 px-4 md:px-8 bg-gray-50 dark:bg-linear-to-b dark:from-gray-950 dark:to-gray-900"
> >
<div className="max-w-lg md:max-w-6xl mx-auto"> <div className="max-w-lg md:max-w-6xl mx-auto">
<div className="text-center mb-12"> <div className="text-center mb-12">
@@ -339,7 +339,7 @@ export default function HomePage() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Prize 1 */} {/* Prize 1 */}
<div className="bg-white dark:bg-neutral-800 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-neutral-700 hover:border-primary-500 dark:hover:border-primary-500 transition-colors"> <div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-primary-500 dark:hover:border-primary-500 transition-colors">
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center"> <div className="w-16 h-16 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
<Icon <Icon
@@ -357,12 +357,12 @@ export default function HomePage() {
</div> </div>
{/* Prize 2 */} {/* Prize 2 */}
<div className="bg-white dark:bg-neutral-800 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-neutral-700 hover:border-gray-500 dark:hover:border-neutral-500 transition-colors"> <div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-gray-500 dark:hover:border-gray-500 transition-colors">
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-gray-100 dark:bg-neutral-700 rounded-full flex items-center justify-center"> <div className="w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center">
<Icon <Icon
icon="ic:round-star" icon="ic:round-star"
className="h-8 w-8 text-gray-600 dark:text-neutral-400" className="h-8 w-8 text-gray-600 dark:text-gray-200"
/> />
</div> </div>
</div> </div>
@@ -375,7 +375,7 @@ export default function HomePage() {
</div> </div>
{/* Prize 3 */} {/* Prize 3 */}
<div className="bg-white dark:bg-neutral-800 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-neutral-700 hover:border-orange-300 dark:hover:border-orange-500 transition-colors"> <div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-orange-300 dark:hover:border-orange-500 transition-colors">
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center"> <div className="w-16 h-16 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center">
<Icon <Icon
@@ -393,7 +393,7 @@ export default function HomePage() {
</div> </div>
{/* Special Prize */} {/* Special Prize */}
<div className="bg-white dark:bg-neutral-800 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-neutral-700 hover:border-purple-300 dark:hover:border-purple-500 transition-colors"> <div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-purple-300 dark:hover:border-purple-500 transition-colors">
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center"> <div className="w-16 h-16 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
<Icon <Icon
@@ -416,14 +416,14 @@ export default function HomePage() {
{/* Timeline Section */} {/* Timeline Section */}
<section <section
id="timeline" id="timeline"
className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-neutral-950" className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-gray-950"
> >
<div className="max-w-4xl mx-auto font-sans"> <div className="max-w-4xl mx-auto font-sans">
<div className="text-center mb-12 font-bai-jamjuree"> <div className="text-center mb-12 font-bai-jamjuree">
<h2 className="text-3xl md:text-5xl font-bold mb-4 dark:text-white"> <h2 className="text-3xl md:text-5xl font-bold mb-4 dark:text-white">
Timeline <span className="text-primary-500">Acara</span> Timeline <span className="text-primary-500">Acara</span>
</h2> </h2>
<p className="text-lg text-gray-600 dark:text-neutral-400"> <p className="text-lg text-gray-600 dark:text-gray-200">
Jadwal lengkap pelaksanaan hackathon Jadwal lengkap pelaksanaan hackathon
</p> </p>
</div> </div>
@@ -433,7 +433,7 @@ export default function HomePage() {
<div className="flex gap-6"> <div className="flex gap-6">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="w-4 h-4 bg-primary-500 rounded-full"></div> <div className="w-4 h-4 bg-primary-500 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300 dark:bg-neutral-700"></div> <div className="w-0.5 h-full bg-gray-300 dark:bg-gray-700"></div>
</div> </div>
<div className="flex-1 pb-8"> <div className="flex-1 pb-8">
<p className="text-primary-500 font-semibold mb-2"> <p className="text-primary-500 font-semibold mb-2">
@@ -442,7 +442,7 @@ export default function HomePage() {
<h3 className="text-xl font-bold mb-2 dark:text-white"> <h3 className="text-xl font-bold mb-2 dark:text-white">
Penutupan Registrasi Penutupan Registrasi
</h3> </h3>
<p className="text-gray-600 dark:text-neutral-400"> <p className="text-gray-600 dark:text-gray-200">
Batas akhir pendaftaran peserta hackathon. Batas akhir pendaftaran peserta hackathon.
</p> </p>
</div> </div>
@@ -452,7 +452,7 @@ export default function HomePage() {
<div className="flex gap-6"> <div className="flex gap-6">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="w-4 h-4 bg-primary-500 rounded-full"></div> <div className="w-4 h-4 bg-primary-500 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300 dark:bg-neutral-700"></div> <div className="w-0.5 h-full bg-gray-300 dark:bg-gray-700"></div>
</div> </div>
<div className="flex-1 pb-8"> <div className="flex-1 pb-8">
<p className="text-primary-500 font-semibold mb-2"> <p className="text-primary-500 font-semibold mb-2">
@@ -461,7 +461,7 @@ export default function HomePage() {
<h3 className="text-xl font-bold mb-2 dark:text-white"> <h3 className="text-xl font-bold mb-2 dark:text-white">
Technical Meeting Technical Meeting
</h3> </h3>
<p className="text-gray-600 dark:text-neutral-400"> <p className="text-gray-600 dark:text-gray-200">
Akan diadakan technical meeting terkait lomba melalui Google Akan diadakan technical meeting terkait lomba melalui Google
Meet. Stay tune di grup WA Hackathon. Meet. Stay tune di grup WA Hackathon.
</p> </p>
@@ -472,7 +472,7 @@ export default function HomePage() {
<div className="flex gap-6"> <div className="flex gap-6">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="w-4 h-4 bg-primary-500 rounded-full"></div> <div className="w-4 h-4 bg-primary-500 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300 dark:bg-neutral-700"></div> <div className="w-0.5 h-full bg-gray-300 dark:bg-gray-700"></div>
</div> </div>
<div className="flex-1 pb-8"> <div className="flex-1 pb-8">
<p className="text-primary-500 font-semibold mb-2"> <p className="text-primary-500 font-semibold mb-2">
@@ -481,7 +481,7 @@ export default function HomePage() {
<h3 className="text-xl font-bold mb-2 dark:text-white"> <h3 className="text-xl font-bold mb-2 dark:text-white">
Tahap Penyisihan Tahap Penyisihan
</h3> </h3>
<p className="text-gray-600 dark:text-neutral-400"> <p className="text-gray-600 dark:text-gray-200">
Peserta mengerjakan tantangan yang diberikan. Peserta mengerjakan tantangan yang diberikan.
</p> </p>
</div> </div>
@@ -491,7 +491,7 @@ export default function HomePage() {
<div className="flex gap-6"> <div className="flex gap-6">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="w-4 h-4 bg-primary-500 rounded-full"></div> <div className="w-4 h-4 bg-primary-500 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300 dark:bg-neutral-700"></div> <div className="w-0.5 h-full bg-gray-300 dark:bg-gray-700"></div>
</div> </div>
<div className="flex-1 pb-8"> <div className="flex-1 pb-8">
<p className="text-primary-500 font-semibold mb-2"> <p className="text-primary-500 font-semibold mb-2">
@@ -500,7 +500,7 @@ export default function HomePage() {
<h3 className="text-xl font-bold mb-2 dark:text-white"> <h3 className="text-xl font-bold mb-2 dark:text-white">
Penilaian & Webinar Penilaian & Webinar
</h3> </h3>
<p className="text-gray-600 dark:text-neutral-400"> <p className="text-gray-600 dark:text-gray-200">
Proses penilaian oleh juri dan sesi webinar. Proses penilaian oleh juri dan sesi webinar.
</p> </p>
</div> </div>
@@ -518,7 +518,7 @@ export default function HomePage() {
<h3 className="text-xl font-bold mb-2 dark:text-white"> <h3 className="text-xl font-bold mb-2 dark:text-white">
Pengumuman & Final Pengumuman & Final
</h3> </h3>
<p className="text-gray-600 dark:text-neutral-400"> <p className="text-gray-600 dark:text-gray-200">
Presentasi final dan pengumuman pemenang. Presentasi final dan pengumuman pemenang.
</p> </p>
</div> </div>
@@ -528,50 +528,48 @@ export default function HomePage() {
</section> </section>
{/* Judges Section */} {/* Judges Section */}
<section className="py-16 md:py-24 px-4 md:px-8 bg-gray-50 dark:bg-neutral-900 font-sans"> <section className="py-16 md:py-24 px-4 md:px-8 bg-gray-50 dark:bg-gray-900 font-sans">
<div className="md:max-w-6xl mx-auto"> <div className="md:max-w-6xl mx-auto">
<div className="text-center mb-12 font-bai-jamjuree"> <div className="text-center mb-12 font-bai-jamjuree">
<h2 className="text-3xl md:text-5xl font-bold mb-4 dark:text-white"> <h2 className="text-3xl md:text-5xl font-bold mb-4 dark:text-white">
Dewan <span className="text-primary-500">Juri</span> Dewan <span className="text-primary-500">Juri</span>
</h2> </h2>
<p className="text-lg text-gray-600 dark:text-neutral-400"> <p className="text-lg text-gray-600 dark:text-gray-200">
Perwakilan dari IMPHNEN dan Kolosal.ai yang akan menilai karya Perwakilan dari IMPHNEN dan Kolosal.ai yang akan menilai karya
</p> </p>
</div> </div>
<div className="w-full max-w-md md:max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="w-full max-w-md md:max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Judge 1 */} {/* Judge 1 */}
<div className="flex flex-col justify-between bg-white dark:bg-neutral-800 rounded-xl px-4 py-8 shadow-lg text-center"> <div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
<h3 className="text-p3 font-bold mb-1 dark:text-white"> <h3 className="text-p3 font-bold mb-1 dark:text-white">
Alifais Farrel Ramdhani Alifais Farrel Ramdhani
</h3> </h3>
<div> <div>
<p className="text-primary-500 font-semibold mb-1">CTO</p> <p className="text-primary-500 font-semibold mb-1">CTO</p>
<p className="text-gray-600 dark:text-neutral-400"> <p className="text-gray-600 dark:text-gray-200">Kolosal.ai</p>
Kolosal.ai
</p>
</div> </div>
</div> </div>
{/* Judge 2 */} {/* Judge 2 */}
<div className="flex flex-col justify-between bg-white dark:bg-neutral-800 rounded-xl px-4 py-8 shadow-lg text-center"> <div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
<h3 className="text-p3 font-bold mb-1 dark:text-white"> <h3 className="text-p3 font-bold mb-1 dark:text-white">
Anka Tama Muhammad Alif Ramadhan
</h3> </h3>
<div> <div>
<p className="text-primary-500 font-semibold mb-1">Admin</p> <p className="text-primary-500 font-semibold mb-1">Admin</p>
<p className="text-gray-600 dark:text-neutral-400">IMPHNEN</p> <p className="text-gray-600 dark:text-gray-200">IMPHNEN</p>
</div> </div>
</div> </div>
{/* Judge 3 */} {/* Judge 3 */}
<div className="flex flex-col justify-between bg-white dark:bg-neutral-800 rounded-xl px-4 py-8 shadow-lg text-center"> <div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
<h3 className="text-p3 font-bold mb-1 dark:text-white"> <h3 className="text-p3 font-bold mb-1 dark:text-white">
Hafid Nur Hafid Nur
</h3> </h3>
<div> <div>
<p className="text-primary-500 font-semibold mb-1">Moderator</p> <p className="text-primary-500 font-semibold mb-1">Moderator</p>
<p className="text-gray-600 dark:text-neutral-400">IMPHNEN</p> <p className="text-gray-600 dark:text-gray-200">IMPHNEN</p>
</div> </div>
</div> </div>
</div> </div>
@@ -581,14 +579,14 @@ export default function HomePage() {
{/* FAQ Section */} {/* FAQ Section */}
<section <section
id="faq" id="faq"
className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-neutral-950" className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-gray-950"
> >
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<div className="text-center mb-12"> <div className="text-center mb-12">
<h2 className="text-3xl md:text-5xl font-bold mb-4 text-primary-500"> <h2 className="text-3xl md:text-5xl font-bold mb-4 text-primary-500">
FAQ FAQ
</h2> </h2>
<p className="text-lg text-gray-600 dark:text-neutral-400"> <p className="text-lg text-gray-600 dark:text-gray-200">
Pertanyaan yang Sering Diajukan Pertanyaan yang Sering Diajukan
</p> </p>
</div> </div>
@@ -597,11 +595,11 @@ export default function HomePage() {
{faqs.map((faq, index) => ( {faqs.map((faq, index) => (
<div <div
key={index} key={index}
className="border border-gray-200 dark:border-neutral-700 rounded-lg" className="border border-gray-200 dark:border-gray-700 rounded-lg dark:bg-gray-900"
> >
<button <button
onClick={() => setOpenFaq(openFaq === index ? null : index)} onClick={() => setOpenFaq(openFaq === index ? null : index)}
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 dark:hover:bg-neutral-800 transition-colors cursor-pointer" className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
> >
<span className="font-semibold text-gray-900 dark:text-white"> <span className="font-semibold text-gray-900 dark:text-white">
{faq.question} {faq.question}
@@ -623,7 +621,7 @@ export default function HomePage() {
</svg> </svg>
</button> </button>
{openFaq === index && ( {openFaq === index && (
<div className="px-6 pb-4 text-gray-600 dark:text-neutral-400"> <div className="px-6 pb-4 text-gray-600 dark:text-gray-200">
{faq.answer} {faq.answer}
</div> </div>
)} )}
@@ -634,12 +632,12 @@ export default function HomePage() {
</section> </section>
{/* Sponsors Section */} {/* Sponsors Section */}
<section className="py-20 md:py-28 px-4 md:px-8 bg-gray-50 dark:bg-neutral-900"> <section className="py-20 md:py-28 px-4 md:px-8 bg-gray-50 dark:bg-gray-900">
<div className="max-w-6xl mx-auto text-center"> <div className="max-w-6xl mx-auto text-center">
<h2 className="text-3xl md:text-5xl font-bold mb-4 dark:text-white"> <h2 className="text-3xl md:text-5xl font-bold mb-4 dark:text-white">
Sponsor & <span className="text-primary-500">Partner</span> Sponsor & <span className="text-primary-500">Partner</span>
</h2> </h2>
<p className="text-lg text-gray-600 dark:text-neutral-400 mb-8"> <p className="text-lg text-gray-600 dark:text-gray-200 mb-8">
Acara ini sepenuhnya disponsori oleh Acara ini sepenuhnya disponsori oleh
</p> </p>
@@ -648,7 +646,7 @@ export default function HomePage() {
href="https://kolosal.ai" href="https://kolosal.ai"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="bg-white dark:bg-neutral-800 rounded-xl p-8 shadow-lg inline-block border-2 border-transparent hover:border-gray-500 dark:hover:border-neutral-500 transition-colors" className="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-lg inline-block border-2 border-transparent hover:border-gray-500 dark:hover:border-gray-500 transition-colors"
> >
<img <img
src="images/sponsors/kolosal-logo_rlxbck.svg" src="images/sponsors/kolosal-logo_rlxbck.svg"
@@ -663,13 +661,13 @@ export default function HomePage() {
{/* CTA Section */} {/* CTA Section */}
<section <section
id="masuk" id="masuk"
className="py-20 md:py-28 px-4 md:px-8 bg-linear-to-b from-white to-blue-50 dark:from-neutral-950 dark:to-neutral-900" className="py-20 md:py-28 px-4 md:px-8 bg-linear-to-b from-white to-blue-50 dark:from-gray-950 dark:to-gray-900"
> >
<div className="max-w-4xl mx-auto text-center font-sans"> <div className="max-w-4xl mx-auto text-center font-sans">
<h2 className="text-3xl md:text-5xl font-bold mb-6 font-bai-jamjuree dark:text-white"> <h2 className="text-3xl md:text-5xl font-bold mb-6 font-bai-jamjuree dark:text-white">
Segera Daftarkan <span className="text-primary-500">Timmu!</span> Segera Daftarkan <span className="text-primary-500">Timmu!</span>
</h2> </h2>
<p className="text-lg md:text-xl text-left md:text-center text-gray-600 dark:text-neutral-400 mb-8"> <p className="text-lg md:text-xl text-left md:text-center text-gray-600 dark:text-gray-200 mb-8">
Jangan lewatkan kesempatan emas untuk bersaing dengan developer Jangan lewatkan kesempatan emas untuk bersaing dengan developer
terbaik, terbaik,
<br className="hidden md:block" /> belajar dari para ahli, dan <br className="hidden md:block" /> belajar dari para ahli, dan
@@ -687,13 +685,13 @@ export default function HomePage() {
href="https://chat.whatsapp.com/BlxrYh9uSC37d7VPhJslGL" href="https://chat.whatsapp.com/BlxrYh9uSC37d7VPhJslGL"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="px-8 py-3 bg-white dark:bg-neutral-800 text-gray-900 dark:text-white text-lg rounded-lg border-2 border-gray-300 dark:border-neutral-600 hover:border-gray-400 dark:hover:border-neutral-500 transition-colors font-semibold" className="px-8 py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-lg rounded-lg border-2 border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500 transition-colors font-semibold"
> >
Gabung Grup WA Hackathon Gabung Grup WA Hackathon
</a> </a>
</div> </div>
<p className="text-sm text-gray-500 dark:text-neutral-500 mt-6"> <p className="text-sm text-gray-500 dark:text-gray-500 mt-6">
Pendaftaran ditutup pada{' '} Pendaftaran ditutup pada{' '}
<span className="text-primary-500 font-semibold"> <span className="text-primary-500 font-semibold">
30 November 2025 30 November 2025
@@ -703,7 +701,7 @@ export default function HomePage() {
</section> </section>
{/* Footer */} {/* Footer */}
<footer className="bg-gray-900 text-white py-12 px-4 md:px-8 font-sans"> <footer className="bg-gray-950 text-white py-12 px-4 md:px-8 font-sans">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
{/* Brand */} {/* Brand */}
@@ -798,12 +796,12 @@ export default function HomePage() {
</a> </a>
</li> </li>
<li> <li>
<a <Link
href="/auth/signup" to="/auth/signup"
className="hover:text-white transition-colors" className="hover:text-white transition-colors"
> >
Daftar Daftar
</a> </Link>
</li> </li>
</ul> </ul>
</div> </div>
+36 -24
View File
@@ -1,7 +1,6 @@
import { FC, ReactElement, useState, useEffect } from 'react'; import { FC, ReactElement, useState, useEffect } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms'; import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms'; import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, Link } from 'react-router';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { import {
userEditProfileSchema, userEditProfileSchema,
@@ -26,8 +25,15 @@ const ROLE_OPTIONS = [
'Mobile Developer', 'Mobile Developer',
]; ];
const ProfilePage: FC = (): ReactElement => { type ProfileModalProps = {
const navigate = useNavigate(); open: boolean;
onClose: () => void;
};
const ProfilePage: FC<ProfileModalProps> = ({
open,
onClose,
}): ReactElement | null => {
const [avatarFile, setAvatarFile] = useState<File | null>(null); const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string>(''); const [avatarPreview, setAvatarPreview] = useState<string>('');
@@ -78,15 +84,17 @@ const ProfilePage: FC = (): ReactElement => {
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
// Validate file size (max 5MB) // Validate file size (max 2MB)
if (file.size > 5 * 1024 * 1024) { if (file.size > 2 * 1024 * 1024) {
toast.error('The file is too large. Maximum 5MB'); toast.error('The file is too large. Maximum size is 2MB.');
e.target.value = '';
return; return;
} }
// Validate file type // Validate file type
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
toast.error('The file must be an image'); toast.error('The file must be an image');
e.target.value = '';
return; return;
} }
@@ -123,8 +131,8 @@ const ProfilePage: FC = (): ReactElement => {
// Wait a bit for the onSuccess handler to update localStorage // Wait a bit for the onSuccess handler to update localStorage
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
// Navigate back to dashboard // Close modal
navigate('/dashboard'); onClose();
} catch (error) { } catch (error) {
console.error('Profile update failed:', error); console.error('Profile update failed:', error);
toast.error( toast.error(
@@ -137,17 +145,20 @@ const ProfilePage: FC = (): ReactElement => {
const isLoading = isUpdating || isUploading; const isLoading = isUpdating || isUploading;
return ( return open ? (
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex flex-col justify-center items-center px-4 py-8 z-50 overflow-y-auto"> <div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-md z-50 overflow-y-auto">
<div className="bg-white dark:bg-neutral-900 w-full max-w-md p-8 rounded-xl shadow-xl dark:shadow-neutral-950/50"> <div className="min-h-full flex items-center justify-center">
<div className="bg-white dark:bg-gray-900 w-full max-w-md mx-4 my-6 sm:my-8 p-8 rounded-xl border dark:boder-gray-800">
<div className="mb-6"> <div className="mb-6">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white"> <h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Edit Profile Edit Profile
</h1> </h1>
<Link <button
to="/dashboard" type="button"
className="text-gray-500 dark:text-neutral-400 hover:text-gray-700 dark:hover:text-neutral-200" onClick={onClose}
className="text-gray-500 dark:text-neutral-400 hover:text-gray-700 dark:hover:text-neutral-200 cursor-pointer"
aria-label="Close profile modal"
> >
<svg <svg
className="w-6 h-6" className="w-6 h-6"
@@ -162,7 +173,7 @@ const ProfilePage: FC = (): ReactElement => {
d="M6 18L18 6M6 6l12 12" d="M6 18L18 6M6 6l12 12"
/> />
</svg> </svg>
</Link> </button>
</div> </div>
<p className="text-gray-600 font-sans dark:text-neutral-400"> <p className="text-gray-600 font-sans dark:text-neutral-400">
Update your photo and name Update your photo and name
@@ -177,10 +188,10 @@ const ProfilePage: FC = (): ReactElement => {
<img <img
src={avatarPreview} src={avatarPreview}
alt="Avatar preview" alt="Avatar preview"
className="w-32 h-32 rounded-full object-cover border-4 border-gray-200 dark:border-neutral-700 group-hover:border-blue-400 dark:group-hover:border-blue-500 transition-colors" className="w-24 h-24 rounded-full object-cover border-4 border-gray-200 dark:border-neutral-700 group-hover:border-blue-400 dark:group-hover:border-blue-500 transition-colors"
/> />
) : ( ) : (
<div className="w-32 h-32 rounded-full bg-gray-200 dark:bg-neutral-700 flex items-center justify-center group-hover:bg-gray-300 dark:group-hover:bg-neutral-600 transition-colors"> <div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center group-hover:bg-gray-300 dark:group-hover:bg-gray-600 transition-colors">
<Icon <Icon
icon="ic:baseline-person" icon="ic:baseline-person"
width="48" width="48"
@@ -226,7 +237,7 @@ const ProfilePage: FC = (): ReactElement => {
<p className="text-sm text-gray-500 dark:text-neutral-400 text-center font-sans"> <p className="text-sm text-gray-500 dark:text-neutral-400 text-center font-sans">
Click the camera icon to change your photo Click the camera icon to change your photo
<br /> <br />
Format: JPG, PNG. Max 5MB Format: JPG, PNG. Max 2MB
</p> </p>
</div> </div>
@@ -241,7 +252,7 @@ const ProfilePage: FC = (): ReactElement => {
{/* City */} {/* City */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300"> <label className="block text-label1 font-medium text-neutral-800 dark:text-neutral-300">
City City
</label> </label>
<Controller <Controller
@@ -249,7 +260,7 @@ const ProfilePage: FC = (): ReactElement => {
name="location" name="location"
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<CitySelect <CitySelect
value={field.value} value={field.value ?? ''}
onChange={field.onChange} onChange={field.onChange}
error={fieldState.error?.message} error={fieldState.error?.message}
placeholder="Search your city..." placeholder="Search your city..."
@@ -283,7 +294,7 @@ const ProfilePage: FC = (): ReactElement => {
: (field.value || []).filter((v) => v !== role); : (field.value || []).filter((v) => v !== role);
field.onChange(newValue); field.onChange(newValue);
}} }}
className="rounded border-gray-300 dark:border-neutral-600 text-blue-600 focus:ring-blue-500 dark:bg-neutral-800" className="rounded border-gray-300 dark:border-neutral-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-800"
/> />
<span className="text-sm dark:text-neutral-300"> <span className="text-sm dark:text-neutral-300">
{role} {role}
@@ -313,7 +324,7 @@ const ProfilePage: FC = (): ReactElement => {
{...field} {...field}
placeholder="Tell us about yourself..." placeholder="Tell us about yourself..."
rows={4} rows={4}
className="w-full" className="w-full font-sans"
size="lg" size="lg"
/> />
{fieldState.error && ( {fieldState.error && (
@@ -332,7 +343,7 @@ const ProfilePage: FC = (): ReactElement => {
type="button" type="button"
variant="secondary" variant="secondary"
className="flex-1" className="flex-1"
onClick={() => navigate('/dashboard')} onClick={onClose}
disabled={isLoading} disabled={isLoading}
> >
Cancel Cancel
@@ -373,7 +384,8 @@ const ProfilePage: FC = (): ReactElement => {
</form> </form>
</div> </div>
</div> </div>
); </div>
) : null;
}; };
export default ProfilePage; export default ProfilePage;
@@ -1,8 +1,15 @@
import { FC, ReactElement, useState, useRef, useEffect } from 'react'; import { FC, ReactElement, useState, useRef, useEffect, useMemo } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router'; import { useNavigate, useParams } from 'react-router';
import { useTeamById, useTeamMessages, useSendMessage, useDeleteMessage, useAuthStore } from '@imphnen-frontend-service/service'; import {
useTeamById,
useTeamMessages,
useSendMessage,
useDeleteMessage,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Icon } from '@iconify/react';
const TeamChatPage: FC = (): ReactElement => { const TeamChatPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>(); const { teamId } = useParams<{ teamId: string }>();
@@ -13,16 +20,54 @@ const TeamChatPage: FC = (): ReactElement => {
const { data: teamData } = useTeamById(teamId || ''); const { data: teamData } = useTeamById(teamId || '');
const { data: messages, isLoading } = useTeamMessages(teamId || ''); const { data: messages, isLoading } = useTeamMessages(teamId || '');
const { mutateAsync: sendMessage, isPending: isSending } = useSendMessage(teamId || ''); const { mutateAsync: sendMessage, isPending: isSending } = useSendMessage(
teamId || ''
);
const { mutateAsync: deleteMessage } = useDeleteMessage(teamId || ''); const { mutateAsync: deleteMessage } = useDeleteMessage(teamId || '');
const team = teamData?.data; const team = teamData?.data;
const currentUserId = session?.user?.id; const currentUserId = session?.user?.id;
// Message type used for UI rendering
interface ChatMessage {
id: string;
user_id: string;
user?: {
avatar?: string;
fullname?: string;
};
message: string;
created_at: string;
}
// Inline delete confirmation UI state
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
// Prepare messages: de-duplicate by id and sort by created_at
const displayMessages = useMemo(() => {
const raw: ChatMessage[] = Array.isArray(messages)
? (messages as ChatMessage[])
: [];
const seen = new Set<string>();
const dedup: ChatMessage[] = [];
for (const m of raw) {
const key = m.id ?? `${m.user_id}-${m.created_at}`;
if (!seen.has(key)) {
seen.add(key);
dedup.push(m);
}
}
dedup.sort(
(a, b) =>
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
return dedup;
}, [messages]);
// Auto-scroll to bottom when new messages arrive // Auto-scroll to bottom when new messages arrive
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]); }, [displayMessages]);
const handleSendMessage = async (e: React.FormEvent) => { const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -38,11 +83,10 @@ const TeamChatPage: FC = (): ReactElement => {
}; };
const handleDeleteMessage = async (messageId: string) => { const handleDeleteMessage = async (messageId: string) => {
if (!confirm('Are you sure you want to delete this message?')) return;
try { try {
await deleteMessage(messageId); await deleteMessage(messageId);
toast.success('Message deleted'); toast.success('Message deleted');
setDeleteTargetId(null);
} catch (error) { } catch (error) {
console.error('Failed to delete message:', error); console.error('Failed to delete message:', error);
toast.error('Failed to delete message'); toast.error('Failed to delete message');
@@ -63,16 +107,23 @@ const TeamChatPage: FC = (): ReactElement => {
}; };
return ( return (
<div className="flex flex-col h-screen bg-gray-50 dark:bg-neutral-950"> <div className="flex flex-col h-screen bg-gray-50 dark:bg-gray-950">
{/* Header */} {/* Header */}
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700 shadow-sm dark:shadow-neutral-950/50"> <div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 shadow-sm dark:shadow-gray-950/50">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Team Chat</h1> <h1 className="text-2xl font-bold text-gray-900 dark:text-white">
<p className="text-gray-600 dark:text-neutral-400 text-sm mt-0.5">{team?.name}</p> Team Chat
</h1>
<p className="text-gray-600 dark:text-gray-400 text-sm mt-0.5">
{team?.name}
</p>
</div> </div>
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}> <Button
variant="secondary"
onClick={() => navigate(`/teams/${teamId}`)}
>
Back to Team Back to Team
</Button> </Button>
</div> </div>
@@ -86,21 +137,27 @@ const TeamChatPage: FC = (): ReactElement => {
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 dark:border-primary-400"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 dark:border-primary-400"></div>
</div> </div>
) : messages && messages.length > 0 ? ( ) : displayMessages && displayMessages.length > 0 ? (
<div className="space-y-4"> <div className="space-y-4">
{messages.map((msg) => { {displayMessages.map((msg) => {
const isOwnMessage = msg.user_id === currentUserId; const isOwnMessage = msg.user_id === currentUserId;
const isLeader = team?.leader_id === currentUserId; const isLeader = team?.leader_id === currentUserId;
const canDelete = isOwnMessage || isLeader; const canDelete = isOwnMessage || isLeader;
return ( return (
<div <div
key={msg.id} key={`${msg.id ?? 'message'}-${msg.created_at}`}
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`} className={`flex ${
isOwnMessage ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`flex gap-3 max-w-lg ${
isOwnMessage ? 'flex-row-reverse' : 'flex-row'
}`}
> >
<div className={`flex gap-3 max-w-lg ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}>
{/* Avatar */} {/* Avatar */}
<div className="flex-shrink-0"> <div className="shrink-0">
{msg.user?.avatar ? ( {msg.user?.avatar ? (
<img <img
src={msg.user.avatar} src={msg.user.avatar}
@@ -115,13 +172,17 @@ const TeamChatPage: FC = (): ReactElement => {
</div> </div>
{/* Message Bubble */} {/* Message Bubble */}
<div className={`flex-1 ${isOwnMessage ? 'text-right' : 'text-left'}`}> <div className={`flex-1 text-left`}>
<div className={`inline-block ${isOwnMessage ? 'items-end' : 'items-start'}`}> <div
className={`inline-block ${
isOwnMessage ? 'items-end' : 'items-start'
}`}
>
<div className="flex items-baseline gap-2 mb-1"> <div className="flex items-baseline gap-2 mb-1">
<span className="font-semibold text-sm text-gray-900 dark:text-white"> <span className="font-semibold text-sm text-gray-900 dark:text-white">
{isOwnMessage ? 'You' : msg.user?.fullname} {isOwnMessage ? 'You' : msg.user?.fullname}
</span> </span>
<span className="text-xs text-gray-500 dark:text-neutral-500"> <span className="text-xs text-gray-500 dark:text-gray-500">
{formatTime(msg.created_at)} {formatTime(msg.created_at)}
</span> </span>
</div> </div>
@@ -129,24 +190,58 @@ const TeamChatPage: FC = (): ReactElement => {
className={`relative group rounded-2xl px-4 py-2.5 ${ className={`relative group rounded-2xl px-4 py-2.5 ${
isOwnMessage isOwnMessage
? 'bg-blue-600 dark:bg-primary-600 text-white' ? 'bg-blue-600 dark:bg-primary-600 text-white'
: 'bg-white dark:bg-neutral-800 text-gray-900 dark:text-white border border-gray-200 dark:border-neutral-700' : 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white border border-gray-200 dark:border-gray-700'
}`} }`}
> >
<p className="text-sm whitespace-pre-wrap break-words">{msg.message}</p> <p className="text-sm whitespace-pre-wrap wrap-break-word font-sans">
{msg.message}
</p>
{/* Delete button */} {/* Delete button */}
{canDelete && ( {canDelete && (
<button <button
onClick={() => handleDeleteMessage(msg.id)} onClick={() =>
className={`absolute top-1 ${isOwnMessage ? 'left-1' : 'right-1'} opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-gray-200 dark:hover:bg-neutral-700 ${isOwnMessage ? 'hover:bg-blue-700 dark:hover:bg-primary-700' : ''}`} setDeleteTargetId(
deleteTargetId === msg.id ? null : msg.id
)
}
className={`absolute top-1 ${
isOwnMessage ? 'left-1' : 'right-1'
} opacity-0 hover:opacity-100 p-1 rounded hover:bg-gray-200 dark:hover:bg-neutral-700 cursor-pointer ${
isOwnMessage
? 'hover:bg-blue-700 dark:hover:bg-primary-700'
: ''
}`}
title="Delete message" title="Delete message"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {/* Icon trash */}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> <Icon
</svg> icon="mdi:trash-can-outline"
className="w-4 h-4"
/>
</button> </button>
)} )}
</div> </div>
{canDelete && deleteTargetId === msg.id && (
<div
className={`mt-2 flex gap-2 ${
isOwnMessage ? 'justify-end' : 'justify-start'
}`}
>
<button
onClick={() => handleDeleteMessage(msg.id)}
className="px-2 py-1 text-xs rounded bg-red-600 text-white hover:bg-red-700 cursor-pointer"
>
Delete
</button>
<button
onClick={() => setDeleteTargetId(null)}
className="px-2 py-1 text-xs rounded bg-gray-200 text-gray-800 hover:bg-gray-300 dark:bg-neutral-700 dark:text-white dark:hover:bg-neutral-600 cursor-pointer"
>
Cancel
</button>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -157,11 +252,14 @@ const TeamChatPage: FC = (): ReactElement => {
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center h-64 text-center"> <div className="flex flex-col items-center justify-center h-64 text-center">
<div className="text-6xl mb-4">💬</div> <Icon
icon="mdi:chat-outline"
className="text-6xl mb-4 text-gray-400"
/>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2"> <h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
No messages yet No messages yet
</h3> </h3>
<p className="text-gray-600 dark:text-neutral-400"> <p className="text-gray-600 dark:text-gray-400 font-sans">
Be the first to start the conversation! Be the first to start the conversation!
</p> </p>
</div> </div>
@@ -170,7 +268,7 @@ const TeamChatPage: FC = (): ReactElement => {
</div> </div>
{/* Message Input */} {/* Message Input */}
<div className="bg-white dark:bg-neutral-900 border-t dark:border-neutral-700 shadow-lg dark:shadow-neutral-950/50"> <div className="bg-white dark:bg-gray-900 border-t dark:border-gray-700 shadow-lg dark:shadow-gray-950/50">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<form onSubmit={handleSendMessage} className="flex gap-3"> <form onSubmit={handleSendMessage} className="flex gap-3">
<input <input
@@ -178,7 +276,7 @@ const TeamChatPage: FC = (): ReactElement => {
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..." placeholder="Type your message..."
className="flex-1 px-4 py-3 border border-gray-300 dark:border-neutral-600 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-primary-500 focus:border-transparent" className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-primary-500 focus:border-transparent"
disabled={isSending} disabled={isSending}
/> />
<Button <Button
@@ -193,8 +291,18 @@ const TeamChatPage: FC = (): ReactElement => {
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" /> className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg> </svg>
Send Send
</div> </div>
@@ -3,10 +3,22 @@ import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms'; import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router'; import { useNavigate, useParams } from 'react-router';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { teamUpdateSchema, TTeamUpdateForm, useUpdateTeam, useTeamById, ETeamVisibility, useUploadFile, useAuthStore } from '@imphnen-frontend-service/service'; import {
teamUpdateSchema,
TTeamUpdateForm,
useUpdateTeam,
useTeamById,
ETeamVisibility,
useUploadFile,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { CitySelect } from '../../../../components/city-select'; 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 EditTeamPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>(); const { teamId } = useParams<{ teamId: string }>();
@@ -17,8 +29,12 @@ const EditTeamPage: FC = (): ReactElement => {
const [bannerFile, setBannerFile] = useState<File | null>(null); const [bannerFile, setBannerFile] = useState<File | null>(null);
const [bannerPreview, setBannerPreview] = useState<string>(''); const [bannerPreview, setBannerPreview] = useState<string>('');
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(teamId || ''); const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
const { mutateAsync: updateTeam, isPending: isUpdating } = useUpdateTeam(teamId || ''); teamId || ''
);
const { mutateAsync: updateTeam, isPending: isUpdating } = useUpdateTeam(
teamId || ''
);
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const team = teamData?.data; const team = teamData?.data;
@@ -47,18 +63,24 @@ const EditTeamPage: FC = (): ReactElement => {
if (isLoadingTeam) { if (isLoadingTeam) {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-gray-600 dark:text-neutral-400">Loading team...</div> <div className="text-gray-600 dark:text-gray-400">Loading team...</div>
</div> </div>
); );
} }
if (!isLeader) { if (!isLeader) {
return ( 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">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Access Denied</h2> <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
<p className="text-gray-600 dark:text-neutral-400 mb-4">Only the team leader can edit team information</p> Access Denied
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button> </h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Only the team leader can edit team information
</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div> </div>
); );
} }
@@ -66,6 +88,11 @@ const EditTeamPage: FC = (): ReactElement => {
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { 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); setLogoFile(file);
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => { reader.onloadend = () => {
@@ -78,6 +105,11 @@ const EditTeamPage: FC = (): ReactElement => {
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { 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); setBannerFile(file);
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => { reader.onloadend = () => {
@@ -115,28 +147,35 @@ const EditTeamPage: FC = (): ReactElement => {
}); });
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700"> <div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Edit Team Info</h1> <h1 className="text-3xl font-bold text-gray-900 dark:text-white">
<p className="text-gray-600 dark:text-neutral-400 mt-1">Update your team details</p> Edit Team Info
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Update your team details
</p>
</div> </div>
</div> </div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-950/50 p-8"> <div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-8">
<form onSubmit={onSubmit} className="space-y-6"> <form onSubmit={onSubmit} className="space-y-6">
{/* Banner Upload */} {/* Banner Upload */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-2"> <label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2">
Team Banner Team Banner
</label> </label>
{bannerPreview ? ( {bannerPreview ? (
<div className="relative"> <div
className="relative w-full"
style={{ aspectRatio: '3 / 1' }}
>
<img <img
src={bannerPreview} src={bannerPreview}
alt="Banner preview" alt="Banner preview"
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-neutral-700" className="absolute inset-0 w-full h-full object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700"
/> />
<button <button
type="button" type="button"
@@ -145,16 +184,20 @@ const EditTeamPage: FC = (): ReactElement => {
setBannerPreview(''); setBannerPreview('');
form.setValue('banner', null); form.setValue('banner', null);
}} }}
className="absolute top-2 right-2 bg-red-500 text-white px-3 py-1 rounded-lg text-sm hover:bg-red-600" className="absolute top-2 right-2 bg-danger-600 text-white px-3 py-1 rounded-lg text-sm hover:bg-danger-700 cursor-pointer"
> >
Remove Remove
</button> </button>
</div> </div>
) : ( ) : (
<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"> <label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800">
<div className="text-center"> <div className="text-center">
<p className="text-gray-500 dark:text-neutral-400">Click to upload banner</p> <p className="text-gray-500 dark:text-gray-400">
<p className="text-xs text-gray-400 dark:text-neutral-500 mt-1">1200x400 recommended</p> Click to upload banner
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
1200x400 recommended. Max 2MB
</p>
</div> </div>
<input <input
type="file" type="file"
@@ -168,7 +211,7 @@ const EditTeamPage: FC = (): ReactElement => {
{/* Logo Upload */} {/* Logo Upload */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-2"> <label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2">
Team Logo Team Logo
</label> </label>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
@@ -176,16 +219,19 @@ const EditTeamPage: FC = (): ReactElement => {
<img <img
src={logoPreview} src={logoPreview}
alt="Logo preview" alt="Logo preview"
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200 dark:border-neutral-700" className="w-24 h-24 rounded-full object-cover border-2 border-gray-200 dark:border-gray-700"
/> />
) : ( ) : (
<div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-neutral-700 flex items-center justify-center"> <div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-gray-400 dark:text-neutral-500 text-3xl">👥</span> <span className="text-gray-400 dark:text-gray-500 text-3xl">
<Icon icon="mdi:account-group" />
</span>
</div> </div>
)} )}
<div> <div>
<div className="flex flex-col md:flex-row items-start justify-start gap-1">
<label htmlFor="logo" className="cursor-pointer"> <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"> <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'} {logoPreview ? 'Change Logo' : 'Upload Logo'}
</span> </span>
<input <input
@@ -204,12 +250,14 @@ const EditTeamPage: FC = (): ReactElement => {
setLogoPreview(''); setLogoPreview('');
form.setValue('logo', null); form.setValue('logo', null);
}} }}
className="ml-3 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600" className="px-4 py-2 text-sm bg-danger-600 text-white rounded-lg hover:bg-danger-700 cursor-pointer"
> >
Remove Remove
</button> </button>
)} )}
</div> </div>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">Max 2MB</p>
</div>
</div> </div>
</div> </div>
@@ -219,11 +267,13 @@ const EditTeamPage: FC = (): ReactElement => {
label="Team Name" label="Team Name"
placeholder="Enter team name" placeholder="Enter team name"
name="name" name="name"
size="lg"
isRequired={true}
/> />
{/* City */} {/* City */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
City City
</label> </label>
<Controller <Controller
@@ -242,7 +292,7 @@ const EditTeamPage: FC = (): ReactElement => {
{/* Description */} {/* Description */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300"> <label className="block text-label1 font-medium text-gray-700 dark:text-gray-300">
Description Description
</label> </label>
<Controller <Controller
@@ -256,9 +306,12 @@ const EditTeamPage: FC = (): ReactElement => {
placeholder="Tell others about your team..." placeholder="Tell others about your team..."
rows={4} rows={4}
className="w-full" className="w-full"
size="lg"
/> />
{fieldState.error && ( {fieldState.error && (
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p> <p className="text-sm text-red-500 mt-1">
{fieldState.error.message}
</p>
)} )}
</div> </div>
)} )}
@@ -267,7 +320,7 @@ const EditTeamPage: FC = (): ReactElement => {
{/* Visibility */} {/* Visibility */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300"> <label className="block text-label1 font-medium text-gray-700 dark:text-gray-300">
Team Visibility Team Visibility
</label> </label>
<Controller <Controller
@@ -275,7 +328,7 @@ const EditTeamPage: FC = (): ReactElement => {
name="visibility" name="visibility"
render={({ field }) => ( render={({ field }) => (
<div className="space-y-3"> <div className="space-y-3">
<label className="flex items-start space-x-3 cursor-pointer border dark:border-neutral-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-neutral-800"> <label className="flex items-start space-x-3 cursor-pointer border dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800">
<input <input
type="radio" type="radio"
{...field} {...field}
@@ -284,13 +337,15 @@ const EditTeamPage: FC = (): ReactElement => {
className="mt-1" className="mt-1"
/> />
<div> <div>
<p className="font-medium text-gray-900 dark:text-white">Public</p> <p className="font-medium text-gray-900 dark:text-white">
<p className="text-sm text-gray-600 dark:text-neutral-400"> Public
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 font-sans">
Team will be visible in Browse Teams Team will be visible in Browse Teams
</p> </p>
</div> </div>
</label> </label>
<label className="flex items-start space-x-3 cursor-pointer border dark:border-neutral-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-neutral-800"> <label className="flex items-start space-x-3 cursor-pointer border dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800">
<input <input
type="radio" type="radio"
{...field} {...field}
@@ -299,8 +354,10 @@ const EditTeamPage: FC = (): ReactElement => {
className="mt-1" className="mt-1"
/> />
<div> <div>
<p className="font-medium text-gray-900 dark:text-white">Private</p> <p className="font-medium text-gray-900 dark:text-white">
<p className="text-sm text-gray-600 dark:text-neutral-400"> Private
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 font-sans">
Team hidden, invite-only Team hidden, invite-only
</p> </p>
</div> </div>
@@ -6,21 +6,33 @@ const TeamDetailLayout: FC = (): ReactElement => {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
return ( return (
<div className="flex min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} /> <Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col overflow-auto"> <div className="flex-1 flex flex-col">
{/* Mobile Header with Hamburger */} {/* Mobile Header with Hamburger */}
<div className="lg:hidden bg-white dark:bg-neutral-900 border-b dark:border-neutral-700 px-4 py-3 flex items-center"> <div className="lg:hidden sticky top-0 z-30 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 flex items-center">
<button <button
onClick={() => setSidebarOpen(true)} onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors" className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
> >
<svg className="w-6 h-6 text-gray-600 dark:text-neutral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /> className="w-6 h-6 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg> </svg>
</button> </button>
<h1 className="ml-3 text-lg font-bold text-gray-900 dark:text-white">Hackathon</h1> <h1 className="ml-3 text-lg font-bold text-gray-900 dark:text-white">
Hackathon
</h1>
</div> </div>
<main className="flex-1 overflow-auto"> <main className="flex-1 overflow-auto">

Some files were not shown because too many files have changed in this diff Show More