Compare commits

...
77 Commits
Author SHA1 Message Date
Hafid Nur 0017aff5f4 feat(hackathon): dark mode for user page 2025-11-27 16:08:39 +07:00
Hafid Nur e52776e68a Merge branch 'develop' into hackathon/fix-ui 2025-11-27 15:51:06 +07:00
Hafid Nur cd06a71a9e 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
2025-11-27 15:42:18 +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
Hafid Nur 0fcd93cb7d feat(hackathon): update dark mode onboarding and edit profile 2025-11-27 14:59:22 +07:00
Hafid Nur 3470853858 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
2025-11-27 14:53:38 +07:00
Hafid Nur ff9a02c020 feat(hackathon): browse team page
- update dark mode color scheme
- add some border style to give outline and depth
- change emoji to icon
2025-11-27 13:28:38 +07:00
Hafid Nur f81a7c334e feat(hackathon): dashboard UI improvement and dark mode adjustment 2025-11-27 13:06:13 +07:00
Hafid Nur 2700f7cbb7 feat(hackathon): update auth page dark mode color scheme 2025-11-27 12:02:23 +07:00
Hafid Nur cfcdfa527c feat(hackathon): update landing page dark mode color scheme 2025-11-27 12:01:50 +07:00
Hafid Nur c6579d5295 Merge branch 'develop' into hackathon/fix-ui 2025-11-27 11:20:12 +07:00
Maulana SodiqinandClaude 66b8b064ba fix(hackathon): truncate long bio text on dashboard
- Add line-clamp-3 to limit bio to 3 lines
- Add wrap-break-word to handle long strings without spaces

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 16:53:46 +07:00
Maulana SodiqinandClaude 63643cc286 fix(hackathon): add missing image loading handlers on team page
- Add totalImagesToLoad, handleImageLoad, handleImageError
- Disable image loading overlay to prevent blocking UI
- Fix ReferenceError: totalImagesToLoad is not defined

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 16:50:54 +07:00
19bd44a7ca feat(hackathon): Update UI landing page, dashboard, onboarding (#62)
* 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

---------

Co-authored-by: Maulana Sodiqin <sodiqincahyana1@gmail.com>
Co-authored-by: Muhammad Zakir Ramadhan <61570975+zakirkun@users.noreply.github.com>
2025-11-26 16:41:10 +07:00
Hafid Nur a1640cfe1d Merge branch 'develop' into hackathon/fix-ui 2025-11-26 16:38:22 +07:00
Hafid Nur 00df69fb93 fix: optimize placeholder banner image & minor fix dark mode 2025-11-26 16:35:49 +07:00
Maulana SodiqinandClaude b3bdf25b5f fix(hackathon): use searchable CitySelect on edit team page
- Replace basic select dropdown with CitySelect component
- Consistent with create team and profile pages

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 16:32:29 +07:00
Hafid Nur 810dbaa0ca Merge branch 'develop' into hackathon/fix-ui 2025-11-26 16:30:52 +07:00
Maulana SodiqinandClaude cdd4fd4703 chore(hackathon): remove console.log statements and improve error handling
- Remove all console.log statements from middleware, layout, and pages
- Replace alert with toast for onboarding error messages
- Delete unused page-original.tsx backup file
- Keep console.error for actual error debugging

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 16:26:00 +07:00
Maulana SodiqinandClaude 7d1b86acdb fix(ui): add dark mode support to Input, Textarea and profile modal
- Add dark mode styling to Input component (bg, text, border, placeholder)
- Add dark mode styling to Textarea component
- Update profile page with blurred backdrop overlay
- Add shadow and proper dark mode to modal container

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 16:16:38 +07:00
Maulana SodiqinandClaude 6e6c361eb8 feat(hackathon): enhance dark mode, city select, and auth-aware navbar
- Apply rose-pine dark theme across all pages for darker appearance
- Update Button component with dark mode variants
- Replace basic city select with searchable CitySelect on create team page
- Show Dashboard button in navbar when user is authenticated
- Apply dark mode styling to onboarding, team pages, and user profile

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 16:06:23 +07:00
Hafid Nur cb09709800 feat(hackathon): update UI
- Update dashboard, onboarding user, edit profile, and team layout
  to make it more consistent
2025-11-26 15:51:38 +07:00
Maulana SodiqinandClaude 62780c8889 fix(hackathon): fix team leader access denied and member removal
- Add loading state check before leader validation to prevent race condition
- Fix useRemoveMember hook to use Supabase directly instead of missing API endpoint
- Leader check now waits for team data to load before showing Access Denied

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 15:36:35 +07:00
Maulana SodiqinandClaude 1839f4cab0 feat(hackathon): add dark mode support with theme toggle
- Add ThemeProvider with system/light/dark mode support and localStorage persistence
- Add ThemeToggle component for pages without sidebar (landing, login, signup)
- Enable class-based dark mode in Tailwind CSS v4 via @custom-variant
- Add dark mode classes to landing page, login, signup, and all dashboard pages
- Keep IMPHNEN logo blue in dark mode, invert Kolosal logo

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 15:25:23 +07:00
Hafid Nur 7e385c6385 fix: landing navbar not sticky to top, fix onboarding UI 2025-11-26 14:33:17 +07:00
Hafid Nur 19aa5bb452 Merge branch 'develop' into hackathon/fix-ui 2025-11-26 14:27:32 +07:00
Maulana SodiqinandClaude c9f5b9e4a5 fix(hackathon): hide team chat and actions from non-members
- Add isMember check to only show Quick Actions to team members
- Hide submission status from non-members
- Non-members can only view team info, leader, and member list

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 14:27:13 +07:00
Maulana SodiqinandClaude 33365affa2 fix(hackathon): replace top navbar with sidebar on team and user detail pages
- Update teams/[teamId]/layout.tsx to use Sidebar component
- Update users/[userId]/layout.tsx to use Sidebar component
- Now matches dashboard layout with sidebar navigation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 14:22:01 +07:00
Maulana SodiqinandClaude 63db7ff6ad fix(hackathon): match search input height with city select and add debounce
- Set consistent h-[42px] height for both search and city filter inputs
- Add 300ms debounce to search to reduce API calls while typing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 14:19:33 +07:00
Maulana SodiqinandClaude 7f8b53edd4 fix(hackathon): improve city select UX and remove redundant navbar
- Replace browse teams city dropdown with searchable CitySelect component
- Remove "Back to Dashboard" button from team detail page (uses sidebar nav)
- Add cities constant to service library for validation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 14:12:45 +07:00
Hafid Nur e8de897dc9 Merge branch 'develop' into hackathon/fix-ui 2025-11-26 13:51:51 +07:00
Maulana SodiqinandClaude eb0628ac3e fix: filter join requests to only show pending status
- Add .eq('status', 'pending') filter to useTeamJoinRequests hook
- Add toast notifications for approve/reject actions on members page
- Parse Supabase error messages to show user-friendly error text

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 11:03:34 +07:00
Ivan RizkyantoandGitHub cbb880a053 Hackathon: better frontend (#61)
* feat: enhance team browsing with member count and view team option

* feat: add default banner image for teams without a custom banner

* feat: implement user profile page and user layout component

* feat: improve browse team filtering layout

* feat: limit join request button visibility based on team member count

* feat: enhance user profile and team dashboard layouts for improved responsiveness

* feat: add location, bio, and skills fields to user profile schema and form

* feat: enhance team display and user information layout for better readability

* feat: update join request button visibility logic based on user team membership

* feat: enhance team and user profile layouts with improved text truncation and responsiveness
2025-11-26 10:18:36 +07:00
Maulana SodiqinandClaude 083a912e91 fix: filter join requests to only show pending status
Previously approved/rejected requests were still showing in the list.
Now only pending requests are displayed.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 21:58:15 +07:00
Muhammad Zakir Ramadhan eff89d28ed feat: add image loading state and skeleton UI for team dashboard 2025-11-25 20:17:48 +07:00
ab744b411d feat(hackathon): update landing page and auth page UI (#60)
* 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

---------

Co-authored-by: Maulana Sodiqin <sodiqincahyana1@gmail.com>
2025-11-25 19:59:08 +07:00
Maulana SodiqinandGitHub b75a88677a Merge branch 'develop' into hackathon/fix-ui 2025-11-25 19:58:59 +07:00
Muhammad Zakir Ramadhan 8947b4d05e Merge branch 'develop' of https://github.com/IMPHNEN/imphnen-frontend-service into develop 2025-11-25 19:55:10 +07:00
Muhammad Zakir Ramadhan 7e3eaa6584 feat: implement user profile editing and sidebar navigation 2025-11-25 19:55:08 +07:00
Maulana SodiqinandClaude 98a459c7d7 fix: remove permission checker to fix null access error
Removed permission checking logic that was causing "Cannot read properties
of null (reading 'name')" error.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 19:35:15 +07:00
Hafid Nur 7642fef3e6 chore: update UI for signup and login
- add back button for better UX
2025-11-25 18:57:03 +07:00
Hafid Nur f40e0e35b9 chore: update landing page 2025-11-25 18:29:04 +07:00
Hafid Nur c6c0d6fded Merge branch 'develop' into hackathon/fix-ui 2025-11-25 17:54:41 +07:00
Maulana SodiqinandClaude 9d24a11c05 fix: add builder prune before build to free memory
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 17:46:10 +07:00
Maulana SodiqinandClaude ee60787100 fix: disable NX daemon and fix env vars for faster Docker builds
- Add NX_DAEMON=false to all Dockerfiles to prevent slow daemon startup
- Add --env-file flag to hackathon build for proper env var substitution

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 17:39:43 +07:00
Maulana Sodiqin 301a349776 trigger deploy hackathon 2025-11-25 16:42:47 +07:00
Maulana SodiqinandClaude 81488ceb6b fix: add build progress output to Docker builds
- Added --progress=plain to show live build output
- Enabled DOCKER_BUILDKIT=1 for better build performance
- Now you can see what's actually happening instead of appearing stuck

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 16:40:08 +07:00
Maulana SodiqinandClaude 9e99fd61e7 fix: add concurrency control to prevent simultaneous deployments
- Added concurrency group 'deploy-vps' to all deployment workflows
- Prevents multiple deployments from running simultaneously on VPS
- Deployments now queue and run one at a time
- Fixes resource exhaustion when all apps deploy at once

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 16:37:08 +07:00
Maulana SodiqinandClaude d36c693615 chore: trigger deployment for all apps
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 16:21:28 +07:00
Maulana SodiqinandClaude 20125a6f4a fix: increase deployment timeout to 30 minutes for all apps
- Added command_timeout: 30m to all SSH deployment workflows
- Prevents timeout issues during Docker builds
- Applies to landing, hackathon, gacha, dimentorin, and backoffice

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 16:20:34 +07:00
Maulana SodiqinandClaude 4bc5006c91 chore: trigger deployment for all apps
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 16:07:16 +07:00
Maulana SodiqinandClaude 71769b71aa fix(hackathon): fix env file location for docker-compose
- Changed from apps/hackathon/.env to root .env.local
- Docker-compose expects .env.local in root directory
- This ensures Supabase env variables are properly loaded during build

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 16:03:10 +07:00
Hafid NurandGitHub a2cf67d210 feat(hackathon): Update landing page UI and meta tag (#59)
* 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
2025-11-25 15:47:55 +07:00
Maulana SodiqinandClaude d9c4217fd3 chore: trigger deployment for all apps
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 15:47:20 +07:00
Hafid Nur fcaa449630 chore: remove react-helmet-async 2025-11-25 15:37:47 +07:00
Hafid Nur 4988980384 Merge branch 'develop' into hackathon/fix-ui 2025-11-25 15:23:03 +07:00
Hafid Nur 9547d34f5c 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
2025-11-25 15:20:10 +07:00
Hafid Nur aeb23a7255 feat: update landing page 2025-11-25 15:05:37 +07:00
Maulana SodiqinandClaude 719e36e46c feat(hackathon): add searchable city select component
- Created CitySelect component with real-time filtering
- Supports 516 Indonesian cities with array filtering
- Added search functionality for better UX
- Integrated with react-hook-form in user onboarding
- Includes clear button and auto-close on outside click

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 14:46:35 +07:00
Hafid NurandGitHub bd052f4866 feat: add Indonesian cities constants (#58)
* feat: add Indonesian cities constants

* chore: use npm, Node 22 LTS, revert package.json
2025-11-25 12:48:39 +07:00
Maulana SodiqinandClaude 734e8540f2 chore: trigger deployment for all apps
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 11:10:07 +07:00
Maulana SodiqinandClaude 1289555749 feat: add Supabase environment variables to deployment workflows
- Updated all deployment workflows to create .env files with Supabase credentials
- Added VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY for Vite apps
- Added NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY for Next.js landing app
- Changed from single-line echo to multi-line cat heredoc for better .env management

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 11:06:57 +07:00
Maulana SodiqinandClaude 971fe7160f fix: handle local changes in deployment workflows
Add git reset --hard before pull to discard any local changes on server:
- Fetch latest changes from origin
- Reset to origin/develop to discard local modifications
- Then pull to update

This fixes deployment failures caused by uncommitted local changes on the server.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 11:04:45 +07:00
Maulana Sodiqin 722caeec9d fix: landing hackathon redirect issue 2025-11-25 11:02:14 +07:00
Maulana SodiqinandClaude 7404afe466 fix: allow landing page to be viewed without authentication
- Remove automatic redirect from "/" to login/dashboard
- Allow everyone to view the hackathon landing page
- Users can still click "Daftar Sekarang" to go to login

This fixes the issue where the landing page immediately redirected to login.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 11:01:41 +07:00
Maulana SodiqinandClaude 400daea60f fix: resolve TypeScript build errors in service library
- Fix supabase.ts with proper placeholder types (was error messages from failed CLI command)
- Remove duplicate TPermissionItem export from local-storage.ts
- Import TPermissionItem from types/permissions instead

These changes fix TypeScript compilation errors during Docker builds.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 10:41:45 +07:00
Maulana SodiqinandClaude 40cd47c6be chore: update package-lock.json to sync with package.json
Synchronized package-lock.json with package.json to fix Docker build failures with npm ci.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 10:32:09 +07:00
Maulana SodiqinandClaude 529d065a75 perf: optimize Docker builds with npm ci
Replace npm install with npm ci and optimization flags to fix deployment timeouts:
- Use npm ci for faster, more reliable installs
- Add --prefer-offline to use cached packages
- Add --no-audit and --no-fund to skip unnecessary checks
- Optimize landing.Dockerfile to copy package files before installing

This resolves the Docker build EOF errors during deployment.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 10:27:49 +07:00
Maulana SodiqinandClaude 69d6d86342 chore: trigger deployment for all apps
Triggering deployment for all apps to deploy the following changes:
- Fixed circular dependency between utils and service libraries
- Added missing auth hooks (usePostVerifyEmail, useGoogleCallback, etc.)
- Moved useAuthStore from utils to service
- All apps now build successfully

Apps to deploy:
- hackathon
- gacha
- dimentorin
- backoffice
- landing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 10:00:10 +07:00
Maulana SodiqinandClaude 31736cc403 fix: add missing auth hooks for gacha and dimentorin builds
Added missing React Query hooks that were being imported but not exported:
- usePostLogin
- usePostRegister
- usePostVerifyEmail
- usePostSendOtp
- useGoogleCallback

These hooks wrap the existing auth API functions with React Query's useMutation.

Fixes build errors:
- gacha:build - "usePostVerifyEmail" is not exported
- dimentorin:build - "useGoogleCallback" is not exported

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 09:49:51 +07:00
Maulana SodiqinandClaude 1cb2443b58 fix: resolve circular dependency between utils and service libraries
Moved useAuthStore from utils to service to break circular dependency:
- utils was importing from service (supabase client, types)
- service was importing from utils (useAuthStore)
- Solution: moved useAuthStore and related storage utilities to service

Changes:
- Created libs/service/src/storage/ with cookies.ts and local-storage.ts
- Moved use-auth-store.ts from utils/hooks to service/hooks/auth
- Updated all 20+ files to import useAuthStore from service instead of utils
- Removed useAuthStore export from utils
- Added storage exports to service index

This fixes the build error: "Could not execute command because the task graph has a circular dependency"

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 02:34:52 +07:00
Maulana Sodiqin 9e9bfc2793 feat: hackathon 2025-11-25 01:47:56 +07:00
Maulana Sodiqin 5a99f216cb chore: deploy hackathon 2025-11-24 13:09:12 +07:00
Maulana Sodiqin efdbc65be6 chore: remove vetrsion 2025-11-24 12:51:41 +07:00
Maulana Sodiqin f5905c0acc chore: remove storybook 2025-11-24 11:21:29 +07:00
Maulana Sodiqin 6348a4998b feat: upgrade nx 2025-11-24 10:48:09 +07:00
Hafid NurandGitHub e067450fcb fix(landing): Fix community links & meta tag for SEO (#57) 2025-10-06 08:57:13 +07:00
132 changed files with 40836 additions and 13001 deletions
+18
View File
@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(npx nx run-many:*)",
"Bash(npm install:*)",
"Bash(npm view:*)",
"Bash(npx nx build landing)",
"Bash(npm uninstall:*)",
"Bash(dir:*)",
"Bash(ren page.tsx page-original.tsx)",
"Bash(ren:*)",
"Bash(npx supabase:*)",
"Bash(libs/service/src/types/supabase.ts)"
],
"deny": [],
"ask": []
}
}
+55
View File
@@ -0,0 +1,55 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
dist
.next
out
build
coverage
# IDE
.vscode
.idea
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Environment
.env
.env.local
.env.*.local
# Testing
coverage
.nyc_output
# Logs
logs
*.log
# Nx
.nx
# Docker
Dockerfile
docker-compose*.yml
.dockerignore
# Misc
*.md
!README.md
.editorconfig
.prettierrc
.eslintrc
+3
View File
@@ -0,0 +1,3 @@
VITE_SUPABASE_URL=https://your-project-id.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key-here
+12 -3
View File
@@ -7,6 +7,10 @@ on:
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
@@ -21,15 +25,20 @@ jobs:
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 pull
cd ~/imphnen-frontend-service-backoffice && git fetch origin && git reset --hard origin/develop && git pull
fi
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env
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 compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build
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
+12 -3
View File
@@ -7,6 +7,10 @@ on:
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
@@ -21,15 +25,20 @@ jobs:
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 pull
cd ~/imphnen-frontend-service-dimentorin && git fetch origin && git reset --hard origin/develop && git pull
fi
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env
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 compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build
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
+12 -3
View File
@@ -7,6 +7,10 @@ on:
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
@@ -21,15 +25,20 @@ jobs:
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 pull
cd ~/imphnen-frontend-service-gacha && git fetch origin && git reset --hard origin/develop && git pull
fi
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-gacha/apps/gacha/.env
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 compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build
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
@@ -0,0 +1,45 @@
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
+12 -3
View File
@@ -7,6 +7,10 @@ on:
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
@@ -21,15 +25,20 @@ jobs:
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 pull
cd ~/imphnen-frontend-service && git fetch origin && git reset --hard origin/develop && git pull
fi
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" > ~/imphnen-frontend-service/apps/landing/.env
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 compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build
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
+4
View File
@@ -62,3 +62,7 @@ storybook-static
# Next.js
.next
out
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
.env.local
+1
View File
@@ -0,0 +1 @@
Tue, Nov 25, 2025 4:21:27 PM
+2
View File
@@ -0,0 +1,2 @@
# Deployment trigger
# Updated to deploy circular dependency fixes and auth hooks
+1
View File
@@ -0,0 +1 @@
Tue, Nov 25, 2025 4:21:27 PM
+2
View File
@@ -0,0 +1,2 @@
# Deployment trigger
# Updated to deploy circular dependency fixes and auth hooks
@@ -3,9 +3,8 @@
import React, { createContext, useContext, useMemo, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '@imphnen-frontend-service/utils';
import {
useAuthStore,
useUserMe,
useUserById,
useUpdateUserMe,
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { toast } from 'sonner';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '@imphnen-frontend-service/utils';
import { useAuthStore } from '@imphnen-frontend-service/service';
export interface GoogleLoginResponse {
access_token?: string;
@@ -1,7 +1,6 @@
import { FC, ReactElement, useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useAuthStore } from '@imphnen-frontend-service/utils';
import { useGoogleCallback } from '@imphnen-frontend-service/service';
import { useGoogleCallback, useAuthStore } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
export const GoogleCallbackPage: FC = (): ReactElement => {
+1
View File
@@ -0,0 +1 @@
Tue, Nov 25, 2025 4:21:27 PM
+2
View File
@@ -0,0 +1,2 @@
# Deployment trigger
# Updated to deploy circular dependency fixes and auth hooks
+1 -1
View File
@@ -3,9 +3,9 @@ import {
authLoginSchema,
TLoginRequest,
usePostLogin,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAuthStore } from '@imphnen-frontend-service/utils';
import { toast } from 'sonner';
import { useNavigate } from 'react-router';
import { useVerifyEmail } from './use-verify-email';
+1
View File
@@ -0,0 +1 @@
Tue, Nov 25, 2025 16:42:27 PM
+14
View File
@@ -0,0 +1,14 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/react'],
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
// Override or add rules here
rules: {
'jsx-a11y/accessible-emoji': 'off',
},
},
];
+40
View File
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>IMPHNEN x Kolosal.ai Hackathon 2025</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="Ikuti IMPHNEN x Kolosal.ai Hackathon 2025! Kompetisi coding online gratis dengan total hadiah Rp14.500.000. Tema: Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif."
/>
<meta
name="keywords"
content="hackathon, lomba coding, kompetisi it, imphnen, kolosal.ai, ai hackathon, lomba programming 2025"
/>
<link
rel="icon"
type="image/svg+xml"
href="/images/imphnen-logo-simple.svg"
/>
<link rel="stylesheet" href="/src/index.css" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://hackathon.imphnen.dev/" />
<meta property="og:title" content="IMPHNEN x Kolosal.ai Hackathon 2025" />
<meta
property="og:description"
content="Total hadiah Rp14.500.000! Daftar sekarang dan wujudkan inovasi AI-mu untuk membantu usaha lokal."
/>
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://hackathon.imphnen.dev/" />
<meta
property="twitter:title"
content="IMPHNEN x Kolosal.ai Hackathon 2025"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
import { join } from 'path';
export default {
plugins: {
'@tailwindcss/postcss': {
base: join(import.meta.dirname, '../../'),
},
},
};
+1
View File
@@ -0,0 +1 @@
{}
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 511 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 351 KiB

@@ -0,0 +1,10 @@
<svg width="82" height="24" viewBox="0 0 82 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.9814 11.6032C15.0691 11.7133 15.1239 11.8495 15.124 12C15.124 12.1501 15.0698 12.2867 14.9825 12.3968L26.8264 24H7.79044L7.76667 23.9757L0.0248413 16.3911L0 16.3679V7.63213L0.0248413 7.60885L7.76667 0.0243365L7.79044 0H26.8264L14.9814 11.6032ZM14.8659 12.5111C14.7535 12.5968 14.6143 12.6507 14.4609 12.6507C14.3071 12.6507 14.1673 12.5971 14.0548 12.5111L10.3977 16.0938C10.5164 16.2113 10.5899 16.3726 10.5899 16.5509C10.5899 16.6459 10.5668 16.7352 10.5295 16.8165L17.6956 23.8371H25.8608C25.8683 23.78 25.8804 23.7233 25.903 23.6699C25.9358 23.5923 25.9831 23.5211 26.0434 23.4614L14.8659 12.5111ZM10.3307 8.06807C10.2184 8.15365 10.0801 8.20766 9.92678 8.20774C9.80008 8.20774 9.68295 8.17075 9.58225 8.1104L5.61305 12L9.65353 15.9584C9.73677 15.9215 9.82951 15.9012 9.92678 15.9012C10.0509 15.9013 10.1655 15.9372 10.2648 15.9954L13.9381 12.3968C13.8511 12.2868 13.7977 12.1499 13.7977 12C13.7978 11.8498 13.8518 11.7132 13.9392 11.6032L10.3307 8.06807ZM10.4906 7.22053C10.5522 7.3192 10.5899 7.4339 10.5899 7.55806C10.5899 7.70823 10.5357 7.84479 10.4485 7.95485L14.0558 11.4889C14.1682 11.4034 14.3076 11.3503 14.4609 11.3503C14.6139 11.3503 14.7536 11.4026 14.8659 11.4879L26.0434 0.537519C25.9835 0.477938 25.9356 0.407383 25.903 0.33013C25.8804 0.276608 25.8682 0.220166 25.8608 0.162949H17.6956L10.4906 7.22053Z" fill="#0D0E0F"/>
<path d="M82 5.69697V17.3333H80.291V5.69697H82Z" fill="#0D0E0F"/>
<path d="M73.9503 17.5265C73.3939 17.5265 72.8909 17.4242 72.4413 17.2197C71.9916 17.0114 71.6353 16.7102 71.3724 16.3163C71.1133 15.9223 70.9837 15.4394 70.9837 14.8674C70.9837 14.375 71.079 13.9697 71.2695 13.6515C71.4601 13.3333 71.7173 13.0814 72.0412 12.8958C72.3651 12.7102 72.7271 12.5701 73.1272 12.4754C73.5273 12.3807 73.935 12.3087 74.3504 12.2595C74.8762 12.1989 75.303 12.1496 75.6307 12.1117C75.9584 12.0701 76.1966 12.0038 76.3452 11.9129C76.4938 11.822 76.5681 11.6742 76.5681 11.4697V11.4299C76.5681 10.9337 76.4271 10.5492 76.1452 10.2765C75.867 10.0038 75.4516 9.86742 74.8991 9.86742C74.3237 9.86742 73.8702 9.99432 73.5387 10.2481C73.211 10.4981 72.9843 10.7765 72.8585 11.0833L71.2524 10.7197C71.4429 10.1894 71.7211 9.76136 72.0869 9.4356C72.4565 9.10606 72.8814 8.86742 73.3615 8.71969C73.8417 8.56818 74.3466 8.49242 74.8762 8.49242C75.2268 8.49242 75.5983 8.53409 75.9908 8.61742C76.3871 8.69697 76.7568 8.84469 77.0997 9.0606C77.4465 9.27651 77.7304 9.58523 77.9514 9.98674C78.1724 10.3845 78.2829 10.9015 78.2829 11.5379V17.3333H76.6139V16.1401H76.5453C76.4348 16.3598 76.269 16.5758 76.048 16.7879C75.827 17 75.5431 17.1761 75.1963 17.3163C74.8496 17.4564 74.4342 17.5265 73.9503 17.5265ZM74.3218 16.1629C74.7943 16.1629 75.1982 16.0701 75.5336 15.8845C75.8727 15.6989 76.1299 15.4564 76.3052 15.1572C76.4843 14.8542 76.5738 14.5303 76.5738 14.1856V13.0606C76.5129 13.1212 76.3947 13.178 76.2195 13.2311C76.048 13.2803 75.8517 13.3239 75.6307 13.3617C75.4097 13.3958 75.1944 13.428 74.9848 13.4583C74.7753 13.4848 74.6 13.5076 74.459 13.5265C74.1275 13.5682 73.8245 13.6383 73.5502 13.7367C73.2796 13.8352 73.0624 13.9773 72.8986 14.1629C72.7385 14.3447 72.6585 14.5871 72.6585 14.8901C72.6585 15.3106 72.8147 15.6288 73.1272 15.8447C73.4397 16.0568 73.8379 16.1629 74.3218 16.1629Z" fill="#0D0E0F"/>
<path d="M69.613 10.7367L68.064 11.0095C67.9992 10.8125 67.8963 10.625 67.7553 10.447C67.6182 10.2689 67.4314 10.1231 67.1952 10.0095C66.9589 9.89583 66.6636 9.83901 66.3092 9.83901C65.8253 9.83901 65.4214 9.94697 65.0975 10.1629C64.7736 10.375 64.6116 10.6496 64.6116 10.9867C64.6116 11.2784 64.7202 11.5133 64.9374 11.6913C65.1546 11.8693 65.5052 12.0151 65.9891 12.1288L67.3838 12.447C68.1916 12.6326 68.7937 12.9186 69.19 13.3049C69.5863 13.6913 69.7845 14.1932 69.7845 14.8106C69.7845 15.3333 69.632 15.7992 69.3272 16.2083C69.0262 16.6136 68.6051 16.9318 68.064 17.1629C67.5267 17.3939 66.9037 17.5095 66.1949 17.5095C65.2118 17.5095 64.4097 17.3011 63.7885 16.8845C63.1674 16.464 62.7864 15.8674 62.6454 15.0947L64.2972 14.8447C64.4001 15.2727 64.6116 15.5966 64.9317 15.8163C65.2518 16.0322 65.6691 16.1401 66.1835 16.1401C66.7436 16.1401 67.1914 16.0246 67.5267 15.7936C67.862 15.5587 68.0297 15.2727 68.0297 14.9356C68.0297 14.6629 67.9268 14.4337 67.721 14.2481C67.5191 14.0625 67.2085 13.9223 66.7894 13.8276L65.3032 13.5038C64.484 13.3182 63.8781 13.0227 63.4856 12.6174C63.0969 12.2121 62.9026 11.6989 62.9026 11.0776C62.9026 10.5625 63.0474 10.1117 63.337 9.72538C63.6266 9.33901 64.0267 9.03788 64.5373 8.82197C65.0479 8.60227 65.6328 8.49242 66.2921 8.49242C67.2409 8.49242 67.9878 8.69697 68.5327 9.10606C69.0776 9.51136 69.4377 10.0549 69.613 10.7367Z" fill="#0D0E0F"/>
<path d="M57.3021 17.5095C56.479 17.5095 55.7607 17.322 55.1472 16.947C54.5337 16.572 54.0574 16.0473 53.7182 15.3731C53.3791 14.6989 53.2095 13.911 53.2095 13.0095C53.2095 12.1042 53.3791 11.3125 53.7182 10.6345C54.0574 9.95644 54.5337 9.42992 55.1472 9.05492C55.7607 8.67992 56.479 8.49242 57.3021 8.49242C58.1252 8.49242 58.8435 8.67992 59.457 9.05492C60.0705 9.42992 60.5468 9.95644 60.8859 10.6345C61.2251 11.3125 61.3946 12.1042 61.3946 13.0095C61.3946 13.911 61.2251 14.6989 60.8859 15.3731C60.5468 16.0473 60.0705 16.572 59.457 16.947C58.8435 17.322 58.1252 17.5095 57.3021 17.5095ZM57.3078 16.0833C57.8413 16.0833 58.2833 15.9432 58.6339 15.6629C58.9845 15.3826 59.2436 15.0095 59.4112 14.5436C59.5827 14.0776 59.6685 13.5644 59.6685 13.0038C59.6685 12.447 59.5827 11.9356 59.4112 11.4697C59.2436 11 58.9845 10.6231 58.6339 10.339C58.2833 10.0549 57.8413 9.91288 57.3078 9.91288C56.7705 9.91288 56.3247 10.0549 55.9703 10.339C55.6197 10.6231 55.3587 11 55.1872 11.4697C55.0196 11.9356 54.9357 12.447 54.9357 13.0038C54.9357 13.5644 55.0196 14.0776 55.1872 14.5436C55.3587 15.0095 55.6197 15.3826 55.9703 15.6629C56.3247 15.9432 56.7705 16.0833 57.3078 16.0833Z" fill="#0D0E0F"/>
<path d="M51.573 5.69697V17.3333H49.864V5.69697H51.573Z" fill="#0D0E0F"/>
<path d="M44.132 17.5095C43.3089 17.5095 42.5906 17.322 41.9771 16.947C41.3636 16.572 40.8873 16.0473 40.5482 15.3731C40.209 14.6989 40.0395 13.911 40.0395 13.0095C40.0395 12.1042 40.209 11.3125 40.5482 10.6345C40.8873 9.95644 41.3636 9.42992 41.9771 9.05492C42.5906 8.67992 43.3089 8.49242 44.132 8.49242C44.9551 8.49242 45.6734 8.67992 46.2869 9.05492C46.9004 9.42992 47.3767 9.95644 47.7158 10.6345C48.055 11.3125 48.2245 12.1042 48.2245 13.0095C48.2245 13.911 48.055 14.6989 47.7158 15.3731C47.3767 16.0473 46.9004 16.572 46.2869 16.947C45.6734 17.322 44.9551 17.5095 44.132 17.5095ZM44.1377 16.0833C44.6712 16.0833 45.1132 15.9432 45.4638 15.6629C45.8144 15.3826 46.0735 15.0095 46.2412 14.5436C46.4126 14.0776 46.4984 13.5644 46.4984 13.0038C46.4984 12.447 46.4126 11.9356 46.2412 11.4697C46.0735 11 45.8144 10.6231 45.4638 10.339C45.1132 10.0549 44.6712 9.91288 44.1377 9.91288C43.6004 9.91288 43.1546 10.0549 42.8002 10.339C42.4496 10.6231 42.1886 11 42.0171 11.4697C41.8495 11.9356 41.7656 12.447 41.7656 13.0038C41.7656 13.5644 41.8495 14.0776 42.0171 14.5436C42.1886 15.0095 42.4496 15.3826 42.8002 15.6629C43.1546 15.9432 43.6004 16.0833 44.1377 16.0833Z" fill="#0D0E0F"/>
<path d="M30.7951 17.3333V5.69697H32.5613V11.2538H32.7042L37.6141 5.69697H39.8376L35.1163 10.9356L39.8547 17.3333H37.7284L33.9445 12.1345L32.5613 13.714V17.3333H30.7951Z" fill="#0D0E0F"/>
</svg>

After

Width:  |  Height:  |  Size: 7.2 KiB

+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="/"
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>
);
}
@@ -0,0 +1,191 @@
import { FC, ReactElement, useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router';
import { useAuthStore, supabase } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
const CallbackPage: FC = (): ReactElement => {
const navigate = useNavigate();
const { setSession } = useAuthStore();
const [isProcessing, setIsProcessing] = useState(true);
const [error, setError] = useState<string | null>(null);
const hasRunRef = useRef(false);
useEffect(() => {
const handleCallback = async () => {
if (hasRunRef.current) {
// console.log('[Callback] Already processed, skipping...');
return;
}
hasRunRef.current = true;
try {
// console.log('[Callback] Processing OAuth callback...');
// console.log('[Callback] Current URL:', globalThis.location.href);
// Supabase client is configured with detectSessionInUrl: true
// This means Supabase automatically detects and processes OAuth tokens from the URL hash
// We just need to wait a moment for it to complete, then check for the session
// console.log('[Callback] Waiting for Supabase to process OAuth callback...');
await new Promise((resolve) => setTimeout(resolve, 1000));
// Get the session that Supabase automatically created from the URL hash
const {
data: { session: sessionData },
error: sessionError,
} = await supabase.auth.getSession();
if (sessionError) {
// console.error('[Callback] Session error:', sessionError);
throw new Error(sessionError.message || 'Failed to get session');
}
if (!sessionData || !sessionData.user) {
throw new Error(
'No session found after OAuth callback. Please try logging in again.'
);
}
// console.log('[Callback] Supabase session established:', {
// userId: sessionData.user.id,
// email: sessionData.user.email,
// });
// Create/update user in the users table (for foreign key constraints)
// console.log('[Callback] Creating/updating user record...');
const { data: userData, error: upsertError } = await supabase
.from('users')
.upsert(
{
id: sessionData.user.id,
email: sessionData.user.email || '',
fullname:
sessionData.user.user_metadata?.full_name ||
sessionData.user.user_metadata?.name ||
sessionData.user.email?.split('@')[0] ||
'',
avatar: sessionData.user.user_metadata?.avatar_url || '',
is_active: true,
updated_at: new Date().toISOString(),
},
{
onConflict: 'id',
}
)
.select()
.single();
if (upsertError) {
// console.warn('[Callback] Failed to create user record:', upsertError);
// Don't throw - continue with login even if user record creation fails
} else {
// console.log('[Callback] User record created/updated successfully');
}
// Store user-friendly data in Zustand for UI purposes
// Supabase now manages the actual auth session
// Use data from database if available, otherwise use OAuth metadata
const userRecord = userData || {
id: sessionData.user.id,
email: sessionData.user.email || '',
fullname:
sessionData.user.user_metadata?.full_name ||
sessionData.user.user_metadata?.name ||
sessionData.user.email?.split('@')[0] ||
'',
avatar: sessionData.user.user_metadata?.avatar_url || '',
phone_number: '',
birthdate: '',
gender: '',
is_active: true,
};
setSession({
token: {
access_token: sessionData.access_token,
refresh_token: sessionData.refresh_token || '',
},
user: {
id: userRecord.id,
email: userRecord.email,
fullname: userRecord.fullname,
phone_number: userRecord.phone_number || '',
avatar: userRecord.avatar || '',
birthdate: userRecord.birthdate || '',
gender: userRecord.gender || '',
is_active: userRecord.is_active,
location: userRecord.location,
bio: userRecord.bio,
skills: userRecord.skills,
role: {
id: '',
name: 'user',
permissions: [],
created_at: '',
updated_at: '',
},
},
});
// console.log('[Callback] Session stored successfully');
toast.success('Login successful!');
setIsProcessing(false);
// Check if user has completed onboarding (has location)
// Use globalThis.location.replace for hard redirect to prevent history issues
if (userRecord.location) {
// console.log('[Callback] User has completed onboarding, redirecting to dashboard...');
globalThis.location.replace('/dashboard');
} else {
// console.log('[Callback] User needs onboarding, redirecting...');
globalThis.location.replace('/onboarding/user');
}
} catch (err) {
// console.error('[Callback] Error:', err);
setError((err as Error).message);
setIsProcessing(false);
toast.error('An error occurred during login');
setTimeout(() => {
navigate('/auth/login');
}, 3000);
}
};
handleCallback();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Run only once on mount
if (error) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 px-4">
<div className="bg-white w-full max-w-2xl p-8 rounded-2xl shadow-lg border border-red-200">
<div className="text-center mb-6">
<div className="text-red-500 text-5xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
GitHub Login Failed
</h2>
<p className="text-red-600 mb-4 whitespace-pre-line">{error}</p>
</div>
<p className="text-gray-600 text-sm mt-6 text-center">
Redirecting to login page in 3 seconds...
</p>
</div>
</div>
);
}
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
Completing login...
</h2>
<p className="text-gray-600 dark:text-gray-400">Please wait</p>
</div>
</div>
);
};
export default CallbackPage;
@@ -0,0 +1,136 @@
import { useState } from 'react';
import { supabase } from '@imphnen-frontend-service/service';
import { Link, useNavigate } from 'react-router';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import ThemeToggle from '../../../components/theme-toggle';
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [emailSent, setEmailSent] = useState(false);
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) {
toast.error('Please enter your email');
return;
}
try {
setIsLoading(true);
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/reset-password`,
});
if (error) {
throw error;
}
setEmailSent(true);
toast.success('Password reset email sent! Check your inbox.');
} catch (err) {
// console.error('Failed to send reset email:', err);
toast.error((err as Error).message || 'Failed to send reset email');
} finally {
setIsLoading(false);
}
};
if (emailSent) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 text-center">
<div className="mb-6">
<div className="mx-auto w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
<span className="text-3xl"></span>
</div>
<h2 className="text-3xl font-bold text-gray-900 mb-2">
Check Your Email
</h2>
<p className="text-gray-600">
We've sent a password reset link to <strong>{email}</strong>
</p>
</div>
<div className="space-y-4">
<p className="text-sm text-gray-600">
Click the link in the email to reset your password. The link will
expire in 1 hour.
</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">
Back to Login
</button>
</Link>
<button
onClick={() => setEmailSent(false)}
className="w-full py-3 text-gray-600 hover:text-gray-900 transition-colors"
>
Send another email
</button>
</div>
</div>
</div>
);
}
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">
<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">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Forgot Password?
</h2>
<p className="text-gray-600 dark:text-gray-400">
No worries, we'll send you reset instructions
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Email Address
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
disabled={isLoading}
className="bg-white dark:bg-gray-800 w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required
/>
</div>
<button
type="submit"
disabled={isLoading}
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'}
</button>
</form>
</div>
</div>
);
}
+242
View File
@@ -0,0 +1,242 @@
import { useState } from 'react';
import {
useGitHubAuth,
useEmailAuth,
supabase,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { GithubOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import { ThemeToggle } from '../../../components/theme-toggle';
export default function LoginPage() {
// console.log('[LoginPage] Rendering...');
const navigate = useNavigate();
const { setSession } = useAuthStore();
const { signInWithGitHub } = useGitHubAuth();
const { signInWithEmail } = useEmailAuth();
const [isGithubLoading, setIsGithubLoading] = useState(false);
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!email || !password) {
setError('Please enter both email and password');
return;
}
try {
setIsEmailLoading(true);
// console.log('[Login] Attempting email login...');
const result = await signInWithEmail(email, password);
// console.log('[Login] Email login successful:', result);
// Get user data from database
const { data: userData } = await supabase
.from('users')
.select('*')
.eq('id', result.user.id)
.single();
// Store session in Zustand
setSession({
token: {
access_token: result.session.access_token,
refresh_token: result.session.refresh_token || '',
},
user: {
id: result.user.id,
email: result.user.email || '',
fullname:
userData?.fullname || result.user.user_metadata?.full_name || '',
phone_number: userData?.phone_number || '',
avatar: userData?.avatar || '',
birthdate: userData?.birthdate || '',
gender: userData?.gender || '',
is_active: userData?.is_active || true,
location: userData?.location,
bio: userData?.bio,
skills: userData?.skills,
role: {
id: '',
name: 'user',
permissions: [],
created_at: '',
updated_at: '',
},
},
});
toast.success('Login successful!');
// Redirect based on onboarding status
if (userData?.location) {
navigate('/dashboard');
} else {
navigate('/onboarding/user');
}
} catch (err) {
console.error('[Login] Email login failed:', err);
setError((err as Error).message || 'Login failed');
setIsEmailLoading(false);
}
};
const handleGithubLogin = async () => {
try {
setIsGithubLoading(true);
// console.log('[Login] Initiating GitHub OAuth...');
const result = await signInWithGitHub();
// console.log('[Login] OAuth result:', result);
// Check if we got a redirect URL
if (result?.url) {
// console.log('[Login] Redirecting to GitHub OAuth:', result.url);
// Manually redirect immediately
globalThis.location.href = result.url;
} else {
// console.error('[Login] No OAuth URL returned');
setIsGithubLoading(false);
}
} catch (error) {
// console.error('[Login] GitHub login failed');
setIsGithubLoading(false);
}
};
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">
<div className="flex items-center justify-between mb-6">
<button
onClick={() => navigate('/')}
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 Homepage
</button>
<ThemeToggle />
</div>
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Welcome Back
</h2>
<p className="text-gray-600 dark:text-gray-400 font-sans">
Sign in to join or create your hackathon team
</p>
</div>
{error && (
<div className="mb-6 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
</div>
)}
<form onSubmit={handleEmailLogin} className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
disabled={isEmailLoading}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
required
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Password
</label>
<Link
to="/auth/forgot-password"
className="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
>
Forgot password?
</Link>
</div>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={isEmailLoading}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
required
/>
</div>
<button
type="submit"
disabled={isEmailLoading}
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'}
</button>
</form>
<div className="my-6 flex items-center">
<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-gray-400">
OR
</span>
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
</div>
<button
onClick={handleGithubLogin}
disabled={isGithubLoading}
type="button"
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" />
<span>
{isGithubLoading ? 'Connecting...' : 'Sign in with GitHub'}
</span>
</button>
<div className="mt-6 text-center">
<p className="text-gray-600 dark:text-gray-400 text-sm">
Don't have an account?{' '}
<a
href="/auth/signup"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
>
Sign up
</a>
</p>
</div>
<div className="mt-6 text-center">
<p className="text-gray-500 dark:text-gray-500 text-xs">
By signing in, you agree to our Terms of Service and Privacy Policy
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,139 @@
import { useState, useEffect } from 'react';
import { supabase } from '@imphnen-frontend-service/service';
import { useNavigate } from 'react-router';
import { toast } from 'sonner';
export default function ResetPasswordPage() {
const navigate = useNavigate();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isValidToken, setIsValidToken] = useState(false);
// useEffect(() => {
// // Check if we have a valid session (from the reset link)
// supabase.auth.getSession().then(({ data: { session } }) => {
// if (session) {
// setIsValidToken(true);
// } else {
// toast.error('Invalid or expired reset link');
// setTimeout(() => navigate('/auth/forgot-password'), 2000);
// }
// });
// }, [navigate]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
toast.error('Passwords do not match');
return;
}
if (password.length < 6) {
toast.error('Password must be at least 6 characters');
return;
}
try {
setIsLoading(true);
const { error } = await supabase.auth.updateUser({
password: password,
});
if (error) {
throw error;
}
toast.success('Password updated successfully!');
// Sign out and redirect to login
await supabase.auth.signOut();
navigate('/auth/login');
} catch (err) {
// console.error('Failed to reset password:', err);
toast.error((err as Error).message || 'Failed to reset password');
} finally {
setIsLoading(false);
}
};
// if (!isValidToken) {
// return (
// <div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950">
// <div className="text-center">
// <div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
// <p className="text-gray-600 dark:text-gray-400">
// Verifying reset link...
// </p>
// </div>
// </div>
// );
// }
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">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Set New Password
</h2>
<p className="text-gray-600 dark:text-gray-400">
Enter your new password below
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
New Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={isLoading}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
required
minLength={6}
/>
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Confirm New Password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
disabled={isLoading}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
required
minLength={6}
/>
</div>
<button
type="submit"
disabled={isLoading}
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'}
</button>
</form>
</div>
</div>
);
}
+295
View File
@@ -0,0 +1,295 @@
import { useState } from 'react';
import {
useGitHubAuth,
useEmailAuth,
supabase,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { GithubOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import { ThemeToggle } from '../../../components/theme-toggle';
export default function SignupPage() {
const navigate = useNavigate();
const { setSession } = useAuthStore();
const { signInWithGitHub } = useGitHubAuth();
const { signUpWithEmail } = useEmailAuth();
const [isGithubLoading, setIsGithubLoading] = useState(false);
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [fullname, setFullname] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const handleEmailSignup = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!fullname || !email || !password || !confirmPassword) {
setError('Please fill in all fields');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters long');
return;
}
try {
setIsEmailLoading(true);
// console.log('[Signup] Attempting email signup...');
const result = await signUpWithEmail(email, password, fullname);
// console.log('[Signup] Email signup successful:', result);
if (!result.user) {
throw new Error('Signup failed - no user returned');
}
// Create user record in database
const { error: upsertError } = await supabase.from('users').upsert(
{
id: result.user.id,
email: result.user.email || '',
fullname: fullname,
is_active: true,
updated_at: new Date().toISOString(),
},
{
onConflict: 'id',
}
);
if (upsertError) {
console.warn('[Signup] Failed to create user record');
}
// If session is available (email confirmation disabled), store it
if (result.session) {
setSession({
token: {
access_token: result.session.access_token,
refresh_token: result.session.refresh_token || '',
},
user: {
id: result.user.id,
email: result.user.email || '',
fullname: fullname,
phone_number: '',
avatar: '',
birthdate: '',
gender: '',
is_active: true,
role: {
id: '',
name: 'user',
permissions: [],
created_at: '',
updated_at: '',
},
},
});
toast.success('Account created successfully!');
navigate('/onboarding/user');
} else {
// Email confirmation is enabled
toast.success(
'Account created! Please check your email to verify your account.'
);
setTimeout(() => {
navigate('/auth/login');
}, 2000);
}
} catch (err) {
// console.error('[Signup] Email signup failed:', err);
setError((err as Error).message || 'Signup failed');
setIsEmailLoading(false);
}
};
const handleGithubLogin = async () => {
try {
setIsGithubLoading(true);
// console.log('[Signup] Initiating GitHub OAuth...');
const result = await signInWithGitHub();
// console.log('[Signup] OAuth result:', result);
if (result?.url) {
// console.log('[Signup] Redirecting to GitHub OAuth:', result.url);
globalThis.location.href = result.url;
} else {
// console.error('[Signup] No OAuth URL returned');
setIsGithubLoading(false);
}
} catch (error) {
// console.error('[Signup] GitHub login failed:', error);
setIsGithubLoading(false);
}
};
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">
<div className="flex items-center justify-between mb-6">
<button
onClick={() => navigate('/')}
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 Homepage
</button>
<ThemeToggle />
</div>
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Create Account
</h2>
<p className="text-gray-600 dark:text-gray-400">
Join the hackathon community
</p>
</div>
{error && (
<div className="mb-6 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
</div>
)}
<form onSubmit={handleEmailSignup} className="space-y-4">
<div>
<label
htmlFor="fullname"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Full Name
</label>
<input
id="fullname"
type="text"
value={fullname}
onChange={(e) => setFullname(e.target.value)}
placeholder="John Doe"
disabled={isEmailLoading}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
required
/>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
disabled={isEmailLoading}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
required
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={isEmailLoading}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
required
/>
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
disabled={isEmailLoading}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
required
/>
</div>
<button
type="submit"
disabled={isEmailLoading}
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'}
</button>
</form>
<div className="my-6 flex items-center">
<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-gray-400">
OR
</span>
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
</div>
<button
onClick={handleGithubLogin}
disabled={isGithubLoading}
type="button"
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" />
<span>
{isGithubLoading ? 'Connecting...' : 'Sign up with GitHub'}
</span>
</button>
<div className="mt-6 text-center">
<p className="text-gray-600 dark:text-gray-400 text-sm">
Already have an account?{' '}
<a
href="/auth/login"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
>
Sign in
</a>
</p>
</div>
<div className="mt-6 text-center">
<p className="text-gray-500 dark:text-gray-500 text-xs">
By signing up, you agree to our Terms of Service and Privacy Policy
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,46 @@
import { FC, ReactElement, useState } from 'react';
import { Outlet } from 'react-router';
import { Sidebar } from '../../components/sidebar';
const DashboardLayout: FC = (): ReactElement => {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col">
{/* Mobile Header with Hamburger */}
<div className="lg:hidden sticky top-0 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 flex items-center">
<button
onClick={() => setSidebarOpen(true)}
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-gray-400"
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="ml-3 text-lg font-bold text-gray-900 dark:text-white">
Hackathon
</h1>
</div>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div>
);
};
export default DashboardLayout;
+307
View File
@@ -0,0 +1,307 @@
import { FC, ReactElement, useEffect, useState } from 'react';
import { Link } from 'react-router';
import {
useMyTeams,
useMyInvitations,
useRespondToInvitation,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Icon } from '@iconify/react';
import ProfilePage from '../profile/page';
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 { session } = useAuthStore();
const [showProfileModal, setShowProfileModal] = useState(false);
// 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: invitationsData } = useMyInvitations();
const { mutateAsync: respondToInvitation } = useRespondToInvitation();
const user = session?.user;
const myTeams = teamsData?.data || [];
const invitations: Invitation[] = (invitationsData?.data ||
[]) as Invitation[];
const handleAcceptInvitation = async (invitationId: string) => {
try {
await respondToInvitation({ invitationId, action: 'accept' });
toast.success('Invitation accepted! You are now a team member.');
} catch (error) {
console.error('Failed to accept invitation:', error);
toast.error('Failed to accept invitation');
}
};
const handleRejectInvitation = async (invitationId: string) => {
try {
await respondToInvitation({ invitationId, action: 'reject' });
toast.success('Invitation declined');
} catch (error) {
console.error('Failed to reject invitation:', error);
toast.error('Failed to decline invitation');
}
};
return (
<>
<div className="p-6 md:p-8 md:max-w-7xl w-full mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Welcome, {user?.fullname || user?.email?.split('@')[0] || 'User'}!
</h1>
{user?.location && (
<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" />
<span>{user.location}</span>
</p>
)}
</div>
{invitations.length > 0 && (
<div className="mb-8 bg-primary-50 dark:bg-blue-900/20 border border-primary-200 dark:border-blue-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
Team Invitations ({invitations.length})
</h2>
<div className="space-y-3 font-sans">
{invitations.map((invitation) => (
<div
key={invitation.id}
className="bg-white dark:bg-gray-900 p-4 rounded-lg shadow-sm flex items-center justify-between"
>
<div>
<p className="font-medium text-gray-900 dark:text-white">
{invitation.team?.name ?? 'Unnamed Team'}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Invited by{' '}
{invitation.inviter?.fullname ?? 'Unknown User'}
</p>
</div>
<div className="flex space-x-2">
<Button
size="sm"
onClick={() => handleAcceptInvitation(invitation.id)}
>
Accept
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => handleRejectInvitation(invitation.id)}
>
Decline
</Button>
</div>
</div>
))}
</div>
</div>
)}
{myTeams.length > 0 ? (
<div className="mb-6 md:mb-8">
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
My Team
</h2>
<div className="grid grid-cols-1 gap-4 md:gap-6 w-full md:max-w-lg">
{myTeams.map((team) => (
<Link
key={team.id}
to={'/teams/' + team.id}
className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow"
>
{team.banner && (
<div className="w-full aspect-3/1">
<img
src={team.banner}
alt={team.name}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="p-4">
<div className="flex flex-col md:flex-row items-center space-y-3 space-x-3 -mt-14 md:mt-0 mb-2 md:mb-3 min-w-12 min-h-12 ">
{team.logo && (
<img
src={team.logo}
alt={team.name}
className="w-20 h-20 rounded-full object-cover shrink-0 border-black dark:border-gray-700"
/>
)}
<div>
<h3 className="text-xl md:text-lg font-bold text-gray-900 dark:text-white leading-tight">
{team.name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 flex items-center">
<Icon
icon="heroicons:map-pin-16-solid"
width="16"
height="16"
/>
<span className="ml-1">{team.city}</span>
</p>
</div>
</div>
<p className="text-gray-600 dark:text-gray-400 line-clamp-3 text-ellipsis font-sans">
{team.description}
</p>
</div>
</Link>
))}
</div>
</div>
) : (
<div className="mb-6 md:mb-8 bg-white dark:bg-gray-900 rounded-lg shadow-md p-6 md:p-8">
<div className="text-center">
<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">
You are not in a team yet
</h2>
<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
</p>
</div>
</div>
)}
<div className="mt-8 bg-white dark:bg-gray-900 rounded-xl shadow-lg">
<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 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 items-start">
{user?.avatar ? (
<img
src={user.avatar}
alt={user.fullname || 'User'}
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-gray-700 shadow-lg bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-gray-400 dark:text-gray-500 text-3xl md:text-4xl font-sans">
U
</span>
</div>
)}
<div className="md:ml-6 mt-4 md:mt-14">
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white">
{user?.fullname ||
user?.email?.split('@')[0] ||
'Unnamed User'}
</h2>
{user?.location ? (
<p className="text-gray-600 dark:text-gray-400 flex items-center mt-1 text-sm md:text-base font-sans">
<Icon
icon="heroicons:map-pin-16-solid"
width="16"
height="16"
/>
<span className="ml-1">{user.location}</span>
</p>
) : (
<Link
to="/onboarding/user"
className="text-primary-500 dark:text-blue-400 hover:text-primary-600 dark:hover:text-blue-300 text-sm md:text-sm mt-1 flex items-center"
>
<span className="font-sans">Complete your profile</span>
<Icon
icon="ic:baseline-chevron-right"
width="24"
height="24"
/>
</Link>
)}
</div>
</div>
{user?.location && (
<button
type="button"
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
</button>
)}
</div>
<div className="space-y-4 md:space-y-6 w-full max-w-full">
{user?.bio && (
<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-gray-300 uppercase tracking-wide mb-2">
About
</h3>
<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}
</p>
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm">
<h3 className="font-semibold text-gray-700 dark:text-gray-300 tracking-wide mb-3">
Contact
</h3>
<div className="flex items-center text-gray-700 dark:text-gray-300">
<span className="text-gray-900 dark:text-white">
{user?.email}
</span>
</div>
</div>
{user?.skills && user.skills.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm">
<h3 className="font-semibold text-gray-700 dark:text-gray-300 tracking-wide mb-3">
Skills
</h3>
<div className="flex flex-wrap gap-2">
{user.skills.map((skill: string) => (
<span
key={skill}
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}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
<ProfilePage
open={showProfileModal}
onClose={() => setShowProfileModal(false)}
/>
</>
);
};
export default DashboardPage;
+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>
);
}
+115
View File
@@ -0,0 +1,115 @@
import { Outlet, ScrollRestoration, useLocation, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { supabase } from '@imphnen-frontend-service/service';
// Define onboarding routes
const ONBOARDING_ROUTES = new Set(['/onboarding/user']);
export default function RootLayout() {
const location = useLocation();
const navigate = useNavigate();
const [isChecking, setIsChecking] = useState(true);
useEffect(() => {
const checkAuth = async () => {
const pathname = location.pathname;
// Allow hackathon pages without checks
if (pathname.startsWith('/hackathons')) {
setIsChecking(false);
return;
}
// Allow auth callback without checks
if (pathname === '/auth/callback') {
setIsChecking(false);
return;
}
// Check Supabase session
const { data: { session }, error } = await supabase.auth.getSession();
if (error) {
console.error('[Layout] Session error:', error);
}
// Public auth pages (login, signup, forgot-password, reset-password) - allow unauthenticated access
const isPublicAuthPage = pathname === '/auth/login' || pathname === '/auth/signup' || pathname === '/auth/forgot-password' || pathname === '/auth/reset-password';
if (isPublicAuthPage) {
// If already authenticated and not on password reset pages, redirect to dashboard
if (session && pathname !== '/auth/reset-password') {
navigate('/dashboard', { replace: true });
setIsChecking(false);
return;
}
// Allow unauthenticated access
setIsChecking(false);
return;
}
// Home page - allow everyone to view the landing page
if (pathname === '/') {
setIsChecking(false);
return;
}
// Require authentication for all other routes
if (!session) {
navigate('/auth/login', { replace: true });
setIsChecking(false);
return;
}
// Check if user has completed onboarding (skip for onboarding routes)
if (!ONBOARDING_ROUTES.has(pathname)) {
try {
const { data: userData, error: userError } = await supabase
.from('users')
.select('location')
.eq('id', session.user.id)
.single();
if (userError) {
console.error('[Layout] Failed to fetch user data:', userError);
setIsChecking(false);
return;
}
const hasLocation = !!userData?.location;
if (!hasLocation) {
navigate('/onboarding/user', { replace: true });
setIsChecking(false);
return;
}
} catch (error) {
// Silently handle error
}
}
setIsChecking(false);
};
checkAuth();
}, [location.pathname, navigate]);
// Show loading state while checking auth
if (isChecking) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-neutral-950">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
<p className="text-gray-600 dark:text-neutral-400">Loading...</p>
</div>
</div>
);
}
return (
<>
<Outlet />
<ScrollRestoration />
</>
);
}
@@ -0,0 +1,269 @@
import { FC, ReactElement, useState, useEffect } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate } from 'react-router';
import { useForm, Controller } from 'react-hook-form';
import {
userOnboardingSchema,
TUserOnboardingForm,
useUpdateUserMe,
useUploadAvatar,
useUserMe,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { CitySelect } from '../../../components/city-select';
import { Icon } from '@iconify/react';
const ROLE_OPTIONS = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
const UserOnboardingPage: FC = (): ReactElement => {
const navigate = useNavigate();
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string>('');
const { data: userData } = useUserMe();
const { mutateAsync: updateUser, isPending: isUpdating } = useUpdateUserMe();
const { mutateAsync: uploadAvatar, isPending: isUploading } =
useUploadAvatar();
const { session } = useAuthStore();
const form = useForm<TUserOnboardingForm>({
resolver: zodResolver(userOnboardingSchema),
mode: 'all',
defaultValues: {
fullname: session?.user?.fullname || userData?.data?.fullname || '',
location: session?.user?.location || '',
bio: session?.user?.bio || '',
skills: session?.user?.skills || [],
},
});
// Set initial avatar preview from GitHub avatar if available
useEffect(() => {
if (session?.user?.avatar && !avatarPreview) {
setAvatarPreview(session.user.avatar);
}
}, [session?.user?.avatar, avatarPreview]);
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setAvatarFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const onSubmit = form.handleSubmit(async (data) => {
try {
let avatarUrl = session?.user?.avatar || null;
// Upload avatar if a new file was selected
if (avatarFile) {
const uploadResult = await uploadAvatar(avatarFile);
avatarUrl = uploadResult.data.url;
}
// Update user in Supabase
await updateUser({
fullname: data.fullname,
avatar: avatarUrl,
location: data.location,
bio: data.bio,
skills: data.skills,
});
// Wait a bit for the onSuccess handler to update localStorage
// The updateUser mutation's onSuccess handler updates the Zustand store and localStorage
await new Promise((resolve) => setTimeout(resolve, 100));
// Use window.location for a full page reload to ensure middleware sees updated localStorage
globalThis.location.href = '/dashboard';
} catch (error) {
toast.error(
error instanceof Error
? error.message
: 'Onboarding failed. Please try again.'
);
}
});
return (
<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-gray-900 w-full max-w-2xl p-8 rounded-xl shadow-lg dark:shadow-gray-950/50">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Complete Your Profile
</h1>
<p className="text-gray-600 font-sans dark:text-gray-400">
Tell us more about yourself to get started
</p>
</div>
<form onSubmit={onSubmit} className="space-y-6">
{/* Avatar Upload */}
<div className="flex flex-col items-center space-y-4">
<div className="relative">
{avatarPreview ? (
<img
src={avatarPreview}
alt="Avatar preview"
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-gray-700 flex items-center justify-center">
<Icon
icon="ic:baseline-person"
width="48"
height="48"
className="text-gray-400 dark:text-gray-500"
/>
</div>
)}
</div>
<div className="flex flex-col items-center">
<label htmlFor="avatar" className="cursor-pointer">
<span className="px-4 py-2 bg-primary-500 dark:bg-primary-600 text-white rounded-lg hover:bg-primary-600 dark:hover:bg-primary-700 inline-block">
{avatarPreview ? 'Change Photo' : 'Upload Photo'}
</span>
<input
id="avatar"
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarChange}
/>
</label>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2 text-center">
Optional, but highly recommended
</p>
</div>
</div>
{/* Full Name */}
<ControlledInputField
control={form.control}
label="Full Name"
placeholder="Enter your full name"
name="fullname"
className="px-3 py-2 text-label2 rounded-lg max-h-auto text-base"
size="md"
isRequired={true}
/>
{/* City */}
<div className="space-y-2">
<label className="block text-base font-medium text-gray-700 dark:text-gray-300">
City <span className="text-red-500">*</span>
</label>
<Controller
control={form.control}
name="location"
render={({ field, fieldState }) => (
<CitySelect
value={field.value}
onChange={field.onChange}
error={fieldState.error?.message}
placeholder="Search your city..."
/>
)}
/>
</div>
{/* Role/Skills */}
<div className="space-y-2">
<label className="block text-base font-medium text-gray-700 dark:text-gray-300">
Role / Skills
</label>
<Controller
control={form.control}
name="skills"
render={({ field }) => (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((role) => (
<label
key={role}
className="flex items-center space-x-2 cursor-pointer"
>
<input
type="checkbox"
checked={field.value?.includes(role)}
onChange={(e) => {
const newValue = e.target.checked
? [...(field.value || []), role]
: (field.value || []).filter((v) => v !== role);
field.onChange(newValue);
}}
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-gray-300">
{role}
</span>
</label>
))}
</div>
</div>
)}
/>
</div>
{/* Bio */}
<div className="space-y-2">
<label className="block text-base font-medium text-gray-700 dark:text-gray-300">
Bio{' '}
<span className="text-gray-400 dark:text-gray-500">
(Optional)
</span>
</label>
<Controller
control={form.control}
name="bio"
render={({ field, fieldState }) => (
<div className="flex">
<Textarea
{...field}
placeholder="Tell us about yourself..."
rows={4}
className="w-full rounded-lg text-sm"
style={{ resize: 'vertical' }}
/>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
</div>
<Button
className="w-full"
type="submit"
disabled={!form.formState.isValid || isUpdating || isUploading}
>
{isUpdating || isUploading ? 'Saving...' : 'Complete Setup'}
</Button>
</form>
</div>
</div>
);
};
export default UserOnboardingPage;
+869
View File
@@ -0,0 +1,869 @@
import { useNavigate } from 'react-router';
import { useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Icon } from '@iconify/react';
import { ThemeToggle } from '../components/theme-toggle';
import { useAuthStore } from '@imphnen-frontend-service/service';
export default function HomePage() {
const navigate = useNavigate();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [openFaq, setOpenFaq] = useState<number | null>(0);
const { session } = useAuthStore();
const isAuthenticated = !!session?.token;
const faqs = [
{
question: 'Siapa yang bisa mengikuti hackathon ini?',
answer:
'Hackathon ini terbuka untuk mahasiswa, fresh graduate, dan profesional muda yang memiliki passion di bidang teknologi. Peserta dapat mendaftar secara tim.',
},
{
question: 'Apakah ada biaya pendaftaran?',
answer:
'Tidak, hackathon ini GRATIS dan terbuka untuk semua peserta yang memenuhi kriteria.',
},
{
question: 'Apa tema hackathon kali ini?',
answer:
'Tema hackathon kali ini adalah "Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif".',
},
{
question: 'Bagaimana format pelaksanaannya?',
answer:
'Hackathon dilaksanakan secara online dengan berbagai tahap mulai dari pendaftaran, technical meeting, tahap penyisihan, hingga final.',
},
{
question: 'Apa hadiah lombanya?',
answer:
'Total hadiah senilai Rp14.500.000 dengan juara 1 mendapat Rp6.000.000, juara 2 Rp4.000.000, juara 3 Rp2.500.000, dan juara kategori lainnya Rp2.000.000.',
},
{
question: 'Apakah harus membentuk tim? Boleh solo?',
answer:
'Tidak boleh solo. Peserta harus membentuk tim yang terdiri dari 2 - 5 orang per tim. Peserta bisa mencari anggota melalui website ini atau WA Group Hackathon.',
},
{
question: 'Apakah boleh menggunakan AI (vibe coding)?',
answer:
'Ya, peserta diperbolehkan menggunakan AI tools untuk membantu development. Kami merekomendasikan menggunakan Kolosal.ai selama proses development (Free Credit)',
},
{
question: 'Apa project wajib di-deploy?',
answer:
'Diusahakan project agar di-deploy dan dapat diakses secara online untuk meningkatkan penilaian.',
},
{
question: 'Apakah peserta mendapatkan sertifikat?',
answer:
'Kami akan memberikan sertifikat kepada tim yang submit project dan menyelesaikan rangkaian hackathon.',
},
{
question: 'Website error dan terjadi masalah?',
answer:
'Jika menemukan masalah teknis, silakan hubungi kami melalui grup WA Hackathon.',
},
];
return (
<main className="min-h-screen bg-white dark:bg-gray-950">
{/* Navigation */}
<div id="#top" className="hidden"></div>
<nav className="border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 sticky top-0 z-50">
<div className="flex items-center justify-between max-w-7xl mx-auto px-4 md:px-8 py-4">
<div className="flex items-center gap-2">
<span className="text-lg md:text-xl font-bold dark:text-white">
IMPHNEN
</span>
<a
href="#top"
className="text-lg md:text-xl font-bold text-primary-500 hover:cursor-pointer"
>
Hackathon
</a>
</div>
{/* Desktop Menu */}
<div className="hidden md:flex text-label1 items-center gap-4 lg:gap-8">
<a
href="#timeline"
className="text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white transition-colors"
>
Timeline
</a>
<a
href="#hadiah"
className="text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white transition-colors"
>
Hadiah
</a>
<a
href="#faq"
className="text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white transition-colors"
>
FAQ
</a>
<ThemeToggle />
{isAuthenticated ? (
<Button
onClick={() => navigate('/dashboard')}
size="sm"
className="rounded-lg text-base"
>
Dashboard
</Button>
) : (
<>
<Button
onClick={() => navigate('/auth/login')}
size="sm"
variant="bordered"
className="rounded-lg text-base dark:bg-gray-800"
>
Masuk
</Button>
<Button
onClick={() => navigate('/auth/signup')}
size="sm"
className="rounded-lg text-base"
>
Daftar Sekarang
</Button>
</>
)}
</div>
{/* Mobile Menu Button */}
<div className="flex items-center gap-2 md:hidden">
<ThemeToggle />
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="p-2 cursor-pointer text-gray-900 dark:text-white"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
</div>
</div>
</nav>
{/* Mobile Menu */}
{mobileMenuOpen && (
<nav className="md:hidden sticky top-18 border-b border-gray-200 dark:border-gray-800 bg-white z-40 dark:bg-gray-900">
<div className="flex flex-col items-start gap-4 px-4 py-4">
<a
href="#timeline"
className="ms-3 text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white"
>
Timeline
</a>
<a
href="#hadiah"
className="ms-3 text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white"
>
Hadiah
</a>
<a
href="#faq"
className="ms-3 text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white"
>
FAQ
</a>
{isAuthenticated ? (
<button
onClick={() => navigate('/dashboard')}
className="px-4 py-2 bg-primary-500 text-white text-base rounded-lg hover:bg-primary-600 transition-colors text-center cursor-pointer"
>
Dashboard
</button>
) : (
<>
<button
onClick={() => navigate('/auth/login')}
className="ms-3 text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white cursor-pointer"
>
Masuk
</button>
<button
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"
>
Daftar Sekarang
</button>
</>
)}
</div>
</nav>
)}
{/* Hero Section */}
<section className="relative w-full overflow-hidden py-20">
<div className="absolute inset-0 overflow-hidden">
<div
className="absolute top-1/4 -left-20 w-100 h-100 rounded-full bg-linear-to-r from-primary/20 to-blue-400/20 blur-3xl"
style={{ transform: 'translate(10px, -5px)', opacity: 0.9 }}
></div>
<div
className="absolute bottom-1/3 -right-20 w-100 h-100 rounded-full bg-linear-to-r from-blue-400/20 to-primary/20 blur-3xl"
style={{ transform: 'translate(0px, 0px)', opacity: 1 }}
></div>
<div className="absolute inset-0 bg-[linear-gradient(rgba(59,130,246,0.05)_1px,transparent_1px),linear-gradient(to_right,rgba(59,130,246,0.05)_1px,transparent_1px)] dark:bg-[linear-gradient(rgba(59,130,246,0.1)_1px,transparent_1px),linear-gradient(to_right,rgba(59,130,246,0.1)_1px,transparent_1px)] bg-size-[40px_40px]"></div>
</div>
<div className="mx-auto container px-4 relative flex flex-col items-center">
{/* Logos */}
<div className="flex items-center gap-4 md:gap-8 lg:gap-12 mb-8 md:mb-12 lg:mb-16 flex-wrap justify-center">
<div className="flex items-center">
<img
src="images/imphnen-logo.svg"
alt="IMPHNEN"
className="h-12 md:h-16"
/>
</div>
<span className="text-3xl md:text-5xl font-bold text-gray-400 dark:text-gray-500">
×
</span>
<div className="flex items-center">
<img
src="images/sponsors/kolosal-logo_rlxbck.svg"
alt="Kolosal.ai"
className="h-8 md:h-12 dark:invert"
/>
</div>
</div>
{/* Title */}
<h1 className="text-h1 font-bold text-gray-900 dark:text-white mb-4 text-center">
Hackathon
</h1>
{/* Subtitle */}
<p className="text-p1 text-primary-500 font-semibold mb-6 md:mb-8 text-center px-4">
"Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif"
</p>
{/* Description */}
<p className="text-p3 text-gray-600 dark:text-gray-200 max-w-lg md:max-w-xl text-center mb-8 md:mb-12 px-4 font-sans">
Kompetisi pengembangan teknologi untuk menciptakan solusi inovatif
yang menghadirkan dampak nyata
</p>
{/* Status */}
<div className="flex flex-col md:flex-row items-center gap-4 md:gap-8 mb-10 md:mb-16 text-base text-gray-600 dark:text-gray-200">
<div className="flex items-center gap-2 md:gap-3">
<Icon icon="streamline-plump:web" className="w-4 h-4" />
<span>Online</span>
</div>
<div className="flex items-center gap-2 md:gap-3 text-center">
<Icon icon="heroicons:clock" className="w-4 h-4" />
<span>Pendaftaran hingga 30 November 2025</span>
</div>
</div>
{/* CTA Buttons */}
<div className="flex flex-col md:flex-row items-center gap-4 px-4">
<Button
onClick={() => navigate('/auth/signup')}
className="rounded-lg text-base max-h-auto"
>
Daftar Sekarang
</Button>
<a
href="https://chat.whatsapp.com/BlxrYh9uSC37d7VPhJslGL"
target="_blank"
rel="noopener noreferrer"
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
</a>
</div>
{/* Scroll indicator */}
<div className="mt-12 md:mt-20">
<svg
className="w-6 h-6 text-gray-400 dark:text-gray-500 animate-bounce"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
</div>
</div>
</section>
{/* About Section */}
<section className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-gray-950">
<div className="max-w-4xl mx-auto text-center">
<h2 className="text-3xl md:text-5xl font-bold mb-10 dark:text-white">
Tentang <span className="text-primary-500">Hackathon</span>
</h2>
<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">
Hackathon
</span>{' '}
adalah kompetisi pengembangan teknologi yang mengajak talenta muda
untuk berkolaborasi dalam menciptakan solusi inovatif.
</p>
<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{' '}
<span className="font-semibold text-primary-500">
"Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif"
</span>
.
</p>
</div>
</section>
{/* Prizes Section */}
<section
id="hadiah"
className="py-16 md:py-24 px-4 md:px-8 bg-gray-50 dark:bg-linear-to-b dark:from-gray-950 dark:to-gray-900"
>
<div className="max-w-lg md:max-w-6xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-5xl font-bold mb-4 font-bai-jamjuree dark:text-white">
Hadiah <span className="text-primary-500">Menarik</span>
</h2>
<p className="text-xl md:text-2xl text-primary-500 font-semibold font-sans">
Total Prize Pool Rp14.500.000
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Prize 1 */}
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-primary-500 dark:hover:border-primary-500 transition-colors">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
<Icon
icon="ic:round-star"
className="h-8 w-8 text-primary-500"
/>
</div>
</div>
<h3 className="text-xl font-bold text-center mb-2 dark:text-white">
Juara 1
</h3>
<p className="text-2xl font-bold text-primary-500 text-center font-sans">
Rp6.000.000
</p>
</div>
{/* Prize 2 */}
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-gray-500 dark:hover:border-gray-500 transition-colors">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center">
<Icon
icon="ic:round-star"
className="h-8 w-8 text-gray-600 dark:text-gray-200"
/>
</div>
</div>
<h3 className="text-xl font-bold text-center mb-2 dark:text-white">
Juara 2
</h3>
<p className="text-2xl font-bold text-primary-500 text-center font-sans">
Rp4.000.000
</p>
</div>
{/* Prize 3 */}
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-orange-300 dark:hover:border-orange-500 transition-colors">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center">
<Icon
icon="ic:round-star"
className="h-8 w-8 text-orange-600 dark:text-orange-500"
/>
</div>
</div>
<h3 className="text-xl font-bold text-center mb-2 dark:text-white">
Juara 3
</h3>
<p className="text-2xl font-bold text-orange-600 dark:text-orange-500 text-center font-sans">
Rp2.500.000
</p>
</div>
{/* Special Prize */}
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-purple-300 dark:hover:border-purple-500 transition-colors">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
<Icon
icon="ic:round-star"
className="h-8 w-8 text-purple-600 dark:text-purple-500"
/>
</div>
</div>
<h3 className="text-xl font-bold text-center mb-2 dark:text-white">
Juara Kategori Lainnya
</h3>
<p className="text-2xl font-bold text-purple-600 dark:text-purple-500 text-center font-sans">
Rp2.000.000
</p>
</div>
</div>
</div>
</section>
{/* Timeline Section */}
<section
id="timeline"
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="text-center mb-12 font-bai-jamjuree">
<h2 className="text-3xl md:text-5xl font-bold mb-4 dark:text-white">
Timeline <span className="text-primary-500">Acara</span>
</h2>
<p className="text-lg text-gray-600 dark:text-gray-200">
Jadwal lengkap pelaksanaan hackathon
</p>
</div>
<div>
{/* Timeline Item 1 */}
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300 dark:bg-gray-700"></div>
</div>
<div className="flex-1 pb-8">
<p className="text-primary-500 font-semibold mb-2">
30 November 2025
</p>
<h3 className="text-xl font-bold mb-2 dark:text-white">
Penutupan Registrasi
</h3>
<p className="text-gray-600 dark:text-gray-200">
Batas akhir pendaftaran peserta hackathon.
</p>
</div>
</div>
{/* Timeline Item 2 */}
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300 dark:bg-gray-700"></div>
</div>
<div className="flex-1 pb-8">
<p className="text-primary-500 font-semibold mb-2">
30 November 2025
</p>
<h3 className="text-xl font-bold mb-2 dark:text-white">
Technical Meeting
</h3>
<p className="text-gray-600 dark:text-gray-200">
Akan diadakan technical meeting terkait lomba melalui Google
Meet. Stay tune di grup WA Hackathon.
</p>
</div>
</div>
{/* Timeline Item 3 */}
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300 dark:bg-gray-700"></div>
</div>
<div className="flex-1 pb-8">
<p className="text-primary-500 font-semibold mb-2">
1 - 7 Desember 2025
</p>
<h3 className="text-xl font-bold mb-2 dark:text-white">
Tahap Penyisihan
</h3>
<p className="text-gray-600 dark:text-gray-200">
Peserta mengerjakan tantangan yang diberikan.
</p>
</div>
</div>
{/* Timeline Item 4 */}
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300 dark:bg-gray-700"></div>
</div>
<div className="flex-1 pb-8">
<p className="text-primary-500 font-semibold mb-2">
8 - 14 Desember 2025
</p>
<h3 className="text-xl font-bold mb-2 dark:text-white">
Penilaian & Webinar
</h3>
<p className="text-gray-600 dark:text-gray-200">
Proses penilaian oleh juri dan sesi webinar.
</p>
</div>
</div>
{/* Timeline Item 5 */}
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
</div>
<div className="flex-1">
<p className="text-primary-500 font-semibold mb-2">
15 Desember 2025
</p>
<h3 className="text-xl font-bold mb-2 dark:text-white">
Pengumuman & Final
</h3>
<p className="text-gray-600 dark:text-gray-200">
Presentasi final dan pengumuman pemenang.
</p>
</div>
</div>
</div>
</div>
</section>
{/* Judges Section */}
<section className="py-16 md:py-24 px-4 md:px-8 bg-gray-50 dark:bg-gray-900 font-sans">
<div className="md:max-w-6xl mx-auto">
<div className="text-center mb-12 font-bai-jamjuree">
<h2 className="text-3xl md:text-5xl font-bold mb-4 dark:text-white">
Dewan <span className="text-primary-500">Juri</span>
</h2>
<p className="text-lg text-gray-600 dark:text-gray-200">
Perwakilan dari IMPHNEN dan Kolosal.ai yang akan menilai karya
</p>
</div>
<div className="w-full max-w-md md:max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Judge 1 */}
<div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
<h3 className="text-p3 font-bold mb-1 dark:text-white">
Alifais Farrel Ramdhani
</h3>
<div>
<p className="text-primary-500 font-semibold mb-1">CTO</p>
<p className="text-gray-600 dark:text-gray-200">Kolosal.ai</p>
</div>
</div>
{/* Judge 2 */}
<div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
<h3 className="text-p3 font-bold mb-1 dark:text-white">
Anka Tama
</h3>
<div>
<p className="text-primary-500 font-semibold mb-1">Admin</p>
<p className="text-gray-600 dark:text-gray-200">IMPHNEN</p>
</div>
</div>
{/* Judge 3 */}
<div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
<h3 className="text-p3 font-bold mb-1 dark:text-white">
Hafid Nur
</h3>
<div>
<p className="text-primary-500 font-semibold mb-1">Moderator</p>
<p className="text-gray-600 dark:text-gray-200">IMPHNEN</p>
</div>
</div>
</div>
</div>
</section>
{/* FAQ Section */}
<section
id="faq"
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="text-center mb-12">
<h2 className="text-3xl md:text-5xl font-bold mb-4 text-primary-500">
FAQ
</h2>
<p className="text-lg text-gray-600 dark:text-gray-200">
Pertanyaan yang Sering Diajukan
</p>
</div>
<div className="space-y-4 font-sans">
{faqs.map((faq, index) => (
<div
key={index}
className="border border-gray-200 dark:border-gray-700 rounded-lg dark:bg-gray-900"
>
<button
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-gray-800 transition-colors cursor-pointer"
>
<span className="font-semibold text-gray-900 dark:text-white">
{faq.question}
</span>
<svg
className={`w-5 h-5 text-primary-500 transform transition-transform ${
openFaq === index ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{openFaq === index && (
<div className="px-6 pb-4 text-gray-600 dark:text-gray-200">
{faq.answer}
</div>
)}
</div>
))}
</div>
</div>
</section>
{/* Sponsors Section */}
<section className="py-20 md:py-28 px-4 md:px-8 bg-gray-50 dark:bg-gray-900">
<div className="max-w-6xl mx-auto text-center">
<h2 className="text-3xl md:text-5xl font-bold mb-4 dark:text-white">
Sponsor & <span className="text-primary-500">Partner</span>
</h2>
<p className="text-lg text-gray-600 dark:text-gray-200 mb-8">
Acara ini sepenuhnya disponsori oleh
</p>
<div className="flex justify-center">
<a
href="https://kolosal.ai"
target="_blank"
rel="noopener noreferrer"
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
src="images/sponsors/kolosal-logo_rlxbck.svg"
alt="Kolosal.ai"
className="h-12 md:h-16 dark:invert"
/>
</a>
</div>
</div>
</section>
{/* CTA Section */}
<section
id="masuk"
className="py-20 md:py-28 px-4 md:px-8 bg-linear-to-b from-white to-blue-50 dark:from-gray-950 dark:to-gray-900"
>
<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">
Segera Daftarkan <span className="text-primary-500">Timmu!</span>
</h2>
<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
terbaik,
<br className="hidden md:block" /> belajar dari para ahli, dan
memenangkan hadiah jutaan rupiah!
</p>
<div className="flex flex-col md:flex-row gap-4 justify-center">
<button
onClick={() => navigate('/auth/signup')}
className="px-8 py-3 bg-primary-500 text-white text-lg rounded-lg hover:bg-primary-700 transition-colors font-semibold cursor-pointer"
>
Daftar Sekarang
</button>
<a
href="https://chat.whatsapp.com/BlxrYh9uSC37d7VPhJslGL"
target="_blank"
rel="noopener noreferrer"
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
</a>
</div>
<p className="text-sm text-gray-500 dark:text-gray-500 mt-6">
Pendaftaran ditutup pada{' '}
<span className="text-primary-500 font-semibold">
30 November 2025
</span>
</p>
</div>
</section>
{/* Footer */}
<footer className="bg-gray-950 text-white py-12 px-4 md:px-8 font-sans">
<div className="max-w-6xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
{/* Brand */}
<div>
<div className="flex items-center gap-2 mb-4 font-bai-jamjuree">
<span className="text-2xl font-bold">IMPHNEN</span>
<span className="text-2xl font-bold text-blue-500">
Hackathon
</span>
</div>
<p className="text-gray-400 text-sm">
Wujudkan ide brilian mu menjadi solusi nyata. Bergabunglah dalam
IMPHNEN Hackathon dan jadilah bagian dari perubahan teknologi
masa depan.
</p>
<div className="flex gap-4 mt-4">
<div className="flex gap-4">
<a
href="https://fb.com/groups/programmerhandal"
className="text-gray-400 hover:text-accent transition-colors"
target="_blank"
rel="noopener noreferrer"
aria-label="Facebook"
>
<Icon icon="ic:baseline-facebook" className="w-6 h-6" />
</a>
<a
href="https://www.instagram.com/imphnen.dev"
className="text-gray-400 hover:text-accent transition-colors"
target="_blank"
rel="noopener noreferrer"
aria-label="Instagram"
>
<Icon icon="mdi:instagram" className="w-6 h-6" />
</a>
<a
href="https://www.linkedin.com/company/imphnen"
className="text-gray-400 hover:text-accent transition-colors"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn"
>
<Icon icon="mdi:linkedin" className="w-6 h-6" />
</a>
<a
href="https://www.tiktok.com/@imphnen"
className="text-gray-400 hover:text-accent transition-colors"
target="_blank"
rel="noopener noreferrer"
aria-label="TikTok"
>
<Icon icon="ic:baseline-tiktok" className="w-6 h-6" />
</a>
<a
href="https://github.com/IMPHNEN"
className="text-gray-400 hover:text-accent transition-colors"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
>
<Icon icon="mdi:github" className="w-6 h-6" />
</a>
</div>
</div>
</div>
{/* Quick Links */}
<div>
<h3 className="font-bold text-lg mb-4 font-bai-jamjuree">
Quick Links
</h3>
<ul className="space-y-2 text-gray-400">
<li>
<a
href="#timeline"
className="hover:text-white transition-colors"
>
Timeline
</a>
</li>
<li>
<a
href="#hadiah"
className="hover:text-white transition-colors"
>
Hadiah
</a>
</li>
<li>
<a href="#faq" className="hover:text-white transition-colors">
FAQ
</a>
</li>
<li>
<a
href="/auth/signup"
className="hover:text-white transition-colors"
>
Daftar
</a>
</li>
</ul>
</div>
{/* Contact */}
<div>
<h3 className="font-bold text-lg mb-4 font-bai-jamjuree">
Contact
</h3>
<ul className="space-y-2 text-gray-400 text-sm">
<li className="flex items-start gap-2">
<Icon
icon="material-symbols:mail-outline-rounded"
className="min-w-5 min-h-5 mt-0.5"
/>
<a
href="mailto:imphnen@gmail.com"
className="hover:text-white transition-colors"
>
imphnen@gmail.com
</a>
</li>
<li className="flex items-start gap-2">
<Icon
icon="solar:phone-linear"
className="min-w-5 min-h-5 mt-0.5"
/>
<a
href="https://chat.whatsapp.com/BlxrYh9uSC37d7VPhJslGL"
className="hover:text-white transition-colors"
target="_blank"
rel="noopener noreferrer"
>
WA Group Hackathon
</a>
</li>
<li className="flex items-start gap-2">
<Icon
icon="streamline-plump:web"
className="min-w-5 min-h-5 mt-0.5"
/>
<a
href="https://imphnen.dev"
className="hover:text-white transition-colors"
target="_blank"
rel="noopener noreferrer"
>
IMPHNEN.dev
</a>
</li>
</ul>
</div>
</div>
<div className="border-t border-gray-800 pt-8 text-center text-gray-400 text-sm">
<p>
© 2025 IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan
Ngoding. All rights reserved.
</p>
</div>
</div>
</footer>
</main>
);
}
+389
View File
@@ -0,0 +1,389 @@
import { FC, ReactElement, useState, useEffect } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useForm, Controller } from 'react-hook-form';
import {
userEditProfileSchema,
TUserEditProfileForm,
useUpdateUserMe,
useUploadAvatar,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { CitySelect } from '../../components/city-select';
import { Icon } from '@iconify/react';
const ROLE_OPTIONS = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
type ProfileModalProps = {
open: boolean;
onClose: () => void;
};
const ProfilePage: FC<ProfileModalProps> = ({
open,
onClose,
}): ReactElement | null => {
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string>('');
const { mutateAsync: updateUser, isPending: isUpdating } = useUpdateUserMe();
const { mutateAsync: uploadAvatar, isPending: isUploading } =
useUploadAvatar();
const { session } = useAuthStore();
const form = useForm<TUserEditProfileForm>({
resolver: zodResolver(userEditProfileSchema),
mode: 'all',
defaultValues: {
fullname: session?.user?.fullname || '',
avatar: session?.user?.avatar || null,
location: session?.user?.location || '',
bio: session?.user?.bio || '',
skills: session?.user?.skills || [],
},
});
// Set initial avatar preview from current user avatar
useEffect(() => {
if (session?.user?.avatar && !avatarPreview) {
setAvatarPreview(session.user.avatar);
}
if (session?.user?.fullname) {
form.setValue('fullname', session.user.fullname);
}
if (session?.user?.location) {
form.setValue('location', session.user.location);
}
if (session?.user?.bio) {
form.setValue('bio', session.user.bio);
}
if (session?.user?.skills) {
form.setValue('skills', session.user.skills);
}
}, [
session?.user?.avatar,
session?.user?.fullname,
session?.user?.location,
session?.user?.bio,
session?.user?.skills,
avatarPreview,
form,
]);
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
toast.error('The file is too large. Maximum 5MB');
return;
}
// Validate file type
if (!file.type.startsWith('image/')) {
toast.error('The file must be an image');
return;
}
setAvatarFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const onSubmit = form.handleSubmit(async (data) => {
try {
let avatarUrl = session?.user?.avatar || null;
// Upload avatar if a new file was selected
if (avatarFile) {
const uploadResult = await uploadAvatar(avatarFile);
avatarUrl = uploadResult.data.url;
}
// Update user profile
await updateUser({
fullname: data.fullname,
avatar: avatarUrl,
location: data.location,
bio: data.bio,
skills: data.skills,
});
toast.success('Profile updated successfully!');
// Wait a bit for the onSuccess handler to update localStorage
await new Promise((resolve) => setTimeout(resolve, 100));
// Close modal
onClose();
} catch (error) {
console.error('Profile update failed:', error);
toast.error(
`Failed to update profile: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
});
const isLoading = isUpdating || isUploading;
return open ? (
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-md z-50 overflow-y-auto">
<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="flex items-center justify-between mb-2">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Edit Profile
</h1>
<button
type="button"
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
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<p className="text-gray-600 font-sans dark:text-neutral-400">
Update your photo and name
</p>
</div>
<form onSubmit={onSubmit} className="space-y-6">
{/* Avatar Upload */}
<div className="flex flex-col items-center space-y-4">
<div className="relative group">
{avatarPreview ? (
<img
src={avatarPreview}
alt="Avatar preview"
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-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="ic:baseline-person"
width="48"
height="48"
className="text-gray-400 dark:text-neutral-500"
/>
</div>
)}
{/* Camera overlay */}
<label
htmlFor="avatar"
className="absolute bottom-0 right-0 bg-primary-500 text-white p-2 rounded-full cursor-pointer hover:bg-primary-600 transition-colors shadow-lg"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<input
id="avatar"
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarChange}
disabled={isLoading}
/>
</label>
</div>
<p className="text-sm text-gray-500 dark:text-neutral-400 text-center font-sans">
Click the camera icon to change your photo
<br />
Format: JPG, PNG. Max 5MB
</p>
</div>
{/* Full Name */}
<ControlledInputField
control={form.control}
label="Full Name"
placeholder="Enter your full name"
name="fullname"
size="lg"
/>
{/* City */}
<div className="space-y-2">
<label className="block text-label1 font-medium text-neutral-800 dark:text-neutral-300">
City
</label>
<Controller
control={form.control}
name="location"
render={({ field, fieldState }) => (
<CitySelect
value={field.value ?? ''}
onChange={field.onChange}
error={fieldState.error?.message}
placeholder="Search your city..."
/>
)}
/>
</div>
{/* Role/Skills */}
<div className="space-y-2">
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">
Role / Skills
</label>
<Controller
control={form.control}
name="skills"
render={({ field }) => (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((role) => (
<label
key={role}
className="flex items-center space-x-2 cursor-pointer font-sans"
>
<input
type="checkbox"
checked={field.value?.includes(role)}
onChange={(e) => {
const newValue = e.target.checked
? [...(field.value || []), role]
: (field.value || []).filter((v) => v !== role);
field.onChange(newValue);
}}
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">
{role}
</span>
</label>
))}
</div>
</div>
)}
/>
</div>
{/* Bio */}
<div className="space-y-2">
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">
Bio{' '}
<span className="text-gray-400 dark:text-neutral-500">
(Optional)
</span>
</label>
<Controller
control={form.control}
name="bio"
render={({ field, fieldState }) => (
<div>
<Textarea
{...field}
placeholder="Tell us about yourself..."
rows={4}
className="w-full font-sans"
size="lg"
/>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
</div>
{/* Action Buttons */}
<div className="flex space-x-3 pt-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={onClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={isLoading || !form.formState.isValid}
>
{isLoading ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Saving...
</span>
) : (
'Save'
)}
</Button>
</div>
</form>
</div>
</div>
</div>
) : null;
};
export default ProfilePage;
@@ -0,0 +1,318 @@
import { FC, ReactElement, useState, useRef, useEffect, useMemo } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import {
useTeamById,
useTeamMessages,
useSendMessage,
useDeleteMessage,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
const TeamChatPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [message, setMessage] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const { data: teamData } = useTeamById(teamId || '');
const { data: messages, isLoading } = useTeamMessages(teamId || '');
const { mutateAsync: sendMessage, isPending: isSending } = useSendMessage(
teamId || ''
);
const { mutateAsync: deleteMessage } = useDeleteMessage(teamId || '');
const team = teamData?.data;
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
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [displayMessages]);
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim() || isSending) return;
try {
await sendMessage(message.trim());
setMessage('');
} catch (error) {
console.error('Failed to send message:', error);
toast.error('Failed to send message');
}
};
const handleDeleteMessage = async (messageId: string) => {
try {
await deleteMessage(messageId);
toast.success('Message deleted');
setDeleteTargetId(null);
} catch (error) {
console.error('Failed to delete message:', error);
toast.error('Failed to delete message');
}
};
const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diffInMs = now.getTime() - date.getTime();
const diffInMins = Math.floor(diffInMs / 60000);
if (diffInMins < 1) return 'Just now';
if (diffInMins < 60) return `${diffInMins}m ago`;
if (diffInMins < 1440) return `${Math.floor(diffInMins / 60)}h ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
};
return (
<div className="flex flex-col h-screen bg-gray-50 dark:bg-gray-950">
{/* Header */}
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 shadow-sm dark:shadow-gray-950/50">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Team Chat
</h1>
<p className="text-gray-600 dark:text-gray-400 text-sm mt-0.5">
{team?.name}
</p>
</div>
<Button
variant="secondary"
onClick={() => navigate(`/teams/${teamId}`)}
>
Back to Team
</Button>
</div>
</div>
</div>
{/* Messages Container */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{isLoading ? (
<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>
) : displayMessages && displayMessages.length > 0 ? (
<div className="space-y-4">
{displayMessages.map((msg) => {
const isOwnMessage = msg.user_id === currentUserId;
const isLeader = team?.leader_id === currentUserId;
const canDelete = isOwnMessage || isLeader;
return (
<div
key={`${msg.id ?? 'message'}-${msg.created_at}`}
className={`flex ${
isOwnMessage ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`flex gap-3 max-w-lg ${
isOwnMessage ? 'flex-row-reverse' : 'flex-row'
}`}
>
{/* Avatar */}
<div className="shrink-0">
{msg.user?.avatar ? (
<img
src={msg.user.avatar}
alt={msg.user.fullname}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div className="w-10 h-10 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold">
{msg.user?.fullname?.charAt(0) || '?'}
</div>
)}
</div>
{/* Message Bubble */}
<div className={`flex-1 text-left`}>
<div
className={`inline-block ${
isOwnMessage ? 'items-end' : 'items-start'
}`}
>
<div className="flex items-baseline gap-2 mb-1">
<span className="font-semibold text-sm text-gray-900 dark:text-white">
{isOwnMessage ? 'You' : msg.user?.fullname}
</span>
<span className="text-xs text-gray-500 dark:text-gray-500">
{formatTime(msg.created_at)}
</span>
</div>
<div
className={`relative group rounded-2xl px-4 py-2.5 ${
isOwnMessage
? 'bg-blue-600 dark:bg-primary-600 text-white'
: '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 wrap-break-word font-sans">
{msg.message}
</p>
{/* Delete button */}
{canDelete && (
<button
onClick={() =>
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"
>
{/* Icon trash */}
<Icon
icon="mdi:trash-can-outline"
className="w-4 h-4"
/>
</button>
)}
</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 ref={messagesEndRef} />
</div>
) : (
<div className="flex flex-col items-center justify-center h-64 text-center">
<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">
No messages yet
</h3>
<p className="text-gray-600 dark:text-gray-400 font-sans">
Be the first to start the conversation!
</p>
</div>
)}
</div>
</div>
{/* Message Input */}
<div className="bg-white dark:bg-gray-900 border-t dark:border-gray-700 shadow-lg dark:shadow-gray-950/50">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<form onSubmit={handleSendMessage} className="flex gap-3">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
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}
/>
<Button
type="submit"
disabled={!message.trim() || isSending}
className="px-6 py-3"
>
{isSending ? (
<div className="flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Sending...
</div>
) : (
<div className="flex items-center gap-2">
<svg
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>
Send
</div>
)}
</Button>
</form>
</div>
</div>
</div>
);
};
export default TeamChatPage;
@@ -0,0 +1,379 @@
import { FC, ReactElement, useState, useEffect } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import { useForm, Controller } from 'react-hook-form';
import {
teamUpdateSchema,
TTeamUpdateForm,
useUpdateTeam,
useTeamById,
ETeamVisibility,
useUploadFile,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { CitySelect } from '../../../../components/city-select';
import { Icon } from '@iconify/react';
const EditTeamPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string>('');
const [bannerFile, setBannerFile] = useState<File | null>(null);
const [bannerPreview, setBannerPreview] = useState<string>('');
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
teamId || ''
);
const { mutateAsync: updateTeam, isPending: isUpdating } = useUpdateTeam(
teamId || ''
);
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const team = teamData?.data;
const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id;
const form = useForm<TTeamUpdateForm>({
resolver: zodResolver(teamUpdateSchema),
mode: 'all',
});
useEffect(() => {
if (team) {
form.reset({
name: team.name,
description: team.description,
city: team.city,
visibility: team.visibility,
logo: team.logo,
banner: team.banner,
});
if (team.logo) setLogoPreview(team.logo);
if (team.banner) setBannerPreview(team.banner);
}
}, [team, form]);
if (isLoadingTeam) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-gray-600 dark:text-gray-400">Loading team...</div>
</div>
);
}
if (!isLeader) {
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">
Access Denied
</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>
);
}
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setLogoFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setLogoPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setBannerFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setBannerPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const onSubmit = form.handleSubmit(async (data) => {
try {
let logoUrl = data.logo;
let bannerUrl = data.banner;
if (logoFile) {
const logoResult = await uploadFile(logoFile);
logoUrl = logoResult.data.url;
}
if (bannerFile) {
const bannerResult = await uploadFile(bannerFile);
bannerUrl = bannerResult.data.url;
}
await updateTeam({
...data,
logo: logoUrl,
banner: bannerUrl,
});
navigate(`/teams/${teamId}`);
} catch (error) {
console.error('Failed to update team:', error);
}
});
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
<div className="max-w-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>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Update your team details
</p>
</div>
</div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-8">
<form onSubmit={onSubmit} className="space-y-6">
{/* Banner Upload */}
<div>
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2">
Team Banner
</label>
{bannerPreview ? (
<div
className="relative w-full"
style={{ aspectRatio: '3 / 1' }}
>
<img
src={bannerPreview}
alt="Banner preview"
className="absolute inset-0 w-full h-full object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700"
/>
<button
type="button"
onClick={() => {
setBannerFile(null);
setBannerPreview('');
form.setValue('banner', null);
}}
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
</button>
</div>
) : (
<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">
<p className="text-gray-500 dark:text-gray-400">
Click to upload banner
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
1200x400 recommended
</p>
</div>
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleBannerChange}
/>
</label>
)}
</div>
{/* Logo Upload */}
<div>
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2">
Team Logo
</label>
<div className="flex items-center space-x-4">
{logoPreview ? (
<img
src={logoPreview}
alt="Logo preview"
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-gray-700 flex items-center justify-center">
<span className="text-gray-400 dark:text-gray-500 text-3xl">
<Icon icon="mdi:account-group" />
</span>
</div>
)}
<div className="flex flex-col md:flex-row items-start justify-start gap-1">
<label htmlFor="logo" className="cursor-pointer">
<span className="px-4 py-2 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700 inline-block">
{logoPreview ? 'Change Logo' : 'Upload Logo'}
</span>
<input
id="logo"
type="file"
accept="image/*"
className="hidden"
onChange={handleLogoChange}
/>
</label>
{logoPreview && (
<button
type="button"
onClick={() => {
setLogoFile(null);
setLogoPreview('');
form.setValue('logo', null);
}}
className="px-4 py-2 text-sm bg-danger-600 text-white rounded-lg hover:bg-danger-700 cursor-pointer"
>
Remove
</button>
)}
</div>
</div>
</div>
{/* Team Name */}
<ControlledInputField
control={form.control}
label="Team Name"
placeholder="Enter team name"
name="name"
size="lg"
isRequired={true}
/>
{/* City */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
City
</label>
<Controller
control={form.control}
name="city"
render={({ field, fieldState }) => (
<CitySelect
value={field.value || ''}
onChange={field.onChange}
error={fieldState.error?.message}
placeholder="Search your city..."
/>
)}
/>
</div>
{/* Description */}
<div className="space-y-2">
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300">
Description
</label>
<Controller
control={form.control}
name="description"
render={({ field, fieldState }) => (
<div>
<Textarea
{...field}
value={field.value || ''}
placeholder="Tell others about your team..."
rows={4}
className="w-full"
size="lg"
/>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
</div>
{/* Visibility */}
<div className="space-y-2">
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300">
Team Visibility
</label>
<Controller
control={form.control}
name="visibility"
render={({ field }) => (
<div className="space-y-3">
<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
type="radio"
{...field}
value={ETeamVisibility.PUBLIC}
checked={field.value === ETeamVisibility.PUBLIC}
className="mt-1"
/>
<div>
<p className="font-medium text-gray-900 dark:text-white">
Public
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 font-sans">
Team will be visible in Browse Teams
</p>
</div>
</label>
<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
type="radio"
{...field}
value={ETeamVisibility.PRIVATE}
checked={field.value === ETeamVisibility.PRIVATE}
className="mt-1"
/>
<div>
<p className="font-medium text-gray-900 dark:text-white">
Private
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 font-sans">
Team hidden, invite-only
</p>
</div>
</label>
</div>
)}
/>
</div>
{/* Submit Button */}
<div className="flex space-x-3 pt-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => navigate(`/teams/${teamId}`)}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={isUpdating || isUploading}
>
{isUpdating || isUploading ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</div>
</div>
</div>
);
};
export default EditTeamPage;
@@ -0,0 +1,46 @@
import { FC, ReactElement, useState } from 'react';
import { Outlet } from 'react-router';
import { Sidebar } from '../../../components/sidebar';
const TeamDetailLayout: FC = (): ReactElement => {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col">
{/* Mobile Header with Hamburger */}
<div className="lg:hidden sticky top-0 z-30 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 flex items-center">
<button
onClick={() => setSidebarOpen(true)}
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-gray-400"
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="ml-3 text-lg font-bold text-gray-900 dark:text-white">
Hackathon
</h1>
</div>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div>
);
};
export default TeamDetailLayout;
@@ -0,0 +1,362 @@
import { FC, ReactElement, useState } from 'react';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import {
useTeamById,
useTeamMembers,
useTeamJoinRequests,
useInviteMember,
useRemoveMember,
useRespondToJoinRequest,
ETeamMemberStatus,
inviteMemberSchema,
TInviteMemberForm,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
const ManageMembersPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [showInviteModal, setShowInviteModal] = useState(false);
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
teamId || ''
);
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(
teamId || ''
);
const { data: joinRequestsData } = useTeamJoinRequests(teamId || '');
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(
teamId || ''
);
const { mutateAsync: removeMember, isPending: isRemoving } = useRemoveMember(
teamId || ''
);
const { mutateAsync: respondToRequest, isPending: isResponding } =
useRespondToJoinRequest(teamId || '');
const team = teamData?.data;
const members = membersData?.data || [];
const joinRequests = Array.isArray(joinRequestsData?.data)
? joinRequestsData.data
: [];
const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id;
const form = useForm<TInviteMemberForm>({
resolver: zodResolver(inviteMemberSchema),
mode: 'all',
});
// Show loading state while fetching team data
if (isLoadingTeam) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
</div>
);
}
if (!isLeader) {
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">
Access Denied
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Only the team leader can manage members
</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div>
);
}
const handleInvite = form.handleSubmit(async (data) => {
try {
await inviteMember(data);
setShowInviteModal(false);
form.reset();
} catch (error) {
console.error('Failed to invite member:', error);
}
});
const handleRemove = async (userId: string) => {
// eslint-disable-next-line no-restricted-globals
if (confirm('Are you sure you want to remove this member?')) {
try {
await removeMember(userId);
} catch (error) {
console.error('Failed to remove member:', error);
}
}
};
const handleApproveRequest = async (requestId: string) => {
try {
await respondToRequest({ requestId, action: 'approve' });
toast.success('Member added successfully!');
} catch (error) {
console.error('Failed to approve request:', error);
toast.error((error as Error).message || 'Failed to approve request');
}
};
const handleRejectRequest = async (requestId: string) => {
try {
await respondToRequest({ requestId, action: 'reject' });
toast.success('Request rejected');
} catch (error) {
console.error('Failed to reject request:', error);
toast.error((error as Error).message || 'Failed to reject request');
}
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex flex-col md:flex-row gap-y-4 items-start md:items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Manage Members
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{team?.name}
</p>
</div>
<div className="flex space-x-3">
<Button onClick={() => setShowInviteModal(true)}>
Invite Member
</Button>
<Button
variant="secondary"
onClick={() => navigate(`/teams/${teamId}`)}
>
Back to Team
</Button>
</div>
</div>
</div>
</div>
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
{/* Join Requests */}
{joinRequests.length > 0 && (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
Join Requests ({joinRequests.length})
</h2>
<div className="space-y-3">
{joinRequests.map((request) => (
<div
key={request.id}
className="border dark:border-gray-700 rounded-lg p-4"
>
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3 flex-1">
{request.user.avatar ? (
<img
src={request.user.avatar}
alt={request.user.fullname}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-400">
👤
</span>
</div>
)}
<div className="flex-1">
<p className="font-medium text-gray-900 dark:text-white">
{request.user.fullname}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{request.user.email}
</p>
{request.user.location && (
<p className="text-sm text-gray-500 dark:text-gray-500">
📍 {request.user.location}
</p>
)}
<p className="text-sm text-gray-700 dark:text-gray-300 mt-2 italic">
"{request.message}"
</p>
</div>
</div>
<div className="flex space-x-2 ml-4">
<Button
size="sm"
onClick={() => handleApproveRequest(request.id)}
disabled={isResponding}
>
Approve
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => handleRejectRequest(request.id)}
disabled={isResponding}
>
Reject
</Button>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Current Members */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
Current Members ({members.length})
</h2>
{isLoadingMembers ? (
<p className="text-gray-600 dark:text-gray-400">
Loading members...
</p>
) : (
<div className="space-y-3">
{members.map((member) => (
<div
key={member.id}
className="border dark:border-gray-700 rounded-lg p-4 flex items-center justify-between"
>
<div className="flex items-center space-x-3">
{member.user.avatar ? (
<img
src={member.user.avatar}
alt={member.user.fullname}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-400">
👤
</span>
</div>
)}
<div>
<p className="font-medium text-gray-900 dark:text-white">
{member.user.fullname}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{member.user.email}
</p>
{member.user.location && (
<p className="text-sm text-gray-500 dark:text-gray-500">
📍 {member.user.location}
</p>
)}
<div className="flex items-center space-x-2 mt-1">
{member.role === 'leader' && (
<span className="px-2 py-1 bg-blue-100 dark:bg-primary-900/30 text-blue-800 dark:text-primary-300 rounded text-xs font-medium">
Leader
</span>
)}
{member.status === ETeamMemberStatus.PENDING && (
<span className="px-2 py-1 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 rounded text-xs font-medium">
Pending Invitation
</span>
)}
</div>
</div>
</div>
{member.role !== 'leader' && (
<Button
size="sm"
variant="secondary"
onClick={() => handleRemove(member.user_id)}
disabled={isRemoving}
>
Remove
</Button>
)}
</div>
))}
</div>
)}
</div>
{/* Warning */}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
<strong>Note:</strong> Members cannot leave the team without your
approval. Only you can remove members from the team.
</p>
</div>
</div>
{/* Invite Member Modal */}
{showInviteModal && (
<div className="fixed inset-0 bg-black/20 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 max-w-md w-full p-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Invite Member
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4 font-sans">
Send an invitation to join your team. The invited member will see
the invitation on their dashboard after logging in.
</p>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 mb-6">
<p className="text-sm text-yellow-800 dark:text-yellow-200 font-sans">
<strong>Important:</strong> The email you enter must match the
GitHub email address the member uses to sign in.
</p>
</div>
<form onSubmit={handleInvite} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Email Address
</label>
<Input
{...form.register('email')}
type="email"
placeholder="member@example.com"
size="lg"
/>
{form.formState.errors.email && (
<p className="text-sm text-red-500 mt-1">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="flex space-x-3">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => {
setShowInviteModal(false);
form.reset();
}}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={!form.formState.isValid || isInviting}
>
{isInviting ? 'Sending...' : 'Send Invitation'}
</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default ManageMembersPage;
@@ -0,0 +1,703 @@
import { FC, ReactElement, useState, useEffect } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Link, useParams, useNavigate } from 'react-router';
import {
useTeamById,
useTeamMembers,
useInviteMember,
useTeamJoinRequests,
useRespondToJoinRequest,
ETeamMemberRole,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
const MAX_TEAM_MEMBERS = 5;
// Image component with loading state
const ImageWithLoader: FC<{
src: string;
alt: string;
className?: string;
onLoad?: () => void;
}> = ({ src, alt, className, onLoad }) => {
const [isLoaded, setIsLoaded] = useState(false);
return (
<div className="relative">
{!isLoaded && (
<div
className={`absolute inset-0 bg-gray-200 animate-pulse ${className}`}
/>
)}
<img
src={src}
alt={alt}
className={`${className} ${
isLoaded ? 'opacity-100' : 'opacity-0'
} transition-opacity duration-300`}
onLoad={() => {
setIsLoaded(true);
onLoad?.();
}}
/>
</div>
);
};
const TeamDashboardPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [showInviteModal, setShowInviteModal] = useState(false);
const [showJoinRequestsModal, setShowJoinRequestsModal] = useState(false);
const [inviteEmail, setInviteEmail] = useState('');
const [imagesLoaded, setImagesLoaded] = useState(false);
const [imageLoadCount, setImageLoadCount] = useState(0);
// Calculate total images to load (banner + logo + member avatars)
const totalImagesToLoad = 0; // Simplified - disable image loading overlay
const handleImageLoad = () => {
setImageLoadCount((prev) => {
const newCount = prev + 1;
if (newCount >= totalImagesToLoad) {
setImagesLoaded(true);
}
return newCount;
});
};
const handleImageError = () => {
// Treat error as loaded to not block the UI
handleImageLoad();
};
// Set images as loaded immediately since we disabled the loading overlay
useEffect(() => {
setImagesLoaded(true);
}, []);
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
teamId || ''
);
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(
teamId || ''
);
const { data: joinRequestsData } = useTeamJoinRequests(
teamId || '',
!!teamId
);
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(
teamId || ''
);
const { mutateAsync: respondToJoinRequest, isPending: isResponding } =
useRespondToJoinRequest(teamId || '');
const team = teamData?.data;
const members = membersData?.data || [];
const joinRequests = joinRequestsData?.data || [];
const pendingJoinRequests = joinRequests.filter(
(req: any) => req.status === 'pending'
);
const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id;
const isMember = members.some(
(member: any) => member.user_id === currentUserId
);
const canInvite = isLeader && members.length < MAX_TEAM_MEMBERS;
const handleInviteMember = async (e: React.FormEvent) => {
e.preventDefault();
if (!inviteEmail.trim() || isInviting) return;
try {
await inviteMember({ email: inviteEmail.trim() });
toast.success('Invitation sent successfully!');
setInviteEmail('');
setShowInviteModal(false);
} catch (error) {
console.error('Failed to invite member:', error);
toast.error('Failed to send invitation');
}
};
const handleRespondToJoinRequest = async (
requestId: string,
action: 'approve' | 'reject'
) => {
try {
await respondToJoinRequest({ requestId, action });
toast.success(
action === 'approve' ? 'Request approved!' : 'Request rejected'
);
} catch (error) {
console.error('Failed to respond to join request:', error);
toast.error('Failed to process request');
}
};
if (isLoadingTeam || isLoadingMembers) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
{/* Skeleton Header */}
<div className="bg-white dark:bg-gray-950 border-b">
<div className="w-full h-48 bg-gray-200 dark:bg-gray-800 animate-pulse" />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-4">
<div className="w-20 h-20 rounded-full bg-gray-300 dark:bg-gray-700 animate-pulse -mt-10" />
<div>
<div className="h-8 w-48 bg-gray-300 dark:bg-gray-700 rounded animate-pulse" />
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-800 rounded animate-pulse mt-2" />
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-800 rounded animate-pulse mt-2" />
</div>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid gap-6 lg:grid-cols-3">
{/* Main Content Skeleton */}
<div className="lg:col-span-2 space-y-6">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-6">
<div className="h-6 w-32 bg-gray-300 dark:bg-gray-700 rounded animate-pulse mb-4" />
<div className="space-y-2">
<div className="h-4 w-full bg-gray-200 dark:bg-gray-800 rounded animate-pulse" />
<div className="h-4 w-3/4 bg-gray-200 dark:bg-gray-800 rounded animate-pulse" />
<div className="h-4 w-5/6 bg-gray-200 dark:bg-gray-800 rounded animate-pulse" />
</div>
</div>
</div>
{/* Sidebar Skeleton */}
<div className="space-y-6">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-6">
<div className="h-6 w-40 bg-gray-300 dark:bg-gray-700 rounded animate-pulse mb-4" />
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center space-x-3">
<div className="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-700 animate-pulse" />
<div>
<div className="h-4 w-24 bg-gray-300 dark:bg-gray-700 rounded animate-pulse" />
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-800 rounded animate-pulse mt-1" />
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
{/* Loading Overlay */}
<div className="fixed inset-0 bg-white/60 dark:bg-black/50 flex items-center justify-center z-50">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent"></div>
<p className="mt-4 text-gray-600 dark:text-white">
Loading team data...
</p>
</div>
</div>
</div>
);
}
if (!team) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Team not found
</h2>
<Button onClick={() => navigate('/dashboard')}>
Back to Dashboard
</Button>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 relative dark:bg-gray-950">
{/* Loading overlay while images are loading */}
{!imagesLoaded && totalImagesToLoad > 0 && (
<div className="fixed inset-0 bg-white/80 flex items-center justify-center z-50">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent"></div>
<p className="mt-4 text-gray-600 font-medium">Loading images...</p>
<p className="text-sm text-gray-400 mt-1">
{imageLoadCount} / {totalImagesToLoad}
</p>
</div>
</div>
)}
{/* Header with Banner */}
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
{team.banner && (
<div className="relative w-full aspect-3/1 overflow-hidden">
<div
className={`absolute inset-0 bg-gray-200 animate-pulse ${
imagesLoaded ? 'hidden' : ''
}`}
/>
<img
src={team.banner}
alt={team.name}
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${
imagesLoaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={handleImageLoad}
onError={handleImageError}
/>
<div className="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent via-black/20 to-black/60" />
</div>
)}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-6">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3 md:space-x-4">
{team.logo && (
<div className="relative shrink-0">
{!imagesLoaded && (
<div className="absolute inset-0 w-20 h-20 rounded-full bg-gray-300 dark:bg-gray-700 animate-pulse -mt-10" />
)}
<img
src={team.logo}
alt={team.name}
className={`w-16 h-16 md:w-20 md:h-20 rounded-full object-cover border-4 border-white shadow-lg -mt-8 md:-mt-10 transition-opacity duration-300 shrink-0 ${
imagesLoaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={handleImageLoad}
onError={handleImageError}
/>
</div>
)}
<div>
<h1 className="text-xl md:text-3xl font-bold text-gray-900 dark:text-white line-clamp-2">
{team.name}
</h1>
<p className="text-sm md:text-base text-gray-600 dark:text-gray-400 mt-1 line-clamp-1 font-sans flex items-center">
<Icon icon="mdi:map-marker" className="inline-block mr-1" />
<span>{team.city}</span>
</p>
<div className="flex flex-wrap items-center gap-2 md:space-x-4 mt-2">
<span className="text-sm text-gray-500 dark:text-gray-400 flex items-center">
<Icon
icon="mdi:account-multiple"
className="inline-block mr-1"
/>
{members.length}{' '}
{members.length === 1 ? 'Member' : 'Members'}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400 flex items-center">
<Icon
icon={
team.visibility === 'public' ? 'mdi:web' : 'mdi:lock'
}
className="inline-block mr-1"
/>
{team.visibility === 'public' ? 'Public' : 'Private'}
</span>
{isLeader && (
<span className="hidden md:inline-flex px-3 py-1 bg-blue-600 text-white rounded-md text-sm font-semibold shadow-sm items-center">
<Icon icon="mdi:crown" className="inline-block mr-1" />
<span>Team Leader</span>
</span>
)}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-8">
<div className="grid gap-4 md:gap-6 xl:grid-cols-3">
{/* Main Content */}
<div className="lg:col-span-2 space-y-4 md:space-y-6">
{/* Team Description */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
About Team
</h2>
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-all font-sans">
{team.description}
</p>
</div>
{/* Team Actions - Only for Leader */}
{isLeader && (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
Team Management
</h2>
<div className="grid gap-3 sm:grid-cols-2">
<Button
className="w-full"
variant="secondary"
onClick={() => setShowInviteModal(true)}
disabled={!canInvite}
>
<Icon icon="mdi:plus" className="inline-block mr-2" />
Invite Member{' '}
{!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
</Button>
<Button
className="w-full relative"
variant="secondary"
onClick={() => setShowJoinRequestsModal(true)}
>
<Icon icon="mdi:email" className="inline-block mr-2" /> Join
Requests
{pendingJoinRequests.length > 0 && (
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center">
{pendingJoinRequests.length}
</span>
)}
</Button>
<Link to={`/teams/${teamId}/edit`}>
<Button className="w-full" variant="secondary">
<Icon icon="mdi:pencil" className="inline-block mr-2" />
Edit Team Info
</Button>
</Link>
<Link to={`/teams/${teamId}/members`}>
<Button className="w-full" variant="secondary">
<Icon
icon="mdi:account-group"
className="inline-block mr-2"
/>
Manage Members
</Button>
</Link>
<Link to={`/teams/${teamId}/chat`}>
<Button className="w-full" variant="secondary">
<Icon icon="mdi:chat" className="inline-block mr-2" />
Team Chat
</Button>
</Link>
<Link to={`/teams/${teamId}/submit`}>
<Button className="w-full">
<Icon icon="mdi:rocket" className="inline-block mr-2" />
Submit Project
</Button>
</Link>
</div>
{!canInvite && members.length >= MAX_TEAM_MEMBERS && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-3 text-center">
Maximum team size reached ({MAX_TEAM_MEMBERS} members)
</p>
)}
</div>
)}
{/* Quick Actions for Members (non-leaders) */}
{!isLeader && isMember && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 md:p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
Quick Actions
</h2>
<div className="grid gap-3 sm:grid-cols-2">
<Link to={`/teams/${teamId}/chat`}>
<Button className="w-full" variant="secondary">
💬 Team Chat
</Button>
</Link>
{team.has_submission && (
<Link to={`/teams/${teamId}/submission`}>
<Button className="w-full" variant="secondary">
📄 View Submission
</Button>
</Link>
)}
</div>
</div>
)}
{/* Submission Status - Only show to members */}
{team.has_submission && isMember && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 md:p-6">
<div className="flex items-center space-x-3">
<span className="text-3xl"></span>
<div>
<h3 className="font-bold text-green-900 dark:text-green-400">
Project Submitted
</h3>
<p className="text-green-700 dark:text-green-300 text-sm">
Your team has successfully submitted a project
</p>
</div>
</div>
<Link to={`/teams/${teamId}/submission`}>
<Button className="mt-4 w-full" variant="secondary">
View Submission Details
</Button>
</Link>
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-4 md:space-y-6">
{/* Team Leader */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
Team Leader
</h3>
{team.leader && (
<button
onClick={() => navigate(`/users/${team.leader.id}`)}
className="w-full flex items-center space-x-3 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg p-2 transition-colors text-left cursor-pointer"
>
{team.leader.avatar ? (
<img
src={team.leader.avatar}
alt={team.leader.fullname}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-400">
👤
</span>
</div>
)}
<div>
<p className="font-medium text-gray-900 dark:text-white">
{team.leader.fullname}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{team.leader.email}
</p>
</div>
</button>
)}
</div>
{/* Team Members */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
Members ({members.length})
</h3>
<div className="space-y-3">
{members.map((member: any) => (
<button
key={member.id}
onClick={() => navigate(`/users/${member.user.id}`)}
className="w-full flex items-center space-x-3 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg p-2 transition-colors text-left cursor-pointer"
>
{member.user?.avatar ? (
<img
src={member.user.avatar}
alt={member.user.fullname || 'Member'}
className="w-10 h-10 rounded-full object-cover"
onLoad={handleImageLoad}
onError={handleImageError}
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-400 text-sm">
👤
</span>
</div>
)}
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 dark:text-white truncate">
{member.user?.fullname || 'Unknown'}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{member.role === ETeamMemberRole.LEADER
? 'Leader'
: 'Member'}
</p>
</div>
</button>
))}
</div>
</div>
</div>
</div>
</div>
{/* Invite Member Modal */}
{showInviteModal && (
<div className="fixed inset-0 bg-black/20 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Invite Team Member
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4 font-sans">
Send an invitation to join your team. The invited member will see
the invitation on their dashboard after logging in.
</p>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 mb-6">
<p className="text-sm text-yellow-800 dark:text-yellow-300 font-sans">
<strong>Important:</strong> The email you enter must match the
GitHub email address the member uses to sign in.
</p>
</div>
<form onSubmit={handleInviteMember} className="space-y-4">
<div>
<label
htmlFor="invite-email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Email Address
</label>
<input
id="invite-email"
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Enter email address..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-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"
required
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Current members: {members.length}/{MAX_TEAM_MEMBERS}
</p>
</div>
<div className="flex space-x-3">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => {
setShowInviteModal(false);
setInviteEmail('');
}}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={!inviteEmail.trim() || isInviting}
>
{isInviting ? 'Sending...' : 'Send Invitation'}
</Button>
</div>
</form>
</div>
</div>
)}
{/* Join Requests Modal */}
{showJoinRequestsModal && (
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
Join Requests ({pendingJoinRequests.length})
</h2>
<button
onClick={() => setShowJoinRequestsModal(false)}
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 text-2xl cursor-pointer"
>
</button>
</div>
{pendingJoinRequests.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-600 dark:text-gray-400 text-lg">
No pending join requests
</p>
<p className="text-gray-500 dark:text-gray-500 text-sm mt-2 font-sans">
When users request to join your team, they'll appear here
</p>
</div>
) : (
<div className="space-y-4">
{pendingJoinRequests.map((request: any) => (
<div
key={request.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3 flex-1">
{request.user?.avatar ? (
<img
src={request.user.avatar}
alt={request.user.fullname}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0">
<span className="text-gray-500 dark:text-gray-400 text-xl">
<Icon
icon="mdi:account-circle"
className="text-3xl"
/>
</span>
</div>
)}
<div className="flex-1">
<p className="font-semibold text-gray-900 dark:text-white">
{request.user?.fullname || 'Unknown User'}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{request.user?.email}
</p>
{request.message && (
<div className="mt-2 bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
<p className="text-sm text-gray-700 dark:text-gray-300">
<strong>Message:</strong> {request.message}
</p>
</div>
)}
<p className="text-sm text-gray-500 dark:text-gray-500 mt-2 font-sans">
Requested{' '}
{new Date(request.created_at).toLocaleDateString()}
</p>
</div>
</div>
</div>
<div className="flex space-x-2 mt-4 justify-end">
<Button
onClick={() =>
handleRespondToJoinRequest(request.id, 'approve')
}
disabled={
isResponding || members.length >= MAX_TEAM_MEMBERS
}
className="px-4 py-2 text-sm"
>
Accept
</Button>
<Button
onClick={() =>
handleRespondToJoinRequest(request.id, 'reject')
}
disabled={isResponding}
variant="secondary"
className="px-4 py-2 text-sm"
>
Reject
</Button>
</div>
{members.length >= MAX_TEAM_MEMBERS && (
<div className="mt-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3">
<p className="text-sm text-yellow-800 dark:text-yellow-300 font-sans">
Team is full ({MAX_TEAM_MEMBERS}/{MAX_TEAM_MEMBERS}{' '}
members). Remove a member before accepting new
requests.
</p>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
);
};
export default TeamDashboardPage;
@@ -0,0 +1,207 @@
import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import { useTeamById, useTeamSubmission } from '@imphnen-frontend-service/service';
const SubmissionViewPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { data: teamData } = useTeamById(teamId || '');
const { data: submissionData, isLoading } = useTeamSubmission(teamId || '', !!teamId);
const team = teamData?.data;
const submission = submissionData?.data;
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950">
<div className="text-gray-600 dark:text-neutral-400">Loading submission...</div>
</div>
);
}
if (!submission) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950">
<div className="text-6xl mb-4">📄</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">No Submission Yet</h2>
<p className="text-gray-600 dark:text-neutral-400 mb-4">Your team hasn't submitted a project</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
</div>
);
}
const submittedDate = submission.submitted_at
? new Date(submission.submitted_at).toLocaleString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: 'Not submitted';
return (
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950">
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Project Submission</h1>
<p className="text-gray-600 dark:text-neutral-400 mt-1">{team?.name}</p>
</div>
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div>
</div>
</div>
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Status Banner */}
<div className="bg-green-50 dark:bg-green-900/20 border border-green-500 rounded-lg p-6 mb-6">
<div className="flex items-center space-x-3">
<span className="text-4xl">✅</span>
<div>
<h3 className="font-bold text-green-900 dark:text-green-100 text-lg">Project Submitted Successfully</h3>
<p className="text-green-700 dark:text-green-300 text-sm">
Submitted on {submittedDate}
</p>
<p className="text-green-600 dark:text-green-400 text-xs mt-1">
This submission is now read-only and cannot be edited
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-950/50 overflow-hidden">
{/* Project Header */}
<div className="bg-gradient-to-r from-blue-600 to-blue-800 text-white p-8">
<h2 className="text-3xl font-bold mb-2">{submission.project_name}</h2>
<p className="text-blue-100">Team: {team?.name}</p>
</div>
{/* Project Details */}
<div className="p-8 space-y-6">
{/* Description */}
<div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Project Description</h3>
<div className="bg-gray-50 dark:bg-neutral-800 rounded-lg p-4">
<p className="text-gray-700 dark:text-neutral-300 whitespace-pre-wrap">{submission.description}</p>
</div>
</div>
{/* Links */}
<div className="grid gap-6 md:grid-cols-2">
{/* Repository */}
<div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Repository</h3>
<a
href={submission.repository_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-2 text-blue-600 dark:text-primary-400 hover:text-blue-800 dark:hover:text-primary-300"
>
<span>🔗</span>
<span className="break-all">{submission.repository_url}</span>
</a>
</div>
{/* Demo URL */}
{submission.demo_url && (
<div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Live Demo</h3>
<a
href={submission.demo_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-2 text-blue-600 dark:text-primary-400 hover:text-blue-800 dark:hover:text-primary-300"
>
<span>🌐</span>
<span className="break-all">{submission.demo_url}</span>
</a>
</div>
)}
{/* Presentation URL */}
{submission.presentation_url && (
<div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Presentation</h3>
<a
href={submission.presentation_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-2 text-blue-600 dark:text-primary-400 hover:text-blue-800 dark:hover:text-primary-300"
>
<span>📊</span>
<span className="break-all">{submission.presentation_url}</span>
</a>
</div>
)}
</div>
{/* Screenshots */}
{submission.screenshots && submission.screenshots.length > 0 && (
<div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">
Screenshots ({submission.screenshots.length})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{submission.screenshots.map((url, index) => (
<a
key={index}
href={url}
target="_blank"
rel="noopener noreferrer"
className="block"
>
<img
src={url}
alt={`Screenshot ${index + 1}`}
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-neutral-700 hover:border-blue-500 dark:hover:border-primary-500 transition-colors cursor-pointer"
/>
</a>
))}
</div>
</div>
)}
{/* Submission Info */}
<div className="bg-gray-50 dark:bg-neutral-800 rounded-lg p-4 border-t-4 border-blue-600 dark:border-primary-500">
<h3 className="text-sm font-bold text-gray-900 dark:text-white mb-2">Submission Information</h3>
<div className="grid gap-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-neutral-400">Status:</span>
<span className="font-medium text-green-600 dark:text-green-400">
{submission.status === 'submitted' ? ' Submitted' : 'Draft'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-neutral-400">Submitted:</span>
<span className="font-medium text-gray-900 dark:text-white">{submittedDate}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-neutral-400">Submission ID:</span>
<span className="font-medium text-gray-900 dark:text-white font-mono text-xs">
{submission.id}
</span>
</div>
</div>
</div>
{/* Read-only Notice */}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
<strong>Note:</strong> This submission is now locked and cannot be edited or deleted.
If you need to make changes, please contact the hackathon organizers.
</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default SubmissionViewPage;
@@ -0,0 +1,334 @@
import { FC, ReactElement, useState } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import { useForm, Controller } from 'react-hook-form';
import {
projectSubmissionSchema,
TProjectSubmissionForm,
useSubmitProject,
useTeamById,
useTeamSubmission,
useUploadFile,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
const SubmitProjectPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [screenshots, setScreenshots] = useState<string[]>([]);
const { data: teamData } = useTeamById(teamId || '');
const { data: submissionData } = useTeamSubmission(teamId || '', !!teamId);
const { mutateAsync: submitProject, isPending: isSubmitting } =
useSubmitProject(teamId || '');
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const team = teamData?.data;
const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id;
const hasSubmission = !!submissionData?.data;
const form = useForm<TProjectSubmissionForm>({
resolver: zodResolver(projectSubmissionSchema),
mode: 'all',
});
if (!isLeader) {
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">
Access Denied
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Only the team leader can submit projects
</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div>
);
}
if (hasSubmission) {
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">
Project Already Submitted
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Your team has already submitted a project
</p>
<div className="flex space-x-3">
<Button onClick={() => navigate(`/teams/${teamId}/submission`)}>
View Submission
</Button>
<Button
variant="secondary"
onClick={() => navigate(`/teams/${teamId}`)}
>
Back to Team
</Button>
</div>
</div>
);
}
const handleScreenshotUpload = async (
e: React.ChangeEvent<HTMLInputElement>
) => {
const files = e.target.files;
if (!files) return;
try {
const uploadPromises = Array.from(files).map((file) => uploadFile(file));
const results = await Promise.all(uploadPromises);
const urls = results.map((r) => r.data.url);
setScreenshots([...screenshots, ...urls]);
} catch (error) {
console.error('Failed to upload screenshots:', error);
}
};
const removeScreenshot = (index: number) => {
setScreenshots(screenshots.filter((_, i) => i !== index));
};
const onSubmit = form.handleSubmit(async (data) => {
try {
await submitProject({
...data,
screenshots,
});
navigate(`/teams/${teamId}/submission`);
} catch (error) {
console.error('Failed to submit project:', error);
}
});
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
<div className="max-w-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">
Submit Project
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">{team?.name}</p>
</div>
</div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Warning Banner */}
<div className="bg-red-50 dark:bg-red-900/20 border-2 border-red-500 rounded-lg p-6 mb-6">
<div className="flex items-start space-x-3">
<span className="text-3xl"></span>
<div>
<h3 className="font-bold text-red-900 dark:text-red-100 text-lg">
IMPORTANT WARNING
</h3>
<ul className="text-red-800 dark:text-red-200 mt-2 space-y-1 text-sm font-sans">
<li> You can only submit your project ONCE</li>
<li> After submission, you CANNOT edit or change anything</li>
<li>
Make sure all information is correct before submitting
</li>
<li> Review your project details carefully</li>
</ul>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-8 border dark:border-gray-800">
<form
onSubmit={(e) => {
e.preventDefault();
setShowConfirmModal(true);
}}
className="space-y-6"
>
{/* Project Name */}
<ControlledInputField
control={form.control}
label="Project Name"
placeholder="Enter your project name"
name="project_name"
size="lg"
isRequired={true}
/>
{/* Description */}
<div className="space-y-2">
<label className="block text-[15px] font-medium text-gray-700 dark:text-gray-300">
Project Description <span className="text-red-500">*</span>
</label>
<Controller
control={form.control}
name="description"
render={({ field, fieldState }) => (
<div>
<Textarea
{...field}
placeholder="Describe your project, its features, and what problem it solves..."
rows={6}
className="w-full"
size="lg"
/>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
</div>
{/* Repository URL */}
<ControlledInputField
control={form.control}
label="Repository URL (GitHub, GitLab, etc.)"
placeholder="https://github.com/username/project"
name="repository_url"
type="url"
size="lg"
isRequired={true}
/>
{/* Demo URL */}
<ControlledInputField
control={form.control}
label="Demo URL (Optional)"
placeholder="https://your-project-demo.com"
name="demo_url"
type="url"
size="lg"
/>
{/* Presentation URL */}
<ControlledInputField
control={form.control}
label="Presentation URL (Optional)"
placeholder="https://slides.com/your-presentation or Google Drive link"
name="presentation_url"
type="url"
size="lg"
/>
{/* Screenshots */}
<div>
<label className="block text-label1 font-medium text-neutral-800 dark:text-gray-300">
Project Screenshots (Optional)
</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4">
{screenshots.map((url, index) => (
<div key={index} className="relative">
<img
src={url}
alt={`Screenshot ${index + 1}`}
className="w-full h-32 object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700"
/>
<button
type="button"
onClick={() => removeScreenshot(index)}
className="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm hover:bg-red-600"
>
×
</button>
</div>
))}
</div>
<label className="flex flex-col items-center justify-center w-full h-32 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">
<p className="text-gray-500 dark:text-gray-400">
Click to upload screenshots
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1 font-sans">
PNG, JPG up to 5MB each
</p>
</div>
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleScreenshotUpload}
disabled={isUploading}
/>
</label>
</div>
{/* Submit Button */}
<div className="flex space-x-3 pt-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => navigate(`/teams/${teamId}`)}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={!form.formState.isValid || isUploading}
>
Review & Submit
</Button>
</div>
</form>
</div>
</div>
{/* Confirmation Modal */}
{showConfirmModal && (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 max-w-md w-full p-6">
<div className="text-center mb-6">
<div className="text-5xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Final Confirmation
</h2>
<p className="text-red-600 dark:text-red-400 font-medium">
This action is IRREVERSIBLE!
</p>
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 mb-6">
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
By clicking "Submit Project", you confirm that:
</p>
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-2">
<li> All information is correct and complete</li>
<li> You understand this can only be done once</li>
<li> You cannot edit after submission</li>
<li> Your team agrees with this submission</li>
</ul>
</div>
<div className="flex space-x-3">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => setShowConfirmModal(false)}
>
Go Back
</Button>
<Button
onClick={onSubmit}
className="flex-1 bg-red-600 hover:bg-red-700"
disabled={isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit Project'}
</Button>
</div>
</div>
</div>
)}
</div>
);
};
export default SubmitProjectPage;
@@ -0,0 +1,276 @@
import { FC, ReactElement, useState, useEffect } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Link, useNavigate } from 'react-router';
import {
useTeams,
useJoinTeam,
useMyTeams,
ETeamVisibility,
joinTeamSchema,
TJoinTeamForm,
} from '@imphnen-frontend-service/service';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { CitySelect } from '../../../components/city-select';
import { Icon } from '@iconify/react';
const BrowseTeamsPage: FC = (): ReactElement => {
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [selectedCity, setSelectedCity] = useState('');
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const [showJoinModal, setShowJoinModal] = useState(false);
// Debounce search term
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchTerm);
}, 300);
return () => clearTimeout(timer);
}, [searchTerm]);
const { data: teamsData, isLoading } = useTeams({
search: debouncedSearch,
city: selectedCity || undefined,
visibility: ETeamVisibility.PUBLIC,
});
const { data: myTeamsData } = useMyTeams();
const { mutateAsync: joinTeam, isPending: isJoining } = useJoinTeam();
const form = useForm<TJoinTeamForm>({
resolver: zodResolver(joinTeamSchema),
mode: 'all',
});
const teams = teamsData?.data || [];
const myTeams = myTeamsData?.data || [];
// Helper function to check if user is a member of a team
const isMyTeam = (teamId: string) => {
return myTeams.some((team: any) => team.id === teamId);
};
const handleJoinRequest = (teamId: string) => {
setSelectedTeamId(teamId);
setShowJoinModal(true);
};
const onSubmit = form.handleSubmit(async (data) => {
if (!selectedTeamId) return;
try {
await joinTeam({ teamId: selectedTeamId, data });
setShowJoinModal(false);
form.reset();
setSelectedTeamId(null);
} catch (error) {
console.error('Failed to send join request:', error);
}
});
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
{/* Header */}
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Browse Teams
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Find and join teams looking for members
</p>
</div>
<Link to="/dashboard" className="hidden md:block">
<Button variant="secondary">Back to Dashboard</Button>
</Link>
</div>
</div>
</div>
{/* Filters */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="bg-white dark:bg-gray-900 p-6 rounded-lg shadow-sm mb-6 border dark:border-gray-800">
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Search Teams
</label>
<input
type="text"
placeholder="Search by team name..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full h-[42px] px-3 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Filter by City
</label>
<CitySelect
value={selectedCity}
onChange={setSelectedCity}
placeholder="All Cities (search to filter...)"
/>
</div>
</div>
</div>
{/* Teams List */}
{isLoading ? (
<div className="text-center py-12">
<p className="text-gray-600 dark:text-gray-400">Loading teams...</p>
</div>
) : teams.length === 0 ? (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-sm p-12 text-center">
<p className="text-gray-600 dark:text-gray-400 text-lg">
No teams found
</p>
<p className="text-gray-500 dark:text-gray-500 mt-2">
Try adjusting your filters
</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{teams.map((team) => (
<div
key={team.id}
className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow flex flex-col border dark:border-gray-800"
>
<img
src={team.banner || '/images/banner-imphnen.webp'}
alt={team.name}
className="w-full aspect-3/1 object-cover"
/>
<div className="p-6 flex flex-col flex-1">
<div className="flex items-center space-x-3 mb-3">
{team.logo ? (
<img
src={team.logo}
alt={team.name}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-300 text-xl">
<Icon icon="mdi:account-group" />
</span>
</div>
)}
<div className="flex-1 min-w-0">
<h3 className="text-lg font-bold text-gray-900 dark:text-white line-clamp-2 leading-tight">
{team.name}
</h3>
<div className="text-sm font-sans text-gray-600 dark:text-gray-400 flex gap-2">
<p className="truncate flex-1 min-w-0 flex items-center gap-x-1">
<Icon icon="mdi:map-marker" />{' '}
<span>{team.city}</span>
</p>
<p className="whitespace-nowrap shrink-0 flex items-center gap-x-1">
<Icon icon="mdi:account-group" />{' '}
{team.members?.length || 0} members
</p>
</div>
</div>
</div>
<p className="text-gray-600 dark:text-gray-300 text-sm mb-4 line-clamp-3 font-sans">
{team.description}
</p>
<div className="space-y-3 mt-auto">
{isMyTeam(team.id) ? (
<Button
className="w-full"
variant="secondary"
onClick={() => navigate(`/teams/${team.id}`)}
>
Your Team
</Button>
) : (
<>
{myTeams.length === 0 &&
(team.members?.length || 0) < 5 && (
<Button
className="w-full"
onClick={() => handleJoinRequest(team.id)}
>
Request to Join
</Button>
)}
<Button
className="w-full"
variant="secondary"
onClick={() => navigate(`/teams/${team.id}`)}
>
View Team
</Button>
</>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Join Request Modal */}
{showJoinModal && (
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Request to Join Team
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6 font-sans">
Send a message to the team leader explaining why you want to join
</p>
<form onSubmit={onSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Your Message
</label>
<textarea
{...form.register('message')}
rows={4}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-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"
placeholder="Tell the team leader why you want to join their team..."
/>
{form.formState.errors.message && (
<p className="text-sm text-red-500 mt-1 font-sans">
{form.formState.errors.message.message}
</p>
)}
</div>
<div className="flex space-x-3">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => {
setShowJoinModal(false);
form.reset();
setSelectedTeamId(null);
}}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={!form.formState.isValid || isJoining}
>
{isJoining ? 'Sending...' : 'Send Request'}
</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default BrowseTeamsPage;
@@ -0,0 +1,308 @@
import { FC, ReactElement, useState } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate } from 'react-router';
import { useForm, Controller } from 'react-hook-form';
import { teamCreateSchema, TTeamCreateForm, useCreateTeam, ETeamVisibility, useUploadFile } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { CitySelect } from '../../../components/city-select';
const CreateTeamPage: FC = (): ReactElement => {
const navigate = useNavigate();
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string>('');
const [bannerFile, setBannerFile] = useState<File | null>(null);
const [bannerPreview, setBannerPreview] = useState<string>('');
const form = useForm<TTeamCreateForm>({
resolver: zodResolver(teamCreateSchema),
mode: 'all',
defaultValues: {
visibility: ETeamVisibility.PUBLIC,
logo: null,
banner: null,
},
});
const { mutateAsync: createTeam, isPending: isCreating } = useCreateTeam();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setLogoFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setLogoPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setBannerFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setBannerPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const onSubmit = form.handleSubmit(async (data) => {
try {
let logoUrl = null;
let bannerUrl = null;
if (logoFile) {
const logoResult = await uploadFile(logoFile);
logoUrl = logoResult.data.url;
}
if (bannerFile) {
const bannerResult = await uploadFile(bannerFile);
bannerUrl = bannerResult.data.url;
}
const result = await createTeam({
...data,
logo: logoUrl,
banner: bannerUrl,
});
navigate(`/teams/${result.data.id}`);
} catch (error) {
console.error('Failed to create team:', error);
}
});
return (
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950">
{/* Header */}
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Create Your Team</h1>
<p className="text-gray-600 dark:text-neutral-400 mt-1">Build your hackathon dream team</p>
</div>
</div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-900/50 p-8">
<form onSubmit={onSubmit} className="space-y-6">
{/* Banner Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-2">
Team Banner <span className="text-gray-400 dark:text-neutral-500">(Optional)</span>
</label>
{bannerPreview ? (
<div className="relative">
<img
src={bannerPreview}
alt="Banner preview"
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-neutral-700"
/>
<button
type="button"
onClick={() => {
setBannerFile(null);
setBannerPreview('');
}}
className="absolute top-2 right-2 bg-red-500 text-white px-3 py-1 rounded-lg text-sm hover:bg-red-600"
>
Remove
</button>
</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">
<div className="text-center">
<p className="text-gray-500 dark:text-neutral-400">Click to upload banner</p>
<p className="text-xs text-gray-400 dark:text-neutral-500 mt-1">1200x400 recommended</p>
</div>
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleBannerChange}
/>
</label>
)}
</div>
{/* Logo Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-2">
Team Logo <span className="text-gray-400 dark:text-neutral-500">(Optional, but highly recommended)</span>
</label>
<div className="flex items-center space-x-4">
{logoPreview ? (
<img
src={logoPreview}
alt="Logo preview"
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200 dark:border-neutral-700"
/>
) : (
<div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-neutral-700 flex items-center justify-center">
<span className="text-gray-400 dark:text-neutral-500 text-3xl">👥</span>
</div>
)}
<div>
<label htmlFor="logo" className="cursor-pointer">
<span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
{logoPreview ? 'Change Logo' : 'Upload Logo'}
</span>
<input
id="logo"
type="file"
accept="image/*"
className="hidden"
onChange={handleLogoChange}
/>
</label>
{logoPreview && (
<button
type="button"
onClick={() => {
setLogoFile(null);
setLogoPreview('');
}}
className="ml-3 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
>
Remove
</button>
)}
</div>
</div>
</div>
{/* Team Name */}
<ControlledInputField
control={form.control}
label="Team Name"
placeholder="Enter your team name"
name="name"
/>
{/* City */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300">
City <span className="text-red-500">*</span>
</label>
<Controller
control={form.control}
name="city"
render={({ field, fieldState }) => (
<CitySelect
value={field.value}
onChange={field.onChange}
error={fieldState.error?.message}
placeholder="Search your city..."
/>
)}
/>
</div>
{/* Description */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300">
Description <span className="text-red-500">*</span>
</label>
<Controller
control={form.control}
name="description"
render={({ field, fieldState }) => (
<div>
<Textarea
{...field}
placeholder="Tell others about your team, what you're looking for, your goals..."
rows={4}
className="w-full"
/>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
)}
</div>
)}
/>
</div>
{/* Visibility */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300">
Team Visibility <span className="text-red-500">*</span>
</label>
<Controller
control={form.control}
name="visibility"
render={({ field }) => (
<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">
<input
type="radio"
{...field}
value={ETeamVisibility.PUBLIC}
checked={field.value === ETeamVisibility.PUBLIC}
className="mt-1"
/>
<div>
<p className="font-medium text-gray-900 dark:text-white">Public</p>
<p className="text-sm text-gray-600 dark:text-neutral-400">
Team will be visible in Browse Teams. Anyone can request to join.
</p>
</div>
</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">
<input
type="radio"
{...field}
value={ETeamVisibility.PRIVATE}
checked={field.value === ETeamVisibility.PRIVATE}
className="mt-1"
/>
<div>
<p className="font-medium text-gray-900 dark:text-white">Private</p>
<p className="text-sm text-gray-600 dark:text-neutral-400">
Team is hidden from Browse Teams. Members can only join via invitation.
</p>
</div>
</label>
</div>
)}
/>
</div>
{/* Warning */}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
<strong>Note:</strong> As team leader, you cannot leave or join another team after creating this team.
</p>
</div>
{/* Submit Button */}
<div className="flex space-x-3 pt-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => navigate('/dashboard')}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={!form.formState.isValid || isCreating || isUploading}
>
{isCreating || isUploading ? 'Creating Team...' : 'Create Team'}
</Button>
</div>
</form>
</div>
</div>
</div>
);
};
export default CreateTeamPage;
+48
View File
@@ -0,0 +1,48 @@
import { FC, ReactElement, useState } from 'react';
import { Outlet } from 'react-router';
import { Sidebar } from '../../components/sidebar';
const TeamsLayout: FC = (): ReactElement => {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col">
{/* Mobile Header with Hamburger */}
<div className="lg:hidden sticky top-0 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 z-10">
<div className="flex items-center">
<button
onClick={() => setSidebarOpen(true)}
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-gray-400"
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="ml-3 text-lg font-bold text-gray-900 dark:text-white">
Hackathon
</h1>
</div>
</div>
<main className="flex-1">
<Outlet />
</main>
</div>
</div>
);
};
export default TeamsLayout;
@@ -0,0 +1,46 @@
import { FC, ReactElement, useState } from 'react';
import { Outlet } from 'react-router';
import { Sidebar } from '../../../components/sidebar';
const UserDetailLayout: FC = (): ReactElement => {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col overflow-auto">
{/* Mobile Header with Hamburger */}
<div className="lg:hidden bg-white dark:bg-gray-900 border-b dark:border-neutral-700 px-4 py-3 flex items-center">
<button
onClick={() => setSidebarOpen(true)}
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"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<h1 className="ml-3 text-lg font-bold text-gray-900 dark:text-white">
Hackathon
</h1>
</div>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div>
);
};
export default UserDetailLayout;
@@ -0,0 +1,236 @@
import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useParams, useNavigate, Link } from 'react-router';
import {
useUserDetailsById,
useTeamsByUserId,
} from '@imphnen-frontend-service/service';
import { Icon } from '@iconify/react';
const UserProfilePage: FC = (): ReactElement => {
const { userId } = useParams<{ userId: string }>();
const navigate = useNavigate();
const { data: userData, isLoading, error } = useUserDetailsById(userId || '');
const { data: teamsData } = useTeamsByUserId(userId || '');
const user = userData?.data;
const userTeams = teamsData?.data || [];
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-gray-600 dark:text-gray-400">
Loading user profile...
</div>
</div>
);
}
if (error || !user) {
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">
User not found
</h2>
{error && (
<p className="text-red-600 dark:text-red-400 mb-4">{String(error)}</p>
)}
<Button onClick={() => navigate('/dashboard')}>
Back to Dashboard
</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">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-6">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3 md:space-x-4">
{user.avatar ? (
<img
src={user.avatar}
alt={user.fullname}
className="w-16 h-16 md:w-20 md:h-20 rounded-full object-cover border-4 border-white dark:border-gray-800 shadow-lg"
/>
) : (
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border-4 border-white dark:border-gray-800 shadow-lg">
<span className="text-gray-500 dark:text-gray-400 text-2xl md:text-3xl">
<Icon icon="mdi:account-circle" className="w-10 h-10" />
</span>
</div>
)}
<div>
<h1 className="text-xl md:text-3xl font-bold text-gray-900 dark:text-white">
{user.fullname}
</h1>
<p className="text-sm md:text-base text-gray-600 dark:text-gray-400 mt-1">
{user.email}
</p>
{user.location && (
<div className="flex items-center space-x-4 mt-2">
<span className="text-sm text-gray-500 dark:text-gray-500">
<Icon
icon="mdi:map-marker"
className="inline-block w-4 h-4 mr-1"
/>
{user.location}
</span>
</div>
)}
</div>
</div>
<Link to="/dashboard" className="hidden md:block">
<Button variant="secondary">Back to Dashboard</Button>
</Link>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-8">
<div className="grid gap-4 md:gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="lg:col-span-2 space-y-4 md:space-y-6">
{/* About Section */}
{user.bio && (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6 overflow-hidden">
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
About
</h2>
<p className="text-gray-700 dark:text-gray-300 break-all overflow-wrap-anywhere font-sans">
{user.bio}
</p>
</div>
)}
{/* Skills Section */}
{user.skills && user.skills.length > 0 && (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
Skills
</h2>
<div className="flex flex-wrap gap-3">
{user.skills.map((skill: string) => (
<span
key={skill}
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}
</span>
))}
</div>
</div>
)}
{/* Teams Section */}
{userTeams.length > 0 ? (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
Teams ({userTeams.length})
</h2>
<div className="grid gap-4 md:grid-cols-2">
{userTeams.map((team: any) => (
<Link
key={team.id}
to={'/teams/' + team.id}
className="bg-white dark:bg-gray-800 rounded-lg shadow-md dark:shadow-gray-950/50 overflow-hidden hover:shadow-lg transition-shadow"
>
<div className="w-full aspect-3/1">
<img
src={team.banner}
alt={team.name}
className="w-full h-full object-cover"
/>
</div>
<div className="p-4">
<div className="flex items-center space-x-3 mb-2">
{team.logo && (
<img
src={team.logo}
alt={team.name}
className="w-12 h-12 rounded-full object-cover"
/>
)}
<div className="flex-1 min-w-0">
<h3 className="font-bold text-gray-900 dark:text-white line-clamp-2 mb-1">
{team.name}
</h3>
<div className="text-sm text-gray-600 dark:text-gray-400 flex gap-2 font-sans">
<p className="truncate flex-1 min-w-0">
<Icon
icon="mdi:map-marker"
className="inline-block w-4 h-4 mr-1"
/>
{team.city}
</p>
<p className="whitespace-nowrap shrink-0">
<Icon
icon="mdi:account-group"
className="inline-block w-4 h-4 mr-1"
/>
{team.members?.length || 0} members
</p>
</div>
</div>
</div>
<p className="text-gray-600 dark:text-gray-400 text-base line-clamp-2 font-sans">
{team.description}
</p>
</div>
</Link>
))}
</div>
</div>
) : (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
Teams
</h2>
<div className="text-center py-8">
<div className="text-4xl mb-3">👥</div>
<p className="text-gray-600 dark:text-gray-400">
Not in any team yet
</p>
</div>
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-4 md:space-y-6">
{/* Contact Info */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
Contact Information
</h3>
<div className="space-y-3">
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">
Email
</p>
<p className="text-gray-900 dark:text-white font-medium">
{user.email}
</p>
</div>
{user.location && (
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">
Location
</p>
<p className="text-gray-900 dark:text-white font-medium">
{user.location}
</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default UserProfilePage;
@@ -0,0 +1,117 @@
import { FC, useState, useRef, useEffect } from 'react';
import INDONESIAN_CITIES from '../constants/cities';
interface CitySelectProps {
value: string;
onChange: (value: string) => void;
error?: string;
placeholder?: string;
}
export const CitySelect: FC<CitySelectProps> = ({
value,
onChange,
error,
placeholder = 'Search your city...',
}) => {
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);
}
};
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('');
setSearchQuery('');
setIsOpen(true);
inputRef.current?.focus();
};
return (
<div className="relative" ref={dropdownRef}>
<div className="relative">
<input
ref={inputRef}
type="text"
value={isOpen ? searchQuery : value}
onChange={(e) => {
setSearchQuery(e.target.value);
setIsOpen(true);
}}
onClick={handleInputClick}
placeholder={placeholder}
className={`w-full h-[42px] px-3 text-[15px] border rounded-md focus:ring-2 focus:ring-blue-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 ${
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
}`}
/>
{value && !isOpen && (
<button
type="button"
onClick={handleClearSelection}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500 hover:text-gray-600 cursor-pointer dark:hover:text-gray-300"
>
</button>
)}
</div>
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto text-label2">
{filteredCities.length > 0 ? (
<ul className="py-1">
{filteredCities.map((city) => (
<li
key={city}
onClick={() => handleSelectCity(city)}
className={`px-3 py-2 cursor-pointer hover:bg-blue-50 dark:hover:bg-blue-900/30 ${
value === city
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-400'
: 'text-gray-900 dark:text-gray-200'
}`}
>
{city}
</li>
))}
</ul>
) : (
<div className="px-3 py-2 text-gray-500 dark:text-gray-400 text-sm">
No cities found
</div>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,144 @@
import { FC, useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { useUserMe, useMyTeams } from '@imphnen-frontend-service/service';
import { useSession } from '@imphnen-frontend-service/utils';
export const Navigation: FC = () => {
const navigate = useNavigate();
const [showUserMenu, setShowUserMenu] = useState(false);
const { data: userData } = useUserMe();
const { data: teamsData } = useMyTeams();
const { signOut } = useSession();
const user = userData?.data;
const myTeams = teamsData?.data || [];
const hasTeam = myTeams.length > 0;
const handleLogout = async () => {
await signOut();
navigate('/auth/login');
};
return (
<nav className="bg-white border-b shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<Link to="/dashboard" className="flex items-center space-x-2">
<span className="text-2xl">🏆</span>
<span className="text-xl font-bold text-gray-900">Hackathon</span>
</Link>
{/* Navigation Links */}
<div className="hidden md:flex items-center space-x-6">
<Link
to="/dashboard"
className="text-gray-700 hover:text-blue-600 font-medium transition-colors"
>
Dashboard
</Link>
<Link
to="/teams/browse"
className="text-gray-700 hover:text-blue-600 font-medium transition-colors"
>
Browse Teams
</Link>
{hasTeam && (
<Link
to={`/teams/${myTeams[0].id}`}
className="text-gray-700 hover:text-blue-600 font-medium transition-colors"
>
My Team
</Link>
)}
</div>
{/* User Menu */}
<div className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center space-x-3 focus:outline-none"
>
{user?.avatar ? (
<img
src={user.avatar}
alt={user.fullname}
className="w-10 h-10 rounded-full object-cover border-2 border-gray-200"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500">👤</span>
</div>
)}
<span className="hidden md:block text-gray-700 font-medium">
{user?.fullname}
</span>
<svg
className="w-4 h-4 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{/* Dropdown Menu */}
{showUserMenu && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
<div className="px-4 py-2 border-b border-gray-200">
<p className="text-sm font-medium text-gray-900">{user?.fullname}</p>
<p className="text-xs text-gray-600 truncate">{user?.email}</p>
</div>
<Link
to="/dashboard"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setShowUserMenu(false)}
>
Dashboard
</Link>
<Link
to="/teams/browse"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setShowUserMenu(false)}
>
Browse Teams
</Link>
{hasTeam && (
<Link
to={`/teams/${myTeams[0].id}`}
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setShowUserMenu(false)}
>
My Team
</Link>
)}
<div className="border-t border-gray-200 mt-2 pt-2">
<button
onClick={handleLogout}
className="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50"
>
Logout
</button>
</div>
</div>
)}
</div>
</div>
</div>
{/* Close dropdown when clicking outside */}
{showUserMenu && (
<div
className="fixed inset-0 z-40"
onClick={() => setShowUserMenu(false)}
/>
)}
</nav>
);
};
+302
View File
@@ -0,0 +1,302 @@
import { FC, useEffect } from 'react';
import { Link, useLocation } from 'react-router';
import {
useMyTeams,
useAuthStore,
supabase,
} from '@imphnen-frontend-service/service';
import { useNavigate } from 'react-router';
import { toast } from 'sonner';
import { useTheme } from './theme-provider';
import { Icon } from '@iconify/react';
interface NavItem {
name: string;
path: string;
icon: React.ReactNode;
show: boolean;
}
interface SidebarProps {
isOpen?: boolean;
onClose?: () => void;
}
export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
const location = useLocation();
const navigate = useNavigate();
const { session, clearSession } = useAuthStore();
const { data: teamsData } = useMyTeams();
const { theme, setTheme, resolvedTheme } = useTheme();
const user = session?.user;
const myTeams = teamsData?.data || [];
const hasTeam = myTeams.length > 0;
// Close sidebar on route change (mobile)
useEffect(() => {
if (onClose) {
onClose();
}
}, [location.pathname]);
const handleLogout = async () => {
try {
await supabase.auth.signOut();
clearSession();
localStorage.clear();
toast.success('Logged out successfully');
navigate('/auth/login');
} catch (error) {
console.error('Logout error:', error);
clearSession();
localStorage.clear();
navigate('/auth/login');
}
};
const navItems: NavItem[] = [
{
name: 'Dashboard',
path: '/dashboard',
icon: <Icon icon="heroicons:home" className="w-5 h-5" />,
show: true,
},
{
name: 'My Teams',
path: '/teams/' + myTeams[0]?.id,
icon: <Icon icon="heroicons:users" className="w-5 h-5" />,
show: myTeams.length > 0,
},
{
name: 'Browse Teams',
path: '/teams/browse',
icon: <Icon icon="heroicons-outline:search" className="w-5 h-5" />,
show: true,
},
{
name: 'Create Team',
path: '/teams/create',
icon: <Icon icon="heroicons:plus" className="h-5 w-5" />,
show: !hasTeam,
},
{
name: 'Edit Profile',
path: '/profile',
icon: (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
),
show: false,
},
];
const cycleTheme = () => {
if (theme === 'light') setTheme('dark');
else if (theme === 'dark') setTheme('system');
else setTheme('light');
};
const getThemeIcon = () => {
if (theme === 'system') {
return (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
);
}
if (resolvedTheme === 'dark') {
return (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
);
}
return (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
);
};
const getThemeLabel = () => {
if (theme === 'system') return 'System';
if (theme === 'dark') return 'Dark';
return 'Light';
};
const sidebarContent = (
<div className="w-64 bg-white dark:bg-gray-900 border-r dark:border-gray-800 min-h-screen flex flex-col">
{/* Logo / Brand with Close Button */}
<div className="p-6 flex items-center justify-between border-b dark:border-gray-800">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
Hackathon
</h1>
{onClose && (
<button
onClick={onClose}
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<svg
className="w-5 h-5 text-gray-500 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
{/* User Info */}
<div className="p-4 border-b dark:border-gray-800">
<div className="flex items-center space-x-3">
{user?.avatar ? (
<img
src={user.avatar}
alt={user.fullname || 'User'}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center">
<Icon
icon="ic:baseline-person"
width="24"
height="24"
className="text-gray-400 dark:text-gray-400"
/>
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{user?.fullname || user?.email?.split('@')[0] || 'User'}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{user?.email}
</p>
</div>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-4">
<ul className="space-y-2">
{navItems
.filter((item) => item.show)
.map((item) => {
const isActive = location.pathname === item.path;
return (
<li key={item.path}>
<Link
to={item.path}
className={`flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors ${
isActive
? 'bg-primary-50 dark:bg-blue-900/30 text-primary-600 dark:text-blue-400 font-medium'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
{item.icon}
<span>{item.name}</span>
</Link>
</li>
);
})}
</ul>
</nav>
{/* Theme Toggle & Logout */}
<div className="p-4 border-t dark:border-gray-800 space-y-2">
<button
onClick={cycleTheme}
className="flex items-center space-x-3 px-4 py-3 w-full rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer"
>
{getThemeIcon()}
<span>{getThemeLabel()}</span>
</button>
<button
onClick={handleLogout}
className="flex items-center space-x-3 px-4 py-3 w-full rounded-lg text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors cursor-pointer"
>
<Icon
icon="heroicons:arrow-right-end-on-rectangle"
className="w-5 h-5"
/>
<span>Logout</span>
</button>
</div>
</div>
);
return (
<>
{/* Desktop Sidebar - Always visible on lg+, sticky position */}
<div className="hidden lg:block sticky top-0 h-screen overflow-y-auto">
{sidebarContent}
</div>
{/* Mobile Sidebar - Overlay */}
{isOpen && (
<div className="lg:hidden fixed inset-0 z-50">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 transition-opacity"
onClick={onClose}
/>
{/* Sidebar */}
<div className="fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out">
{sidebarContent}
</div>
</div>
)}
</>
);
};
export default Sidebar;
@@ -0,0 +1,100 @@
import { createContext, useContext, useEffect, useState, FC, ReactNode } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: 'light' | 'dark';
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
const STORAGE_KEY = 'hackathon-theme';
function getSystemTheme(): 'light' | 'dark' {
if (typeof window !== 'undefined') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'light';
}
interface ThemeProviderProps {
children: ReactNode;
defaultTheme?: Theme;
}
export const ThemeProvider: FC<ThemeProviderProps> = ({ children, defaultTheme = 'system' }) => {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
return stored || defaultTheme;
}
return defaultTheme;
});
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => {
if (theme === 'system') {
return getSystemTheme();
}
return theme;
});
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem(STORAGE_KEY, newTheme);
};
useEffect(() => {
const root = document.documentElement;
// Calculate the resolved theme
const resolved = theme === 'system' ? getSystemTheme() : theme;
setResolvedTheme(resolved);
// Apply dark class to root
if (resolved === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
}, [theme]);
// Listen for system theme changes
useEffect(() => {
if (theme !== 'system') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
const newTheme = e.matches ? 'dark' : 'light';
setResolvedTheme(newTheme);
const root = document.documentElement;
if (newTheme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
};
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
export default ThemeProvider;
@@ -0,0 +1,93 @@
import { FC } from 'react';
import { useTheme } from './theme-provider';
interface ThemeToggleProps {
className?: string;
}
// Sun icon for light mode
const SunIcon = () => (
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
);
// Moon icon for dark mode
const MoonIcon = () => (
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
);
// System icon (monitor)
const SystemIcon = () => (
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
);
export const ThemeToggle: FC<ThemeToggleProps> = ({ className = '' }) => {
const { theme, setTheme, resolvedTheme } = useTheme();
const cycleTheme = () => {
if (theme === 'light') setTheme('dark');
else if (theme === 'dark') setTheme('system');
else setTheme('light');
};
const getThemeLabel = () => {
if (theme === 'system') return 'System';
return theme === 'dark' ? 'Dark' : 'Light';
};
const renderIcon = () => {
if (theme === 'system') {
return <SystemIcon />;
}
return resolvedTheme === 'dark' ? <MoonIcon /> : <SunIcon />;
};
return (
<button
type="button"
onClick={cycleTheme}
className={`p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors cursor-pointer text-gray-600 dark:text-neutral-200 ${className}`}
title={`Theme: ${getThemeLabel()}`}
aria-label={`Current theme: ${getThemeLabel()}. Click to change.`}
>
{renderIcon()}
</button>
);
};
export default ThemeToggle;
+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;
@@ -0,0 +1,76 @@
import { useTeamById, useTeamMembers, useUserMe, useTeamSubmission } from '@imphnen-frontend-service/service';
/**
* Hook to check if current user is a member of the team
*/
export const useTeamMembership = (teamId: string) => {
const { data: userData } = useUserMe();
const { data: membersData } = useTeamMembers(teamId);
const currentUser = userData?.data;
const members = membersData?.data || [];
const isMember = members.some(m => m.user_id === currentUser?.id);
const currentMember = members.find(m => m.user_id === currentUser?.id);
return {
isMember,
currentMember,
memberRole: currentMember?.role,
memberStatus: currentMember?.status,
};
};
/**
* Hook to check if current user is the team leader
*/
export const useIsTeamLeader = (teamId: string) => {
const { data: teamData } = useTeamById(teamId);
const { data: userData } = useUserMe();
const team = teamData?.data;
const currentUser = userData?.data;
const isLeader = currentUser?.id === team?.leader_id;
return {
isLeader,
leaderId: team?.leader_id,
};
};
/**
* Hook to get team submission status
*/
export const useTeamSubmissionStatus = (teamId: string) => {
const { data: submissionData, isLoading } = useTeamSubmission(teamId, !!teamId);
const hasSubmission = !!submissionData?.data;
const submission = submissionData?.data;
const isSubmitted = submission?.status === 'submitted';
return {
hasSubmission,
isSubmitted,
submission,
isLoading,
};
};
/**
* Hook to check if current user can perform team actions
*/
export const useTeamPermissions = (teamId: string) => {
const { isLeader } = useIsTeamLeader(teamId);
const { isMember } = useTeamMembership(teamId);
const { hasSubmission } = useTeamSubmissionStatus(teamId);
return {
canEditTeam: isLeader,
canManageMembers: isLeader,
canSubmitProject: isLeader && !hasSubmission,
canViewTeam: isMember,
canViewSubmission: isMember,
canAccessChat: isMember,
};
};
+169
View File
@@ -0,0 +1,169 @@
@import url('https://fonts.googleapis.com/css2?family=Bai+Jamjuree:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap');
@import 'tailwindcss';
@source "../../../libs/ui/**/*.{ts,tsx}";
/* Enable class-based dark mode (using .dark class on html element) */
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-primary-50: #f0f8ff;
--color-primary-100: #e1f0fd;
--color-primary-200: #bce1fb;
--color-primary-300: #81cbf8;
--color-primary-400: #3eb0f2;
--color-primary-500: #23a1eb;
--color-primary-600: #0877c1;
--color-primary-700: #085f9c;
--color-primary-800: #0b5181;
--color-primary-900: #0f446b;
--color-primary-950: #0a2b47;
--color-neutral-50: #f6f6f6;
--color-neutral-100: #e7e7e7;
--color-neutral-200: #d1d1d1;
--color-neutral-300: #b0b0b0;
--color-neutral-400: #888888;
--color-neutral-500: #6e6a86;
--color-neutral-600: #56526e;
--color-neutral-700: #44415a;
--color-neutral-800: #393552;
--color-neutral-900: #2a273f;
--color-neutral-950: #232136;
--color-success-100: #e0fbd8;
--color-success-200: #bcf8b0;
--color-success-300: #8eea85;
--color-success-400: #63d564;
--color-success-500: #35ba43;
--color-success-600: #269f3e;
--color-success-700: #1a8439;
--color-success-800: #106b32;
--color-success-900: #0b592f;
--color-info-100: #ccfcfe;
--color-info-200: #9bf3fd;
--color-info-300: #67e3fb;
--color-info-400: #42cdf8;
--color-info-500: #04acf3;
--color-info-600: #0185d0;
--color-info-700: #0264af;
--color-info-800: #01478d;
--color-info-900: #003375;
--color-warning-100: #fffcd3;
--color-warning-200: #fffaa9;
--color-warning-300: #fff67d;
--color-warning-400: #fff25d;
--color-warning-500: #ffed27;
--color-warning-600: #dbc91d;
--color-warning-700: #b7a714;
--color-warning-800: #93850b;
--color-warning-900: #7a6d07;
--color-danger-100: #ffe8da;
--color-danger-200: #ffcbb3;
--color-danger-300: #ffaa8d;
--color-danger-400: #ff8870;
--color-danger-500: #ff5242;
--color-danger-600: #da3030;
--color-danger-700: #b7212d;
--color-danger-800: #93152a;
--color-danger-900: #7a0c27;
--radius-none: 0px;
--radius-sm: 2px;
--radius-md: 4px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-full: 50%;
--font-bai-jamjuree: 'Bai Jamjuree', sans-serif;
--text-h1: 3.833rem;
/* ~46px */
--text-h2: 3.083rem;
/* ~37px */
--text-h3: 2.417rem;
/* ~29px */
--text-p1: 1.917rem;
/* ~23px */
--text-p2: 1.583rem;
/* ~19px */
--text-p3: 1.25rem;
/* 15px */
--text-label1: 1rem;
/* 12px */
--text-label2: 0.833rem;
/* ~10px */
--text-label3: 0.677rem;
/* ~8px */
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-neutral-200, currentColor);
}
.dark *,
.dark ::after,
.dark ::before,
.dark ::backdrop,
.dark ::file-selector-button {
border-color: var(--color-neutral-700, currentColor);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--color-neutral-50);
}
.dark ::-webkit-scrollbar-track {
background: var(--color-neutral-900);
}
::-webkit-scrollbar-thumb {
background: #cccccc;
border-radius: 3px;
}
.dark ::-webkit-scrollbar-thumb {
background: var(--color-neutral-600);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-neutral-300);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: var(--color-neutral-500);
}
html {
font-family: 'Bai Jamjuree', sans-serif;
font-weight: 400;
font-size: 16px;
line-height: 1.2;
}
/* Dark mode base styles */
.dark {
color-scheme: dark;
}
.dark body {
background-color: var(--color-neutral-950);
color: var(--color-neutral-100);
}
}
+49
View File
@@ -0,0 +1,49 @@
import { createRoot } from 'react-dom/client';
import { StrictMode } from 'react';
import { createBrowserRouter, RouteObject, RouterProvider } from 'react-router';
import {
add404PageToRoutesChildren,
addErrorElementToRoutes,
convertPagesToRoute,
ModalLoginProvider,
QueryProvider,
} from '@imphnen-frontend-service/utils';
import { Toaster } from 'sonner';
import { ThemeProvider } from './components/theme-provider';
import './index.css';
const files = import.meta.glob('./app/**/*(page|layout).tsx');
const errorFiles = import.meta.glob('./app/**/*error.tsx');
const notFoundFiles = import.meta.glob('./app/**/*404.tsx');
const loadingFiles = import.meta.glob('./app/**/*loading.tsx');
const routes = convertPagesToRoute(files, loadingFiles) as RouteObject;
addErrorElementToRoutes(errorFiles, routes);
add404PageToRoutesChildren(notFoundFiles, routes);
const router = createBrowserRouter([
{
...routes,
// MIDDLEWARE TEMPORARILY DISABLED - causing infinite loops with React Router v7
// TODO: Implement auth checks at component level or use different pattern
// loader: middleware,
// shouldRevalidate: () => false,
},
]);
const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Failed to find the root element');
createRoot(rootElement).render(
<StrictMode>
<ThemeProvider defaultTheme="system">
<QueryProvider>
<ModalLoginProvider>
<Toaster position="top-right" richColors />
<RouterProvider router={router} />
</ModalLoginProvider>
</QueryProvider>
</ThemeProvider>
</StrictMode>
);
+137
View File
@@ -0,0 +1,137 @@
import { SessionUser } from '@imphnen-frontend-service/utils';
import { supabase } from '@imphnen-frontend-service/service';
import { LoaderFunctionArgs, redirect } from 'react-router';
const mappingPublicRoutes = [
'/',
];
const mappingOnboardingRoutes = [
'/onboarding/user',
];
const mappingRoutePermissions = [
{
path: '/dashboard',
permissions: [],
},
{
path: '/teams/browse',
permissions: [],
},
{
path: '/teams/create',
permissions: [],
},
];
const mappingPublicPrefixRoutes = [
'/hackathons',
];
// Cache to prevent redundant checks (cache for 5 seconds)
const onboardingCache = new Map<string, { hasLocation: boolean; timestamp: number }>();
const CACHE_DURATION = 5000; // 5 seconds
export const middleware = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const pathname = url.pathname;
// Get session from Supabase (authoritative source)
const { data: { session: supabaseSession }, error: sessionError } = await supabase.auth.getSession();
// Handle session errors
if (sessionError) {
console.error('[Middleware] Session error:', sessionError);
// Don't redirect on session errors, let the app handle it
}
// Allow to access the hackathon pages without authentication
if (mappingPublicPrefixRoutes.some((prefix) => pathname.startsWith(prefix))) {
return null;
}
// Public routes - allow everyone to view the landing page
if (mappingPublicRoutes.includes(pathname)) {
return null;
}
// Auth callback - allow without authentication check (for OAuth callback)
if (pathname === '/auth/callback') {
return null;
}
// Auth routes (all /auth/* paths) - redirect to dashboard if already authenticated
if (pathname.startsWith('/auth')) {
if (supabaseSession) return redirect('/dashboard');
return null;
}
// Require authentication for all other routes - ONLY check Supabase session
if (!supabaseSession) {
return redirect('/auth/login');
}
// Check if user has completed onboarding by querying database (not localStorage!)
// Skip onboarding check for onboarding routes themselves
if (!mappingOnboardingRoutes.includes(pathname)) {
try {
const userId = supabaseSession.user.id;
const now = Date.now();
// Check cache first
const cached = onboardingCache.get(userId);
let hasLocation = false;
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
hasLocation = cached.hasLocation;
} else {
const { data: userData, error: userError } = await supabase
.from('users')
.select('location')
.eq('id', userId)
.single();
if (userError) {
console.error('[Middleware] Failed to fetch user data:', userError);
// If we can't fetch user data, allow access (don't break the app)
return null;
}
hasLocation = !!userData?.location;
// Update cache
onboardingCache.set(userId, { hasLocation, timestamp: now });
}
if (!hasLocation) {
return redirect('/onboarding/user');
}
} catch (error) {
console.error('[Middleware] Unexpected error checking onboarding:', error);
// On error, allow access (fail open)
return null;
}
}
// Check route permissions using fresh user data from Zustand (for UI metadata)
const session = SessionUser.get();
const userPermissions =
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
const matchedRoute = mappingRoutePermissions.find(
(route) => route.path === pathname
);
if (matchedRoute) {
const hasPermission =
!matchedRoute.permissions ||
matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
if (!hasPermission) {
return redirect('/dashboard');
}
}
return null;
};
+27
View File
@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"node",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts",
"vite/client"
]
},
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"src/**/*.spec.tsx",
"src/**/*.test.tsx",
"src/**/*.spec.js",
"src/**/*.test.js",
"src/**/*.spec.jsx",
"src/**/*.test.jsx",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts"
],
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"allowJs": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"types": ["vite/client", "vitest"]
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../tsconfig.base.json"
}
+30
View File
@@ -0,0 +1,30 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
]
}
+42
View File
@@ -0,0 +1,42 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/apps/hackathon',
server: {
port: 3004,
host: 'localhost',
},
preview: {
port: 3005,
host: 'localhost',
},
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
build: {
outDir: '../../dist/apps/hackathon',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
},
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../coverage/apps/hackathon',
provider: 'v8' as const,
},
},
}));
+1
View File
@@ -0,0 +1 @@
Tue, Nov 25, 2025 4:21:27 PM
+2
View File
@@ -0,0 +1,2 @@
# Deployment trigger
# Updated to deploy circular dependency fixes and auth hooks
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./../../dist/apps/landing/.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1 -1
View File
@@ -3,7 +3,7 @@ const path = require('path');
/** @type {import('@nx/next/plugins/with-nx').WithNxOptions} */
const nextConfig = {
nx: { svgr: false },
nx: {},
output: 'standalone',
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

@@ -34,7 +34,7 @@ export function CommunitySection() {
opacity: 1,
transition: {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94],
ease: [0.25, 0.46, 0.45, 0.94] as any,
},
},
};
@@ -30,7 +30,7 @@ export function TestimonialSection() {
opacity: 1,
transition: {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94],
ease: [0.25, 0.46, 0.45, 0.94] as any,
},
},
};
@@ -24,32 +24,37 @@ export default function Footer() {
</p>
<div className="flex space-x-4">
<Link
href="#"
href="https://fb.com/groups/programmerhandal"
className="text-muted-foreground hover:text-foreground"
target="_blank"
>
<FaFacebook className="size-6" />
</Link>
<Link
href="#"
href="https://discord.gg/imphnen"
className="text-muted-foreground hover:text-foreground"
target="_blank"
>
<FaDiscord className="size-6" />
</Link>
<Link
href="#"
href="https://www.instagram.com/imphnen.dev"
className="text-muted-foreground hover:text-foreground"
target="_blank"
>
<FaInstagram className="size-6" />
</Link>
<Link
href="#"
href="https://www.tiktok.com/@imphnen"
className="text-muted-foreground hover:text-foreground"
target="_blank"
>
<FaTiktok className="size-6" />
</Link>
<Link
href="#"
href="https://www.linkedin.com/company/imphnen"
className="text-muted-foreground hover:text-foreground"
target="_blank"
>
<FaLinkedinIn className="size-6" />
</Link>
@@ -78,6 +83,7 @@ export default function Footer() {
<Link
href={link}
className="text-sm text-muted-foreground hover:text-foreground"
target="_blank"
>
{name}
</Link>
+20 -1
View File
@@ -6,8 +6,27 @@ import { Providers } from './_components/providers';
import { Toaster } from './_components/toaster';
export const metadata: Metadata = {
title: 'IMPHNEN - Ingin Menjadi Programmer Handal?',
title: 'IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding',
description: 'Komunitas belajar programming untuk semua level',
openGraph: {
title: 'IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding',
description: 'Komunitas belajar programming untuk semua level',
url: 'https://imphnen.dev',
images: [
{
url: 'https://imphnen.dev/imphnen-group-cover.webp',
alt: 'IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding',
width: 2550,
height: 945,
},
],
},
twitter: {
card: 'summary_large_image',
title: 'IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding',
description: 'Komunitas belajar programming untuk semua level',
images: ['https://imphnen.dev/imphnen-group-cover.webp'],
},
};
export default function RootLayout({
+5 -5
View File
@@ -4,34 +4,34 @@
"icon": "FaFacebook",
"color": "#1877F2",
"description": "Join discussions and share knowledge",
"link": "#"
"link": "https://fb.com/groups/programmerhandal"
},
{
"name": "Discord",
"icon": "FaDiscord",
"color": "#5865F2",
"description": "Real-time collaboration chat",
"link": "#"
"link": "https://discord.gg/imphnen"
},
{
"name": "Instagram",
"icon": "FaInstagram",
"color": "#E4405F",
"description": "Daily coding tips & showcases",
"link": "#"
"link": "https://www.instagram.com/imphnen.dev"
},
{
"name": "TikTok",
"icon": "FaTiktok",
"color": "#000000",
"description": "Short coding tutorials",
"link": "#"
"link": "https://www.tiktok.com/@imphnen"
},
{
"name": "LinkedIn",
"icon": "FaLinkedin",
"color": "#0A66C2",
"description": "Professional networking",
"link": "#"
"link": "https://www.linkedin.com/company/imphnen"
}
]
+1 -2
View File
@@ -1,10 +1,9 @@
version: '3.8'
services:
backoffice:
build:
context: .
dockerfile: docker/backoffice.Dockerfile
ports:
- "3001:80"
- '127.0.0.1:3001:80'
restart: unless-stopped
container_name: backoffice
+1 -2
View File
@@ -1,10 +1,9 @@
version: '3.8'
services:
dimentorin:
build:
context: .
dockerfile: docker/dimentorin.Dockerfile
ports:
- "3002:80"
- '127.0.0.1:3002:80'
restart: unless-stopped
container_name: dimentorin
+1 -2
View File
@@ -1,10 +1,9 @@
version: '3.8'
services:
gacha:
build:
context: .
dockerfile: docker/gacha.Dockerfile
ports:
- "3003:80"
- '127.0.0.1:3003:80'
restart: unless-stopped
container_name: gacha
+17
View File
@@ -0,0 +1,17 @@
services:
hackathon:
build:
context: .
dockerfile: docker/hackathon.Dockerfile
args:
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
ports:
- '127.0.0.1:3004:80'
restart: unless-stopped
container_name: hackathon
env_file:
- .env.local
environment:
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
+1 -2
View File
@@ -1,10 +1,9 @@
version: '3.8'
services:
landing:
build:
context: .
dockerfile: docker/landing.Dockerfile
ports:
- "3000:3000"
- '127.0.0.1:3005:3000'
restart: unless-stopped
container_name: landing
+2 -1
View File
@@ -1,12 +1,13 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
RUN npm ci --prefer-offline --no-audit --no-fund
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NX_DAEMON=false
RUN npm run backoffice:build
FROM nginx:alpine AS runner
+2 -1
View File
@@ -1,12 +1,13 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
RUN npm ci --prefer-offline --no-audit --no-fund
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NX_DAEMON=false
RUN npm run dimentorin:build
FROM nginx:alpine AS runner
+2 -1
View File
@@ -1,12 +1,13 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
RUN npm ci --prefer-offline --no-audit --no-fund
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NX_DAEMON=false
RUN npm run gacha:build
FROM nginx:alpine AS runner
+26
View File
@@ -0,0 +1,26 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --prefer-offline --no-audit --no-fund
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Accept build arguments
ARG VITE_SUPABASE_URL
ARG VITE_SUPABASE_ANON_KEY
# Set environment variables for build
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
ENV NX_DAEMON=false
RUN npm run hackathon:build
FROM nginx:alpine AS runner
RUN rm -rf /usr/share/nginx/html/*
COPY --from=builder /app/dist/apps/hackathon /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+3 -1
View File
@@ -1,8 +1,10 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --prefer-offline --no-audit --no-fund
COPY . .
RUN npm install
ENV NX_DAEMON=false
RUN npm run landing:build
FROM node:22-alpine AS runner
+516
View File
@@ -0,0 +1,516 @@
email,fullname,id
"rrrijal24@gmail.com","Rijal","0081ce77-2dd2-469c-8759-8d1259b6206c"
"zulhamrianto@gmail.com","","0176361f-e403-4aeb-a683-8aa58d1e51a4"
"akbarkurniawan790@gmail.com","Akbar Kurniawan","017917c0-7393-4620-9d17-bc8467f9c521"
"auliarestyazizah@gmail.com","","0293026a-dfbe-4380-89fa-e2d5729a5cea"
"alhadad.xyz@gmail.com","alhadad","0308f526-0dfe-4509-bee8-4aa217ec0a12"
"radithamzah12@gmail.com","dorrit","032c630c-8a20-40ec-b191-ba8e009ab538"
"pamungkas.ajiseno@gmail.com","Senoaji Pamungkas","0331921a-4009-4204-86af-5eb90d6773cf"
"gamingarisky@gmail.com","","05004e6b-9d5c-4a14-af97-496bcd55878b"
"bagoesrex@gmail.com","Bagus","058da640-ac05-498b-8618-f2c5a4455b90"
"rizkyseptian401@gmail.com","Moc Rizky Septian","05d98092-8528-4edf-8b23-9134d67f628b"
"excellchriatian12@gmail.com","Excell Christian","06f7158e-2218-4b61-8129-899bdaeb1eab"
"sofyanekafebriyanto@gmail.com","","07138a28-5e15-4c08-9325-0b17061d913f"
"baraskyke7@gmail.com","Barasky","07203a89-c58b-4df2-aeba-54b4977c8fc6"
"cukd3h.hynix@gmail.com","","073af6af-33aa-4067-820c-2213df0843b0"
"tsqf.h29@gmail.com","Muh Tsaqif Hafidz","073bb0a3-69b5-4b4d-8d47-e30b38690ef5"
"azranmu@gmail.com","M ASRAN","078e1f86-6ca1-4fc2-ab9d-a600a6c5adf0"
"gdsudiartika@gmail.com","Diar Sudiar","08b7eb15-dd74-4704-b8ec-d4e7dbc37c6e"
"alfiansa@proton.me","Aditia Akbar Putra A.","095a9662-4d57-4c76-ae28-8f3685a01d34"
"ichaaulia720@gmail.com","","09ab46a0-1394-4e41-9938-8f3df7f8bf99"
"naziq02@gmail.com","","09cacf8e-467d-4885-a792-11287b68d626"
"wahyuskull22@gmail.com","Wahyu SA","09dd11d7-4dae-4273-ba66-e313d82c7d52"
"sulthanzain28@gmail.com","Sulthan .Y","0a28758d-fbc3-4979-93ae-33011b3a9691"
"ivan04.android@gmail.com","","0a48d044-6e36-46b6-bd4e-bbf7d3f9519e"
"muklasputra222@gmail.com","Muklas Adi Putra","0ab605f9-d525-4e1b-b1ca-7b21da065aff"
"rojafadilah0203@gmail.com","Roja Fadilah","0b380e5f-26dc-41f0-914d-731d7ca00603"
"akmalkeisin@gmail.com","","0b55c37c-48ce-4721-a7bb-52d9913dfcee"
"bimasaktiramdani01@gmail.com","","0c1ddd78-5f93-4272-953b-4640cc769ab9"
"gajipgaming@gmail.com","Galih Aji Pangestu","0cb2e0f9-6afe-458b-afc9-3170bcc18fa4"
"pascalsanjaya34@gmail.com","Pascal","0d4f3960-509d-4ba4-8d03-5fc1c38a4071"
"hanifdwyputrasembiring@gmail.com","Hanif Dwy Putra S","0e87a0f3-5a96-4b87-8e04-e180ed2731ca"
"afreezap20@gmail.com","","0e9fcc3c-4a3f-43c9-bf4f-e4e4c342f7a0"
"profyonz@gmail.com","","0eb6f8b2-f10f-4fa7-bf66-a43ef14a8f63"
"kesavadananjaya@gmail.com","","0ec815c3-a256-4583-909d-b02655d7d09a"
"mdwikurniawan976@gmail.com","","0f0e2cb8-2c21-417a-a577-f5d0662a2efa"
"rickyrahmad2001@gmail.com","","0fa03bff-4be2-4c13-b687-688191c02955"
"dimasmaulanaichsan@gmail.com","Dimas Maulana Ichsan","0fc6770d-fac7-4118-8796-6cae1b18f880"
"rikiulir@gmail.com","Riki Ulir Niam","0fc99cb4-4495-405e-8b2f-3728404d7615"
"atmasusanto08@gmail.com","","100b63a9-7487-4553-b45d-afb2201e4492"
"alvarelkevin12@gmail.com","Muhammad Kevin Alvarel","114bebc7-2422-4c0a-9898-521f94739e7f"
"rayid1805@gmail.com","","118b7324-bfc1-45f7-9282-d0d7fee3510f"
"sjr16173@gmail.com","M. Salman Alfarisi","11dc3951-1a07-4937-bd75-2c29d2770c13"
"mhmmdarifrs@gmail.com","Muhammad Arif Rahmad","134cd84e-4d79-474a-8ccc-09d728ad3f61"
"2310512054@mahasiswa.upnvj.ac.id","Rafki Aulia Hazli ","15154e34-3c02-426a-b02e-003278334a29"
"fathamarizkinur@gmail.com","","151fa86a-5cec-4758-9824-a9d2ecd5385c"
"risyadraflan14@gmail.com","","1552a593-f8e3-4c80-833b-4c5017dea18f"
"fauziferdiansyahindo@gmail.com","","16a7eda3-4296-4754-b88e-dcf4cf61a0eb"
"amuhajir3126@gmail.com","MamadNewbie","16b7a5ea-e664-4de5-86f1-578b5f3cd28c"
"fdvkey@gmail.com","Bowo Pratama","1730c82d-369d-486d-b7cf-887be5383f8e"
"wahyusiakrom238@gmail.com","ademaswahyu","173e56ab-92cc-48dd-9b03-dc8fdca13fde"
"abiyuaflah135@gmail.com","Abiyu Aflah","18405a8a-9016-4db7-97fa-26ab32735df2"
"feryaryahidayat@gmail.com","Fery","19355ab7-f1cb-47c5-9d2d-5eeb9ee4dbff"
"rokimiftah@gmail.com","Roki Miftah Kamaludin","1973ee9d-2e18-46bc-b8df-272d61980a50"
"glen.tbjs@gmail.com","Jeki","197d5875-dc6f-4068-844d-657db670babc"
"khulafamanagement@gmail.com","","198b0b9f-a34d-410f-87cd-d19cfe02ff0c"
"12350110343@students.uin-suska.ac.id","","1a79c0ce-9119-4067-9f00-c91dd01db95e"
"rizalalfadlil@gmail.com","Hafidz Rizal Al-Fadlil","1aabe9b4-297a-4422-b01f-1b02369dc401"
"razinsyakib@gmail.com","Muhammad Razin Syakib","1b473465-ea92-4b48-9c2a-43c67cf45e01"
"dodysamarinda@gmail.com","","1b4cc185-9532-49b2-b0ac-9f03c1dcc045"
"superaseph@gmail.com","","1b97626f-bb01-457b-9638-7576e5af1683"
"ku.anime115@gmail.com","Muhamad Wisnu","1c88251f-951a-4470-a2eb-58118a75ee75"
"faizaalfa250@gmail.com","","1c903d4e-a741-42f9-b27c-37c5d4e9a704"
"wandgenius6@gmail.com","maulana adam","1d209d21-ef69-4bab-8796-cfa4cbf1fa20"
"aristiov@gmail.com","Aristio","1e0fd9f8-e7cc-4381-939d-0f1aac4a0510"
"adamabdillah20@gmail.com","Adam Abdillah","1e38dac1-a94c-4ac1-9ee8-8a53629e9be4"
"simarmatarafael11@gmail.com","","1ea5a152-5463-44b0-975a-3ed019e804b8"
"kurniadikiki508@gmail.com","","21054038-79d8-49a8-8daf-ca4e780cc09a"
"alif.ayana48@gmail.com","Alif Muhammad","21fe034a-a9f6-417d-a6ed-e8c26f3005b8"
"minanabdillah4@gmail.com","","22c19165-8c32-45f6-9e85-683a4aeaa2ec"
"gammertag31@gmail.com","","236035ab-a274-4f6f-869d-6aaaf761e659"
"abangiqi@gmail.com","","240ff65c-897c-40e7-aafd-d2dd1114ea66"
"meftah.mafazy@gmail.com","","27a09ee3-2c61-4b38-a9f9-1f2810488131"
"islahulkafaa@gmail.com","","289c5f8c-5e0f-4bca-8f66-7641d14a7bb0"
"zaidanabdulaziz03@gmail.com","Zaidan Abdul Aziz","29049370-3d6f-4ae3-b44a-fe9d4f6edb89"
"adigintings@gmail.com","Adi Primanda Ginting","2935f1f9-c070-422f-b9e0-b378b16a32c8"
"fadhil06akbar@gmail.com","","29ae041b-b96c-45d0-a0e2-256ccd8b2168"
"ilhamfiqri37@gmail.com","","29f3fcaa-9104-494e-844e-79d827f74a06"
"riski01.pku@gmail.com","","2a57227d-2920-4839-8849-93ef38a41b9e"
"bisnis.huruhara@gmail.com","","2ae31e65-4272-431b-bbc1-59158cc7cf11"
"luthlirik@gmail.com","luthfi","2b47f090-1c0f-4982-8893-c0b1af1409e6"
"zackyhokya045@gmail.com","","2bb1bf7f-cffb-4b1c-9171-a7e653fb6541"
"bayanalamahul3@gmail.com","","2c228e47-a6ad-47c4-984f-99b6e5e8a13d"
"nizaram4dhan@gmail.com","Nizar Alif Ramadhan","2cd4994b-ba50-43c1-9d78-f4311ef31108"
"fadhilakbar0612@gmail.com","","2cf73de4-90a4-4d1b-a8f0-748ab0a621e7"
"aryhidayatfebriana@gmail.com","Rizky Reza","2d0a00a8-2660-4b60-8de6-f85a6ce98b1d"
"hadiidandri2000@gmail.com","","2ea30f2a-fbeb-4af6-b727-bebb0b687939"
"ahmadirafif7@gmail.com","Rapip","2f08b18b-bd87-4d20-9923-26f4759a4b6e"
"rve27github@gmail.com","Radika","2f788a66-e34e-466a-bbfd-3de920711223"
"fikricf147@gmail.com","","311d81bf-5b5e-4519-a33b-cdd0ee0ecc82"
"ausathdzil@gmail.com","Ausath Ikram","31a21f59-ecea-4bea-9a7b-56dc92c00a12"
"jono210678@gmail.com","","34298723-449f-4b95-b6cb-1d7d5ab71540"
"rheatkhs@gmail.com","Febiadi Wisnu Akbar","34bad6e8-3372-4634-ac93-b10f8882bbb2"
"sntdashi@gmail.com","rawrzn","35159d0a-33cd-4c64-a36d-d37c97e595db"
"muhammadhidayat0278@gmail.com","","351fe76c-5a5c-4d5c-a546-f0dd7ccfec0f"
"111202213986@mhs.dinus.ac.id","","35930877-5702-4eab-9d47-87ccf4035583"
"khairulumam950@gmail.com","khairul umam","3631e3e5-3eac-4297-af3c-6d8648510471"
"muhammad.jibrilmuqoddas@gmail.com","Murphy Lawden","369f03f6-dbd3-49a0-af32-ebfb527624b3"
"rafibudiansyah@gmail.com","","37844c0a-f42b-4bdb-9731-ab9722de70dc"
"tongkorongankostpaksutha@gmail.com","","37d7c1ea-d063-4ceb-93ad-9c9784744516"
"gilangsukmw@gmail.com","","38a5bd5f-c462-424b-986b-db04df906813"
"muhammadhaikalaziz28@gmail.com","Kaal","38a738a0-3329-4001-bf88-05d4de6b11d3"
"cimicimicilung4868@gmail.com","Eka Revandi","38cce9e2-df1d-43a5-bf45-595e25744e16"
"rizqybelajar8@gmail.com","","397f8039-f1c9-42ee-88de-b9597caae518"
"syaefulloharnas16@gmail.com","Syaefulloh Arnas","398ae3d5-349e-44fd-9428-f9335f89c511"
"oppokunew19@gmail.com","","39d68abb-2502-4330-b475-51416bf5269c"
"hrfahrizzal@gmail.com","","3ade0045-eb6d-43c8-8781-ea8d281a464e"
"rizkyfadillah0890@gmail.com","Rizky Fadillah","3b04b506-ff39-4c2f-9f8f-182c62e4b6f4"
"syahidizanagi@gmail.com","","3b2fb3a3-9b55-4338-bf09-8952db4371c0"
"ryanfebrian729@gmail.com","Ryan Feb","3b467fcb-af79-4031-85f8-3073e9343058"
"hajopan12@gmail.com","","3b4c66b3-4ec3-465b-998c-32c9ab7bf2b4"
"imaim3024@gmail.com","AimZ","3b5a0b3a-5930-4cbc-ac78-b1d9d20ac460"
"rafligamercratf@gmail.com","rafly","3b901d8e-7c96-483d-b299-98c8e54f5b57"
"jojohyperbackend@gmail.com","ZeeHyper02","3b9e0c60-5a32-4d1c-b011-46390971b67f"
"ihenry@gmail.com","","3c96f60e-d8ab-4e2d-86f6-6a7f4b40c179"
"dimsartz021@gmail.com","Dimas Tri M","3cdffe97-dad4-47e3-825d-d9c13ae815b5"
"fathurrahman081296@gmail.com","Fathur Rahman","3da30612-1885-423f-a836-3cc7ca33add9"
"hafidnurazis@gmail.com","Hafid Nur","3e20b9ee-f566-4b0f-9e4f-cbb92d31031c"
"helmiputra265@gmail.com","","3f60fb92-3440-4f61-82fe-192c6bcdb301"
"farhansyamsuddin3@gmail.com","","3fc8adf4-a75a-4272-a431-a9b614e8dbd3"
"fauzanabiyan33@gmail.com","Hoshino Abydos ᴾᴿᴼ▄︻デk̷n̷e̷a̷f̷══━一","3ff2ae42-a561-4338-ba61-f444409322c3"
"bagusindrayanaindo@gmail.com","Bagus Indrayana","40194320-d827-4a53-bb8f-36303a6713a2"
"daunn871@gmail.com","Daun","41412e38-3b07-464f-93a4-457ed1cf6176"
"muhammadirfanbaihaqi538@gmail.com","","4155a53f-252e-4b44-a951-851ea5cce606"
"mujahidislami030@gmail.com","Mujahid Islami Primaldi Abdullah","41899d5b-9f1f-4ca9-8be8-9837ea83f493"
"muhammadkevinalvarel@gmail.com","","418f738b-2187-4432-957d-15eb258a65bb"
"galangryandana@gmail.com","","433c45f0-1d8e-442e-95c8-bd9c7b97108e"
"nasruladitri2@gmail.com","Nasrul Aditri","43ea0f85-1b69-4235-ae25-523ad1984d46"
"muhammadariefcp@gmail.com","Muhammad Arief C P","43f5ef95-ac4b-4d5e-bf57-b6bf0c30936d"
"adityamaullana234@gmail.com","Aditya Maulana zidqy ","44045f03-964c-4935-9918-cddc36676278"
"g4mless@proton.me","g4mless","44192093-fadb-4be9-8662-d02e9c47a1b3"
"justgilang99@gmail.com","","46769f5b-f33f-4777-aff1-9c25b9c488e8"
"jezudu@denipl.net","","46a4954b-1b91-4647-a5ba-376bbc299402"
"alfisahri33@gmail.com","alfi sahri","473b0a56-7583-43e6-8511-2e73889e9b37"
"mraihanaf0@gmail.com","Muhammad Raihan Al Farizy","47866d52-c02c-44d9-a89d-76ee1103eed9"
"obenpasaribu@gmail.com","","47e2dd3f-339f-4ce5-aa42-4d89a1fd4aea"
"2206073@itg.ac.id","ZaenalSyamsyulArief","48a70338-aa6b-4f22-b5b1-458da13d85c6"
"ronyronny12@gmail.com","","4b5b6fd7-4f79-412b-967e-0437d3c4c6af"
"malfathiratir@gmail.com","","4bda0567-2a5a-4fda-8d8f-a5998e9d8800"
"lakazamlak02@gmail.com","","4c4abd7a-772d-49fe-977a-57a917adbe6a"
"nazedev@gmail.com","","4cdb4fd8-565e-47c7-9559-215a31f4bd1c"
"revelcahyadi2004@gmail.com","Revel Cahyadi","4d2efba3-a0dd-448e-9a40-a7395a15f863"
"pulungwicaksono2006@gmail.com","Pulung Wicaksono","4d9a29b1-080e-47d0-836b-f33dfbf84c6c"
"ferospedrosa@gmail.com","","501c795f-fd27-4f86-900e-fbbbd02474a7"
"bilgrandov9902003@gmail.com","","503377c4-26af-4890-82f1-a3619953b7b7"
"gbrnmhd89@gmail.com","Gilbran Mahda","509825be-9d84-451e-9833-f2bbd5507ade"
"jevonit252@gmail.com","","512805dd-507c-4193-adad-a1cf5f76a777"
"iwank31052003@gmail.com","Iwan Kurniawan","516deb0d-9ec1-4776-835f-6daba29e7c1b"
"rifanajieagung48@gmail.com","","5182dafb-4a12-4bcc-b967-6ea50aefe7d0"
"anata@yopmail.com","","51832f14-1e08-4543-b1a0-d8107573efbc"
"fajargustiana2@gmail.com","Fajar Gustiana","51896ad3-8184-4e4a-a9c8-4eaa39ac8451"
"raflyazizabdillah30@gmail.com","","51bb6cfe-bd23-4ef9-9992-96b4e1c3a7ad"
"patrickmarcel65@gmail.com","Patrick Marcel","51d0ed9b-5d7c-400e-a75e-b3777dae5be8"
"auranawamadani@gmail.com","Aura N,A","529c4fa4-4894-4c88-ba63-857532f7fef3"
"ayamkucing129@gmail.com","Elang","52b81c76-fa09-4e96-8dfd-f405c5c09808"
"ayyasi52@gmail.com","","543eced6-00b4-4227-82b0-13e626fb1c88"
"itsandka@gmail.com","","548a310d-05a7-4b64-9d5b-032d848446fc"
"ryanda.valents3649@student.unri.ac.id","Ryanda Valents Anakri","54db9209-843c-465c-82c2-d8c4ea72dc7e"
"noaa277@gmail.com","Karakaira","54e0cfb9-55b7-4c00-812f-f04296c11a70"
"larasrahm22@gmail.com","","550fc044-10bd-49a1-9c09-50bd4d5325cc"
"zrbagus5@gmail.com","Bagus","5568477e-e7a0-4a93-b1de-0e775414111a"
"jonfrymarbun@gmail.com","Jonfry Agung Marbun","56f8b4f6-f31e-4faf-87f6-acc8d83bd72d"
"andika@umpo.ac.id","","5768d665-77a3-4ae3-b2ee-37e408c0e98e"
"snarsaggaf@gmail.com","pii","576d1650-71c8-4a5c-81c9-dda3284c13c4"
"azhararizkyrafi@gmail.com","Rizky Rafi Azhara","58871899-6d54-4cd5-bbd0-3d9e2cbe5f9f"
"safuzi333@gmail.com","","58ced2da-dd5c-42da-acd5-0082b5e0e835"
"putrasyaddad@gmail.com","Syaddad Raihan Putra","59d3324c-a264-4f60-91ca-96d9d5def00a"
"bryanfernandoks11@gmail.com","Bryan Fernando Kurniawan Suhartono","5a48dc88-25c8-49e8-8a74-36b493584aa8"
"saehansa@gmail.com","","5a8cf51a-fa8a-44ce-ad1a-898c8487513f"
"kontakpribadiatsushi@gmail.com","","5ab3bd8c-27a2-4552-8f0c-98370dff4e3b"
"mohkaisanalkalbi@gmail.com","","5c15338b-c4a0-4071-ab67-c8f29ffaadaa"
"bayuardana213@gmail.com","","5dc2beb9-46c1-4920-9d07-9708de896778"
"danufebriansyah09@gmail.com","Danu Febriansyah","5e0d9e2e-5ced-4f94-9797-b8083778fe0e"
"pjuanda12@gmail.com","","5e91489c-cacf-4050-b46b-4327255ec74a"
"muzammilulmuttaqin11@gmail.com","","5ee5672c-dba1-42b7-b8f9-e76ba4efd21a"
"raffida2013@gmail.com","Raffi Deas Alvaro","5f46ecdb-f025-4fc6-a981-dd4c4c93a52a"
"markusdamar2958@gmail.com","Damar","5f475128-017f-4021-961c-4d58eef63d99"
"felixcyro008@gmail.com","","5fb48143-4d2c-4d71-bf9a-bd9f44647de6"
"hazelbigbosshazel@gmail.com","","5fecab2f-2382-404e-8f8f-ed031332b886"
"mhd.rijal203@gmail.com","Muhamad Rijal","60fbc970-2079-42c8-8dcb-637c466a7f1c"
"imovic004@gmail.com","movic","61c5d3a0-553f-474d-9950-483cb86057df"
"samuelmarubamanik@gmail.com","","625aa73b-69db-473f-baad-6791ff865e40"
"muhammad.fahri137296@smp.belajar.id","","63a19768-b9c4-480d-95c5-2770393935fd"
"me@anym.dev","","63a87b68-4dc0-41c8-a99a-1335c77dd4e0"
"kithly19@gmail.com","","64428bf7-052f-4b7b-95ca-802f76e93dcc"
"raissatristan14@gmail.com","","645e0a22-a055-4cba-a007-37bd9791f882"
"fairuzfuadi123@gmail.com","","653f2a62-66c5-4eae-a7c5-822a85cd2d9b"
"ahdan.abde@gmail.com","Ahdan Abde Rifai","65d6021e-a03c-4661-a3eb-5452cc89b588"
"ivanrahmat.p77@gmail.com","Ivan Rahmat Prakasa","65d79307-2b78-48af-a9d8-750e66126401"
"hamzahbaik9@gmail.com","","65efcb5a-13e3-4552-9336-f7b68705e521"
"andrianxyz82@gmail.com","","6677b5ae-3171-428d-aa61-22464b1ba80f"
"billysyahputra630@gmail.com","Billy","66dbdf3d-3cf0-4d81-810e-185e61b952b7"
"yusri.irfani@gmail.com","","6773b841-2037-4aee-ace1-a8bea7341e25"
"reiyhan.aditya@gmail.com","Rey Panda","68876807-64ed-49f9-91d4-d7f68eb0e6c9"
"hexorzplay@gmail.com","","689872ad-856d-40f6-9d87-86d580394fb4"
"codewithwan@gmail.com","","699646d9-192a-451f-99d1-46d4a42e4eec"
"mtaufiksaadi28@gmail.com","","6a9264f2-2731-4e4a-8e29-9fc1a505e6dc"
"alamsantoso.12@gmail.com","Emowmow","6ab9934d-8f77-4e32-b4db-d09222fa4e44"
"kunengrestu@gmail.com","","6ae98213-5e3a-41c7-b356-03ae6760993d"
"andikacahargen2@gmail.com","Anka Tama","6b62be2c-912d-4809-af64-08ff52aef4b8"
"rmz4mc7wg6@privaterelay.appleid.com","","6bab0fab-6c05-4ed5-9d4d-bbabaf62b513"
"handikacoco@gmail.com","","6bd01f53-7884-40a2-aeb4-34b000120088"
"riziqliliulilalbab@gmail.com","RIZIQ LILI ULIL ALBAB","6d10db2d-3ac2-4008-90dd-141311219c53"
"syisahi4696@gmail.com","","6dd8c78b-9884-4944-b2d4-de2bb8711da6"
"akhmadwibu05@gmail.com","akhmadkhoirudin","6e32e4d3-25e7-47d5-97b5-60955969e22f"
"andika.namikaze02@gmail.com","Andika Fitra Darmawan","6eae5e60-544e-4bf2-81af-7b1138ae967a"
"luhalisarafansyah@gmail.com","","6eb155d0-aea1-41ca-bcdf-7effa9912366"
"novianybl@gmail.com","Novian Project","6ebfa0ad-fde4-42f9-9d0f-8e51e465d534"
"boostlivious@gmail.com","Raynaldi Siahaan","6f089bd4-8829-4a62-8c06-104319116e93"
"rasyadbimasatya04@gmail.com","Rasyad Bimasatya","6f237adc-f724-424c-baf1-4d13f03887e1"
"rezyaindisaputra@gmail.com","","6f43758c-224e-4d9d-9273-f3a3df51a11c"
"rahardisalim23@gmail.com","","6f8a13c1-3836-4715-9181-dc21314cd8af"
"alfin.rozzaq.270206@gmail.com","Alfin Rozzaq Nirwana","6f974b20-6309-4b44-9c7c-165d4cb458c9"
"muhammad.toffazal00@gmail.com","","7055961a-7b40-49d9-ace2-6ce7ad48f14d"
"raaflynr@gmail.com","","706b67c2-e410-4143-a7fa-213d35178db4"
"kujouarisu@gmail.com","","7216ca14-7cff-4d0b-b8c9-70427af21b4a"
"rohimsaga7@gmail.com","","722989b5-f685-4fc3-9bc7-76f04c479d49"
"rayhanlubis01@gmail.com","Rayhan Lubis","72bf1d04-8b95-4f90-a88f-f78c0571d841"
"raflyirham1005@gmail.com","Rafly Irham Safatulloh","73404aea-a007-4629-b9dc-fb26cb307e57"
"sopiadi2006@gmail.com","","7364d4fa-f6f7-429f-a95d-f6775eca6d9f"
"haydar130305@gmail.com","","7462e4b2-b446-4562-9bc8-e08c07d157ef"
"arilindra21@gmail.com","Moch. Aril Indra Permana","748de15d-374a-4756-89e2-75754e90542f"
"gundowijoyo7@gmail.com","bahaywuj","74bd7b16-a75a-4ffd-908d-0b00f6ced98b"
"yayohajid@gmail.com","","74f39d03-eb00-4a6c-81fe-f2d3d2f60cf3"
"codesphere01010@gmail.com","Dzarel Developer ","757980ed-43fe-4092-9d2b-387e86c5b596"
"stefanuslay01@gmail.com","Stefanus Lay","763244a7-0bc5-4668-811f-4e20ae8825bb"
"fawfawfaw994@gmail.com","","76c148f1-fef9-4489-8421-1cb875d7747f"
"resa.rahman@gmail.com","","76da63db-4937-44f4-bd7b-2673db6b7c40"
"bryann.carls@gmail.com","","76e6cd37-478a-45f7-ac57-81ff1b646761"
"m.saidibjm4@gmail.com","Muhammad Saidi","786820eb-eccf-4c57-b05a-9cc4249672c4"
"securtydit@gmail.com","","78d09810-d778-46e4-a69a-c055ebc29998"
"helmygarcia67@gmail.com","Helmy ","793b5a30-f0c7-4dfe-afc8-0e85e7ccd49c"
"akbarriansyah339@gmail.com","Akbar Riansyah","793ce294-f69a-4aba-8259-f20ee2b4c73f"
"revanza.firdaus@gmail.com","Muhammad Revanza Firdaus","79c01c0b-4bff-43af-9ec9-d1fa29f3987b"
"rezokushop@gmail.com","","7a27e0da-9593-40a7-8462-9daf6e96e3ae"
"aril.permana@paper.id","","7a960082-4df8-45fd-99fa-a204d6688caa"
"atmasusanto38.imphenhack@gmail.com","","7b247e32-ed65-4865-a1ce-0e9cc4cb8297"
"ihsanwar77@gmail.com","","7b4b6a6d-1cef-415a-aca3-f98648f0f340"
"fsparatos@gmail.com","","7c30facc-eb6f-412e-88c4-0095f9bfdd1b"
"shalahuddin.4332311020@students.polibatam.ac.id","Shalahuddin Al-Ayyubi","7ca6c4b1-be0e-4993-83d8-0eff8945ea50"
"arissetia.m@gmail.com","Aris Setiawan","7cbcf002-8e92-4b28-bcee-1776ddf5ec86"
"andikadwipwf@gmail.com","","7d5ac7ef-704e-4275-b1b6-59b497548365"
"msayyid.rafeed@gmail.com","Muhammad Sayyid Rafee' Djamil","7da76028-0ed7-434e-b51e-d163cb3d3585"
"muhammadriskiakbar2009@gmail.com","","7ecd33ed-40b6-4236-981f-5319fdd390d3"
"bobypratama.dev@gmail.com","Muhammad Boby Pratama","7f0592c4-b0a6-476d-a436-75a8fe753f32"
"akbarrozaq691@gmail.com","","7f1689fe-9d54-4cec-a1f0-db5ffae98d3b"
"220605110044@student.uin-malang.ac.id","Ridho Aulia Rahman","801d6313-6a1c-45d3-9a64-1db88269e250"
"muhammadrestu1108@gmail.com","Muhammad Restu","805d5fc1-6f9d-4e37-b1bc-514ed4e17bea"
"iyandabes1@gmail.com","Astha","80e0229e-e268-4d22-a0c5-4610bd2b4bdd"
"ridhofahmij225@gmail.com","Ridha Fahmi J","80f24b96-36a6-44eb-a361-26bd04554609"
"wisnuajipamungkas354@gmail.com","","80f40577-19f8-45cb-86d1-dcc60472b9e8"
"mgsmfr@gmail.com","","8177c99d-28f5-4a88-8415-9abea3fb934b"
"eguinjonathan08@gmail.com","Guinn","8268ab9d-acc1-4379-b9e9-331392668db2"
"fardanyudhistira16@gmail.com","Fardan Yudhistira ","83635084-c207-40ee-ac11-742b213fe843"
"quyun532@gmail.com","","83c6b62f-5c95-4e74-b16f-a9f48a9346cc"
"zakamaragames@gmail.com","Muhammad Ilyasa","83cc1c2b-01ba-438c-8c62-7c11a75c531c"
"krisnaguntur71@gmail.com","","840aa041-314a-44cc-8b52-e529b7aab3c3"
"mhmdazkanfl@gmail.com","Muhammad Azka Naufal","8454c75e-b0e4-4c04-a580-c13cddd63f3c"
"fajarrahman216@gmail.com","Computer","848435a2-d024-497a-b020-f320155ef675"
"aswinarung2@gmail.com","AswinA","84fe21f4-c03c-4042-b535-d61d92d33288"
"davidk3476@gmail.com","","85887410-b9e3-4861-8ebb-e35474bfd003"
"glenrioariesto@gmail.com","GlennJackson","865d44a1-be9b-4dbc-8110-02e91a0ad557"
"ferta996@gmail.com","Ferta Junindi","86be6a41-8d6d-4f91-b5d7-782d9a2de14e"
"julianisa.ar@gmail.com","Fatah","8709fc42-9f88-496c-8610-6cae5253cce9"
"indraaziz15@gmail.com","","8786bfb3-7f3e-40ed-9f46-f3ab5b1a938a"
"hshino@student.telkomuniversity.ac.id","Muhammad Hashfi Hadyan","87ce6b8d-af52-449b-a11a-854042633edf"
"farhansyahbanna07@gmail.com","Farhan ","8a1671be-64ea-48ec-9b24-f2589fc33281"
"nawvalovsky@gmail.com","","8b023aff-3d10-44dd-ad30-2251604bd319"
"mininsna@gmail.com","Minin","8bcee28c-19fa-45b8-bc0a-35e97af0110c"
"rzkalih330@gmail.com","","8bf4de04-3335-43cd-9f09-9a0bcddd3e65"
"agusse9986@gmail.com","Agus Setiawan","8c49cb67-5a7b-4bff-bd78-141166b1ce39"
"ixanramdhani@gmail.com","","8ca8015e-07c1-4d57-86f0-3223165b10af"
"bagas101021@gmail.com","Bagas Pamungkas","8d3cdd6e-cf34-4462-a2b3-91874bda4839"
"maulidanfaka391@gmail.com","Faksz","8e4ccb11-d491-4aaf-bf78-15fa333ea71d"
"mugteadotco@gmail.com","MugTea","8e907eae-5137-4499-a13b-11fd89106ae4"
"denandragagono@gmail.com","","90b98bc8-3d98-428f-8549-07457cf4bda0"
"trian.radis448@gmail.com","Trian Radis Pengestu","91064af2-a72c-4c48-9cd5-acdc2b6527f9"
"mnazriel177@gmail.com","","9130249c-c366-4c47-9d4e-337c35abce6a"
"onesta928@gmail.com","","91582716-23d1-42f9-afc8-6eb857bcc289"
"dimasmaulana.idn@gmail.com","Dimas Maulana","917aa59a-bf05-47af-a26b-f78f9faeaac0"
"naufalhaidar946@gmail.com","","91c5e840-3758-4c7f-9dcc-b621b6ba4d10"
"sandysyarifhartanto@gmail.com","Sandy Syarif","92e971bf-863c-4d66-a522-81a1a864c56c"
"77leviann@gmail.com","","93cc57f9-48c5-4b71-83bd-63bac1f5717a"
"roxyuciha@gmail.com","Robbanie","93d67e12-dbf6-4df7-882f-38ff471c9cf5"
"thisiswahibza@gmail.com","","93ffb004-f16d-4dd8-bd4e-55384c056138"
"raazan.muhammad.ikhsan@gmail.com","Razan Muhammad Ikhsan","953d0ce6-49ee-4606-9616-23b204e61710"
"farilpratamap@gmail.com","","955d4a9e-7e58-4210-86af-d38f8794a565"
"candraggml@gmail.com","","9665ef56-2b46-4883-bd1d-29d20c5a6f98"
"aulianaufaln@gmail.com","","9815d40b-d962-4a75-8383-8993407f89a7"
"rubenrextonb@gmail.com","","98236b68-a066-4538-9ae5-d5b063961244"
"bettyindrawati4@gmail.com","Betty Indrawati","98352ac8-241d-4433-b61b-60d9904b7999"
"alfandy115@gmail.com","Alfandy Afriandani","98a8d32e-1408-4f29-a374-fba3470bbe7c"
"zahranvidian@gmail.com","","9939f383-0ee4-4b8e-be03-08977324edd0"
"alifr5353@gmail.com","Alif Ramadhan ","9a5f346e-dcfe-47d2-9e43-80dd353d46e3"
"gn.mailwork@gmail.com","Grafis Nuresa","9acbdac5-f7aa-4834-90c4-4d19d67410f4"
"zexceed12300@gmail.com","Ihsan Nul Amri","9b067871-2da9-48e7-b15f-1e604d422d33"
"haikalrafifas@gmail.com","Haikal Rafif","9b212b4f-a019-48cd-9e6a-0f9d3c0ada4d"
"novealdioardhan@gmail.com","ardhan novealdio","9b9b68e3-f8fa-47a2-a47b-13af2d5cb22f"
"nabilpasha230606@gmail.com","Nabil Pasha","9c63cf90-68af-4566-aa74-562a9a10d73a"
"nash.collage.life@gmail.com","Muhammad Nashrulloh","9c963bbf-6eb0-46c8-86ce-ebf1d0d09b38"
"hhhh25737@gmail.com","Muhammad Ridwan","9cb022e2-1f94-414d-b9cb-c1781d676a72"
"samuelsiallagan49@gmail.com","","9cf09ee4-6ce0-4afe-a36e-9f472035cb34"
"sirrauf412@gmail.com","Sir Ananda Rauf","9d4ccd3e-c2c1-4ef9-9fda-007403908ecc"
"bentambunan20@gmail.com","","9d539268-8beb-4e57-80fd-6d2293776528"
"falihafiq1928@gmail.com","Muhammad Falih Afiq","9d8a38c3-e631-4b31-8f86-e3f6408f7330"
"muhammadalfathir@smkwikrama.sch.id","","9e26cc6f-048d-41a1-b657-4f06f5198aaa"
"hazizhaziz549@gmail.com","","9e63c2d8-d2c2-4ac7-9c2e-226965c4f402"
"hekate071@gmail.com","","9eb70e94-b963-4f4c-9852-7f6e95e7987f"
"mramdani1230@gmail.com","MRamdani","9f472bbd-aedb-49be-b8bf-0473d55f2622"
"dewarahmat1233@gmail.com","Matt","9fece7da-1c1d-4e07-843c-9cd26aee60b5"
"derriverse@gmail.com","","a0148ac9-002a-420e-bc23-ac7b3ee9a33c"
"jalagohu@denipl.net","","a050ea6f-8d2c-4b4b-9f64-306517b63613"
"muhammadrobisasaki@gmail.com","sasakirooo","a2770339-e7e6-4b20-9cfe-99c8d2471930"
"triwibowoilham2@gmail.com","Muhammad Ilham Triwibowo","a2c1d665-f360-4cd8-a313-5fee29b91d6e"
"yuratamma@gmail.com","Zidna Fadla","a2e938b8-7403-4e25-bc34-63b9838983a6"
"sudo.rusydi@gmail.com","","a32c0e81-1fbd-4bec-8e91-4cd6d10f553b"
"nazihelfikar68@gmail.com","","a42e221f-c198-463f-ac11-84060fd9b494"
"adzthariq@gmail.com","Muhammd Thariq Adzikra","a4c434da-2491-40b2-aa57-7b4aa53a69c1"
"saintparid@gmail.com","Saint Jameson Parid","a53db442-4a6b-4431-afe1-b9d148b977fb"
"asja77502@gmail.com","lioXploitcs ","a57807e3-bafb-4c4c-b23e-ba57c43c2cc2"
"realdrenzzz@gmail.com","Drenzzz.","a5ea628a-24d9-4754-b461-3eda53da53bf"
"maldenai76@gmail.com","","a5ee4302-50ff-4463-8f47-9cf60b414960"
"yogaagustiansyah01@gmail.com","Yoga Agustiansyah","a60c10f3-d73a-4953-8d2c-1e413c881470"
"masuwha@gmail.com","","a6acc3eb-6b59-4d31-881a-c4a1aecf8cfc"
"jazaniezt07@gmail.com","M. Abdillah Aljazani","a81baf2a-b875-4c86-84ab-618f403abc60"
"rickogurning333@gmail.com","","a8e87809-36a3-467e-8a49-8a730bb70724"
"zakyfarhan1326@gmail.com","Zaky Muhammad Fauzi","a90a9400-0576-4708-8912-c630807b9391"
"sandysyarifhhartanto@gmail.com","","a91c8500-69fd-4602-9795-779d006a305e"
"andra.pratama@riswan.com","","a927c690-8ebf-44eb-bf97-1d780729a2c4"
"naufalyuprata@gmail.com","","a93fd9dd-3e36-4ba1-b668-988dc2d186c6"
"mubarrokwildan1@gmail.com","Akazano_abe","a9807283-d4de-4990-8985-b22ded98e4cd"
"fackyouu128@gmail.com","Reza A Maulana ","a997e674-f927-474b-b817-9543f722aa9b"
"riantrito@gmail.com","","a9dfd864-faa1-452a-b074-8498e6e08af5"
"alhikam200@gmail.com","ΑlhikamΔirga⌘","a9f7ded3-ed87-486b-bd11-0d4b3cb5d9a7"
"afiffahriafrizall@gmail.com","","aa088d9f-7251-4673-ad6b-88261666710b"
"miawspecialist@gmail.com","iyep","aa42b892-74dc-4a00-85b5-c7f78127c4e9"
"muhammaddean27@gmail.com","Muhammad Dean Fahreza","aa4ba09e-4ed0-449c-ba98-5441844f022b"
"harissabil04@gmail.com","Muhammad Haris Sabil Al Karim","aa9d683d-0787-4279-8085-541459b1d8fa"
"quyunisnawan34@gmail.com","","ab36232b-b46c-4d30-8bb3-9b11e3e9ac7b"
"mnabilfs22@gmail.com","Muhammad Nabil Farras Sulthan","ab89f5c9-95fe-4808-81be-9bdc9c3b21ec"
"mochamadrifkyrifaldy@gmail.com","rifkyrifaldy","aba1da85-d87b-4d52-bea9-35639c4f1d64"
"muhammadyustanzah@gmail.com","","ada3b4d9-745b-41c2-95b5-1a8275e29882"
"sulfikaralijun@gmail.com","Sulfikar Alijun","adbd7ccc-252f-40c4-b5db-8940392da7e2"
"mitsuhaadly@gmail.com","Adeley","aef3f5b3-23b0-44e7-aa73-edab6ab20c6e"
"mobilezero24@gmail.com","Muhammad keisya fadilah","af8d0e16-0358-4d96-b822-a4023b06752d"
"arvardy@student.ub.ac.id","","b022268c-2ac3-43a7-a5cf-c0bae568477a"
"zulfikarm022@gmail.com","","b04d2b39-25c2-4e65-a28f-8b35edeb6137"
"hasrinata@gmail.com","Hasrinata Arya Afendi","b1171213-ed39-4333-b58f-2424d3d2b9fe"
"wahyuikbal777@gmail.com","wahyu ikbal maulana","b1e1ba24-35fe-4dd7-b37f-77d42314b3d9"
"rafiadly259@gmail.com","","b21cde91-57f4-4f4e-b082-9130dcaff5a6"
"rakhaakbar522@gmail.com","Muhammad Rakha Akbar","b2208e46-26b9-497c-9105-7bfff4fd89f3"
"diwapraset02@gmail.com","Diwa","b22a09b9-7079-49c9-9734-0f69f73cdd67"
"anakriryanda@gmail.com","","b26a5fba-f01d-49eb-a0ab-b0fa67d846a8"
"sxntient.labs@gmail.com","","b2fb784d-cf93-4d45-a8bf-0fbbefdb8761"
"vlakasofficial@gmail.com","Fauzan","b35057c8-d5c3-4e20-b027-025f3d959398"
"sandifadel739@gmail.com","","b43dfb80-8037-4478-a050-e28b0b0e243c"
"alfawwaz.naufal13@gmail.com","Naufal Al F","b44432b9-c6ce-472c-b53b-fe6f838cc283"
"ilhampradani8@gmail.com","","b49fddfb-0be3-4d4f-a6d3-927c14ac9b81"
"divoveenda@gmail.com","Veenda Putri Divo","b4c53c87-6310-4143-bccf-94ee0440c17d"
"fazaradityarma@gmail.com","Faza","b50caf6d-e433-43d8-bef2-1ff073b13027"
"muhyusufsyamsyam@gmail.com","Yusuf Syam","b5113861-be40-4651-8ab2-33a8051656a7"
"koding12sam@gmail.com","Muel","b569b9c4-b173-45a4-b022-5bd0607e84be"
"zenaufa@hotmail.com","","b5a1b1e9-3bb5-4076-88a1-d36ce4dde6fc"
"naufalfadhilah689@gmail.com","Muhammad Naufal Fadhilah","b6a3051f-1890-440e-9956-c380e2b5c139"
"putrosrv@gmail.com","","b6ad4101-23d4-427c-9376-964cf995fc18"
"fajarskyy833@gmail.com","","b732bc7c-b3c7-4a79-8529-4622445343f3"
"fuwafuwa@tiffincrane.com","","b76b604d-9621-4aaf-bdf1-bc2da2f8248f"
"aminaprzaa@gmail.com","","b7e71f93-6fce-4ef0-8eae-1a15634bcb9e"
"abdurrahman.hafizh02@gmail.com","","b7f0d89b-6bdf-40dd-b5ca-442130b12c00"
"apriliyantoecha1@gmail.com","Echa Apriliyanto","b8350a6f-621e-4c2f-9d2b-cf79c2a2f091"
"rizkysrg62@gmail.com","","b864e321-9c43-422a-b980-c91809d3039a"
"kuncibatara@gmail.com","","b9066990-a721-40f9-8478-5f806c80c751"
"lotteaquamarine@comfythings.com","","b96bc4aa-4a97-40cc-be49-2560df93751a"
"zlutfhi89@gmail.com","","b9bb3ff3-5539-4b48-8101-90517a06cc42"
"theownerkill432@gmail.com","","b9ef4db0-6b7b-4dba-aef7-9434ce104e07"
"aryandasanggadiennata26@gmail.com","R3X","bb1696b1-7f82-46fa-805b-6ec79e673e40"
"rickyricko302@gmail.com","Ricky Verdiyanto","bbbf243b-f08e-4911-b4bd-838200c50cb5"
"nalindasepti@gmail.com","","bbc5e3e7-cd72-4739-81c6-199876b02084"
"gamerzcraft37@gmail.com","Gazach","bbfac7a8-2a13-48a2-9f29-499f33d68b8c"
"rewyzzi@gmail.com","","bc02bb7b-8193-4ddc-b8ca-d858b212de2e"
"alfanhuda2004@gmail.com","Alfanhuda","bc2bec53-ee17-40f7-9640-9556745a171a"
"tengkusripratiwi1608@gmail.com","","bd35df8a-9a78-4b14-aa8b-8366e9de58bd"
"bataraa@proton.me","Batara Krisna Danduru","bdb30d1b-9754-4333-b7e0-bce7e7f9ebda"
"hayyannashrulloh25@gmail.com","","be5f3f84-3435-4800-b019-3b6564113004"
"050393537@ecampus.ut.ac.id","Fahrur Rozi","be83a7e1-abf4-43ca-a23c-d335449b844f"
"arivopwenz@gmail.com","Ari Prabowo","be9d3160-e00e-4547-bbdd-c6546fe0af71"
"faaldy16@gmail.com","Aldi Fauzi Ilmamuslim","beb5d548-c976-4110-87f2-75d4f8465e2b"
"arbasfardan@gmail.com","Fardan Arbaz","bef2164a-6f12-4d69-847e-5d6ac19f554f"
"bwfzbw@gmail.com","jarss (zharfan)","bef8c2ba-c959-4896-a0d3-463e2f77570b"
"nabielilyasa133@gmail.com","","bf0d6b84-0508-4917-8ff5-e23e1d3ce23b"
"xecloud4@gmail.com","A. Abqory","bfacc996-8020-41fe-b323-c5c3dac7f425"
"christiannathanielp@gmail.com","Christian Nathaniel","c00a24fd-44b7-477a-9048-05007fdfd76e"
"ardyrkm23@gmail.com","","c048d109-8d91-49f2-b04f-fc86866c1487"
"risfarishk@gmail.com","","c084728e-f1c0-4775-b0b5-10ab0441bab6"
"awannshafwan@gmail.com","Awan","c0f6b85a-4723-484d-8fdf-713f7e5aa9f7"
"willygurning14@gmail.com","","c19ad425-8df8-4b37-8f35-797767f48672"
"0110224081@student.nurulfikri.ac.id","Raffa Yuda Pratama","c288a2df-a1e6-4beb-a6ea-aab0b7f912df"
"240605110160@student.uin-malang.ac.id","","c478b5f7-fc09-4b97-8354-e8889f852450"
"muhammadsultansyah14@gmail.com","Muhammad Sultansyah","c4aa882a-bfc2-4a1c-a9e7-89f9a37b9fba"
"grecyllagrecy@gmail.com","","c5611544-3041-4a31-866a-84ad0b781aca"
"ilhamthaariq@gmail.com","Muhammad Ilham Rijal Thaariq","c5ecbd2b-8d7b-4440-a75e-2bb58b1ed8cb"
"basukiridhoalghifary@gmail.com","basukiridho","c6323853-13e3-49dc-972c-a7cb789f0b32"
"kacunghabibiganteng@gmail.com","KacungHabibi™","c6dab1e3-8177-4784-936b-364a9e58a076"
"nhardiansa@gmail.com","Nabil Hardiansa","c79d4b2b-e63f-4187-a8cd-520fde333ab3"
"bhosya27@gmail.com","Bowoksae","c8f35350-384f-4933-be59-d785dbe06abc"
"m.sadiqin46@gmail.com","MuhammadSadiqin","c92b26ed-ddc1-45e7-9a17-f2d2ea7b9d2f"
"gayuyunma1123@gmail.com","","c978891b-71d3-423a-8c97-5648304f5a47"
"akbarriffani19@gmail.com","LebahRajin","c9b2c04a-5027-4ccb-bd0b-2c26c7e9f619"
"ajangrahmat@gmail.com","Ajang Rahmat","ca299304-dfa7-49ba-aa1d-0ed259ffe0fc"
"auliyanihlah@gmail.com","","caa569eb-822e-418c-93aa-df23563a178c"
"razandirgham@gmail.com","","caaaf8b5-ac4b-40b5-9549-f0f8ec9d6cd1"
"profadlibae@gmail.com","Acmad Fadli Aditama","cabaade4-b256-4798-844a-78f36cb16b0b"
"atnandimas@gmail.com","Adnan Rohmat K","cb2161a5-7c09-4360-908b-f2e629be1cda"
"sitizahraazizah23@gmail.com","Siti Zahra Azizah","cc7e4a74-7c3d-44cd-af91-a1b9326cb13e"
"nurdin99.ek@gmail.com","Nurdin","cd64c94d-ca81-4169-bb4d-e2af2dba4a55"
"fauzanhabib56@gmail.com","","cd7ebe7e-a914-457f-be95-2e6256342552"
"reyhananf7@gmail.com","","cdab11c6-ef41-4e2c-ad9d-f9636681e9f0"
"loidthegost@gmail.com","","ce458e26-313e-47cd-937d-e5e95f77a136"
"omnidevv@gmail.com","","cf1eadaa-a313-4760-808e-37dc70446ccf"
"m.ilhamjaya1808@gmail.com","Muhammad Ilham Jaya ","cfc296db-5cd7-4485-b8d0-ba330f5c148c"
"risbulgt@gmail.com","","d00b9542-2bc4-4a1c-acca-e2e58f7363e5"
"lembahku@gmail.com","","d15d8329-a248-4339-8090-69c40ca2a050"
"fcrizkywijaya@gmail.com","Farid Rizky Wijaya","d1c950e6-fb9e-4d53-9c2c-e77477a9a1eb"
"bimaadam.dev@gmail.com","","d263ee7e-e4fb-4b55-a5d9-0207520e0fa8"
"felixarajiph@gmail.com","Rajiph iqbal","d350da40-7543-4073-b6a5-95c79d705dd3"
"ahmadfawwaz103@gmail.com","","d396661f-c733-4991-a212-76b9e4ecb02c"
"hafizpramudya17@gmail.com","Nflz","d44ab980-6982-46b3-8da5-fafef0dedd9f"
"johnobama24keren@gmail.com","Hiuaaaaa","d4953c02-1a07-476c-b5ca-7dfd1e91d28c"
"aliakbar06110@gmail.com","","d4d2c768-3296-40fa-8c87-3bf0b9a54fac"
"sanjaya28051@gmail.com","","d5ff8a92-2b82-42b9-a143-2cd8c376c1ef"
"nawvalovsky@proton.me","","d731aa31-8893-4496-ad36-db71729e3419"
"agung.astanto14@gmail.com","","d744ffbb-7011-43b6-a4f8-7ef2f105ba12"
"yumpp24@gmail.com","","d7587b8e-51fb-404b-ae31-7f5a86c85119"
"novandiramadhan80@gmail.com","","d8f27598-3fed-47da-be14-86c59f70387a"
"sfatimaple@gmail.com","Sfatimah Azzahra","d937a04d-da2e-46e0-9e38-b36d244f8daa"
"andriscreen@gmail.com","Andri Irawan","d938e47a-5a8d-41c2-9952-c42c7d83f291"
"suryami62@outlook.com","Surya Ramadhan","d9a4b3e0-eed5-4c63-984f-096a8fb0a03d"
"abdalhaqqm@gmail.com","Abdalhaqq Muhammad Saih","d9b2f557-027c-4c2e-85e0-f157f2df4917"
"alijun.dev@gmail.com","Sulfikar Alijun","da349dec-eee1-4ed4-bd97-d8322f1ab36e"
"warsenosetyono@gmail.com","Warseno","daaf8fd0-2660-4462-990b-9eab46df6f47"
"kemasmuhammadalqodri@gmail.com","","db3ca0d6-67c6-48c0-9da1-e4b45ca3f921"
"fadlankurahan2@gmail.com","","dd022635-06ed-4ce2-85c1-7026c954e5c7"
"iqbalharimurti1@gmail.com","krenz","dd38bb9b-c45d-4ce2-b4b3-f99b8c8d7e82"
"obarya24@gmail.com","Afdaan","dde9ea50-6a95-4a73-a7cf-0c1e9e457c56"
"jexawe1022@okcdeals.com","","de072603-83a0-4d19-b601-d7bd25534d3d"
"mdjauharil29@gmail.com","Areal","de4190a8-2a66-4e96-aaf6-c3fb0ca838a7"
"diasdigital03@gmail.com","Dias","dea38d11-ef5f-492c-b107-17b509326138"
"apirahman55@gmail.com","Api Dev","df939ac3-de24-473c-8006-cf512fec4e10"
"rvlionxz@gmail.com","Reval Maulidan","dfad62ce-3285-4d1d-8ad6-8cb3a618a3e8"
"tsqf.hx@gmail.com","","e09e8094-49ae-4cb0-9e25-8cb548f6b37e"
"bloomsky133@gmail.com","","e18f82ff-7809-4274-b81c-b3dae3bac731"
"asepharyanalm001@gmail.com","Asep Haryana Saputra","e198581d-62bc-4098-8dc8-94302face1d9"
"kussokurae990@gmail.com","","e1dc4e80-c85e-440f-977e-32ac86ac4a8e"
"kaoricicak000@gmail.com","","e210703d-e0f2-493a-b976-ad02b714fd4d"
"felixvalentino58@gmail.com","","e21dbd95-0379-4087-84d4-735a7e280990"
"ferdialf.dev@gmail.com","FerdiAlfian_","e2248b8b-b6cb-4a9c-9746-eba0bc8d03ce"
"rafiqgans32@gmail.com","Ainur Rafiq","e26b3480-1f75-4038-9c73-c5db5c478e08"
"thed0704@gmail.com","","e27f8e2b-2ba4-41c8-8ef1-1a1c8299ec38"
"voidstayprivate@proton.me","Corteza Familia","e4747f46-e751-4e42-8190-5c8be219d50e"
"hoshikochan93@gmail.com","Rechan Dinata","e5770488-35c2-4298-a97e-b79f863c0c2a"
"ytbchanelsukiran@gmail.com","","e582bdf9-209e-4aa0-b052-043a664a5a27"
"putraaldhyansyah@gmail.com","","e59ed718-2a1b-4f86-a4e1-6063384a5fc1"
"gilarwaluyo@gmail.com","Abimanyu Gilar","e5d0bd59-1f07-49b7-8242-4ddb620e2a8b"
"noisyboyss887@gmail.com","Kangmas Darr","e65bf229-f745-41b4-a54e-875a765aa745"
"farelfebryan06@gmail.com","","e68e50fd-fc75-42a0-8b84-70bfaf508d01"
"wisdom.wahyuaji@gmail.com","Wisdom Wahyu Aji","e6a9a555-7af1-4abb-8ad7-1162e3be258a"
"b19marcel@gmail.com","","e6c844ce-a58a-414e-b0e3-8daf404a79f5"
"devakhri.farhan@gmail.com","Devakhri Farhan ","e725132c-af86-4da9-a220-57288a436ab2"
"haikaljitra35@gmail.com","","e7bec056-7f63-4642-a763-afa39c3c340e"
"evanluis198@gmail.com","Evan Luis Tanjung","e8d69460-4eb9-48c7-a921-4738ad7c8dc9"
"ikhwannurrizkif@gmail.com","","e913c217-2dde-4498-909c-4b2d5afd89d1"
"ryandraa27@gmail.com","","e98fbabe-4120-4d16-b11f-e447467c8ef9"
"mtaufikska@gmail.com","","e99519f0-630b-4293-8487-a17d667fa708"
"tudebaliirl@gmail.com","TudeStillLearning!","e99abe65-5ce7-49c5-930b-b7e456d55661"
"adam.daniamm@gmail.com","","e9b65cdd-9c7c-471d-97ac-b02f0434e87f"
"boboi0704@gmail.com","","e9bc3d6c-67af-4687-8b3b-a122a93710ef"
"nathantanusubroto15@gmail.com","","e9ef0f76-533a-4016-ab14-420934da3fe3"
"mikmself@gmail.com","Muhamad Irga Khoirul Mahfis","eab56176-5f01-4ff2-8dca-51bebf7771fc"
"yuenvengozatheowonata@gmail.com","Yuenvengoza Theowonata","eac6a2e3-017f-4c92-ae73-38bc43520640"
"dexangga06@gmail.com","","eb0176ef-9cd5-474d-9bc2-115afb6eb831"
"reeimu28@gmail.com","Moruten","eb2c7101-f5d4-453c-80e8-1f3f77d350dc"
"henryfaaa@gmail.com","","eb350f16-56c0-4e05-a483-7300ab363c6b"
"kelvinlihandy2005@gmail.com","Kelvin Lihandy","ec3d9492-cc18-497e-b63a-a1c06281bcaa"
"alyssashane2607@gmail.com","","ece0ce22-4193-46f0-af02-d0153e896ee4"
"yandrajafarsidiq8@gmail.com","Yandra Jafar Sidiq","ee2aff01-ddc0-40fd-afda-db8e0c0e9556"
"rikogans754@gmail.com","","eef4fdd0-0198-4749-b099-0d558ab2ce22"
"arkandi.2020@gmail.com","Muhammad Arkandi Syahputra Santosa","ef13c12f-2cca-4ddf-9dad-3d387a89bbf8"
"alfaris.mgk@gmail.com","","f01ba8b4-7ee2-4561-bc22-73bbfb712ea0"
"bilorpanas@gmail.com","","f05211c3-aa26-473b-a6a4-225424349e81"
"limakaki881@gmail.com","yth.mas.kanjeng.gus.mas.lui.s.pd.kom.h.i.alm.h.lc.hc.pc.amd.intel.ndx.aka.","f0cace35-b865-4475-9ddb-5fce24f655f2"
"arvanardana1@gmail.com","Arvan Ardana","f1df8edd-2549-4c98-be46-01584aa134eb"
"farreldiego29@gmail.com","Farrel Diego","f26c75f5-6ae7-446b-aa47-695ba13e7c7f"
"wizzkingchannel@gmail.com","HabiebAnugrah","f34efb80-ad1d-434f-a319-e1d54973ff12"
"adityakurniasaputra903@gmail.com","Kurnias","f377d789-ecf1-4a12-b72b-a8ffe30cc123"
"dav1nalifianda@gmail.com","Davin Alifianda Adytia","f3a25278-ec1d-496a-a89c-030c5c521111"
"rifqinanda60@gmail.com","","f3fef42a-9d51-40d6-b6e7-59f25bf068c8"
"notmadz132@gmail.com","Mu'adz Abdulloh","f40b14d5-0cc4-4605-9893-8f27dd34de71"
"qodria589@gmail.com","","f44ce041-e64c-4cbd-a104-83964aa37ccb"
"fahrezaxgamer@gmail.com","Fahreza Pratama Hidayat","f47cacb7-e099-4816-96c9-77f858414235"
"dyas@live.com","Drestanto Muhammad Dyasputro","f57ac77c-7ea9-4b92-aeb5-a7131a5052dc"
"ahmadgozali4040@gmail.com","Ahmad Ghozali","f6938963-132b-4471-90c1-2a252923b89f"
"jony.huang.me@gmail.com","Jony Wijaya","f786208b-28cc-4484-935f-b88f2f75d6d0"
"afrizaahmad18@gmail.com","normies","f7b6ebf3-3bce-46df-b18f-22763e552e6e"
"satyabr147@gmail.com","","f833772b-a037-48ae-90eb-c3b02a06a3b9"
"gmrikza05@gmail.com","","f83bbabf-f6e8-4023-9626-2a97a0c4c994"
"andikadibya92@gmail.com","Andika Dibya Azzumardi","f8cc3af4-3a3a-4a3d-8314-972725366ffb"
"rizkynursanto48@gmail.com","Rizky Adi N.","fa57208f-b1a7-4766-9067-d4531833f4b9"
"muh.indaarr@gmail.com","Muhammad Indar","fa7f0fc6-7449-42ac-a80f-560960345ac4"
"laluarya633@gmail.com","","fae5c5a9-6a26-48d6-b04d-68ebf4d81e7b"
"muhammadfadlanrais@gmail.com","","fbc4c505-01ef-40b2-a257-c2430fe1bdc5"
"rifkybujanabisri@gmail.com","Rifky Bujana Bisri","fc00d1bf-65da-431b-9994-52a3cfc88273"
"alazmimuhammadhabib@gmail.com","Izumi","fc05d2d3-0a55-4407-b20e-99d7bf24a3ec"
"scamgarapan@gmail.com","","fc22e953-b532-48b2-be6a-0fbc3b3774fb"
"fajaralfrzi@gmail.com","","fc701b59-0510-44f3-a85e-0cd22e9f777f"
"imanwealth00@gmail.com","WebDevShoul","fcad381c-ab34-4f62-b6f7-4569c24625f3"
"muhammadalifaditya92@gmail.com","Alif Aditya","fea8d6e4-a831-43a9-b98c-6a29c20dab53"
"udinhilif@gmail.com","Rio Fernanda","fef16ed6-438c-44c0-9070-66d3f602beae"
1 email fullname id
2 rrrijal24@gmail.com Rijal 0081ce77-2dd2-469c-8759-8d1259b6206c
3 zulhamrianto@gmail.com 0176361f-e403-4aeb-a683-8aa58d1e51a4
4 akbarkurniawan790@gmail.com Akbar Kurniawan 017917c0-7393-4620-9d17-bc8467f9c521
5 auliarestyazizah@gmail.com 0293026a-dfbe-4380-89fa-e2d5729a5cea
6 alhadad.xyz@gmail.com alhadad 0308f526-0dfe-4509-bee8-4aa217ec0a12
7 radithamzah12@gmail.com dorrit 032c630c-8a20-40ec-b191-ba8e009ab538
8 pamungkas.ajiseno@gmail.com Senoaji Pamungkas 0331921a-4009-4204-86af-5eb90d6773cf
9 gamingarisky@gmail.com 05004e6b-9d5c-4a14-af97-496bcd55878b
10 bagoesrex@gmail.com Bagus 058da640-ac05-498b-8618-f2c5a4455b90
11 rizkyseptian401@gmail.com Moc Rizky Septian 05d98092-8528-4edf-8b23-9134d67f628b
12 excellchriatian12@gmail.com Excell Christian 06f7158e-2218-4b61-8129-899bdaeb1eab
13 sofyanekafebriyanto@gmail.com 07138a28-5e15-4c08-9325-0b17061d913f
14 baraskyke7@gmail.com Barasky 07203a89-c58b-4df2-aeba-54b4977c8fc6
15 cukd3h.hynix@gmail.com 073af6af-33aa-4067-820c-2213df0843b0
16 tsqf.h29@gmail.com Muh Tsaqif Hafidz 073bb0a3-69b5-4b4d-8d47-e30b38690ef5
17 azranmu@gmail.com M ASRAN 078e1f86-6ca1-4fc2-ab9d-a600a6c5adf0
18 gdsudiartika@gmail.com Diar Sudiar 08b7eb15-dd74-4704-b8ec-d4e7dbc37c6e
19 alfiansa@proton.me Aditia Akbar Putra A. 095a9662-4d57-4c76-ae28-8f3685a01d34
20 ichaaulia720@gmail.com 09ab46a0-1394-4e41-9938-8f3df7f8bf99
21 naziq02@gmail.com 09cacf8e-467d-4885-a792-11287b68d626
22 wahyuskull22@gmail.com Wahyu SA 09dd11d7-4dae-4273-ba66-e313d82c7d52
23 sulthanzain28@gmail.com Sulthan .Y 0a28758d-fbc3-4979-93ae-33011b3a9691
24 ivan04.android@gmail.com 0a48d044-6e36-46b6-bd4e-bbf7d3f9519e
25 muklasputra222@gmail.com Muklas Adi Putra 0ab605f9-d525-4e1b-b1ca-7b21da065aff
26 rojafadilah0203@gmail.com Roja Fadilah 0b380e5f-26dc-41f0-914d-731d7ca00603
27 akmalkeisin@gmail.com 0b55c37c-48ce-4721-a7bb-52d9913dfcee
28 bimasaktiramdani01@gmail.com 0c1ddd78-5f93-4272-953b-4640cc769ab9
29 gajipgaming@gmail.com Galih Aji Pangestu 0cb2e0f9-6afe-458b-afc9-3170bcc18fa4
30 pascalsanjaya34@gmail.com Pascal 0d4f3960-509d-4ba4-8d03-5fc1c38a4071
31 hanifdwyputrasembiring@gmail.com Hanif Dwy Putra S 0e87a0f3-5a96-4b87-8e04-e180ed2731ca
32 afreezap20@gmail.com 0e9fcc3c-4a3f-43c9-bf4f-e4e4c342f7a0
33 profyonz@gmail.com 0eb6f8b2-f10f-4fa7-bf66-a43ef14a8f63
34 kesavadananjaya@gmail.com 0ec815c3-a256-4583-909d-b02655d7d09a
35 mdwikurniawan976@gmail.com 0f0e2cb8-2c21-417a-a577-f5d0662a2efa
36 rickyrahmad2001@gmail.com 0fa03bff-4be2-4c13-b687-688191c02955
37 dimasmaulanaichsan@gmail.com Dimas Maulana Ichsan 0fc6770d-fac7-4118-8796-6cae1b18f880
38 rikiulir@gmail.com Riki Ulir Niam 0fc99cb4-4495-405e-8b2f-3728404d7615
39 atmasusanto08@gmail.com 100b63a9-7487-4553-b45d-afb2201e4492
40 alvarelkevin12@gmail.com Muhammad Kevin Alvarel 114bebc7-2422-4c0a-9898-521f94739e7f
41 rayid1805@gmail.com 118b7324-bfc1-45f7-9282-d0d7fee3510f
42 sjr16173@gmail.com M. Salman Alfarisi 11dc3951-1a07-4937-bd75-2c29d2770c13
43 mhmmdarifrs@gmail.com Muhammad Arif Rahmad 134cd84e-4d79-474a-8ccc-09d728ad3f61
44 2310512054@mahasiswa.upnvj.ac.id Rafki Aulia Hazli 15154e34-3c02-426a-b02e-003278334a29
45 fathamarizkinur@gmail.com 151fa86a-5cec-4758-9824-a9d2ecd5385c
46 risyadraflan14@gmail.com 1552a593-f8e3-4c80-833b-4c5017dea18f
47 fauziferdiansyahindo@gmail.com 16a7eda3-4296-4754-b88e-dcf4cf61a0eb
48 amuhajir3126@gmail.com MamadNewbie 16b7a5ea-e664-4de5-86f1-578b5f3cd28c
49 fdvkey@gmail.com Bowo Pratama 1730c82d-369d-486d-b7cf-887be5383f8e
50 wahyusiakrom238@gmail.com ademaswahyu 173e56ab-92cc-48dd-9b03-dc8fdca13fde
51 abiyuaflah135@gmail.com Abiyu Aflah 18405a8a-9016-4db7-97fa-26ab32735df2
52 feryaryahidayat@gmail.com Fery 19355ab7-f1cb-47c5-9d2d-5eeb9ee4dbff
53 rokimiftah@gmail.com Roki Miftah Kamaludin 1973ee9d-2e18-46bc-b8df-272d61980a50
54 glen.tbjs@gmail.com Jeki 197d5875-dc6f-4068-844d-657db670babc
55 khulafamanagement@gmail.com 198b0b9f-a34d-410f-87cd-d19cfe02ff0c
56 12350110343@students.uin-suska.ac.id 1a79c0ce-9119-4067-9f00-c91dd01db95e
57 rizalalfadlil@gmail.com Hafidz Rizal Al-Fadlil 1aabe9b4-297a-4422-b01f-1b02369dc401
58 razinsyakib@gmail.com Muhammad Razin Syakib 1b473465-ea92-4b48-9c2a-43c67cf45e01
59 dodysamarinda@gmail.com 1b4cc185-9532-49b2-b0ac-9f03c1dcc045
60 superaseph@gmail.com 1b97626f-bb01-457b-9638-7576e5af1683
61 ku.anime115@gmail.com Muhamad Wisnu 1c88251f-951a-4470-a2eb-58118a75ee75
62 faizaalfa250@gmail.com 1c903d4e-a741-42f9-b27c-37c5d4e9a704
63 wandgenius6@gmail.com maulana adam 1d209d21-ef69-4bab-8796-cfa4cbf1fa20
64 aristiov@gmail.com Aristio 1e0fd9f8-e7cc-4381-939d-0f1aac4a0510
65 adamabdillah20@gmail.com Adam Abdillah 1e38dac1-a94c-4ac1-9ee8-8a53629e9be4
66 simarmatarafael11@gmail.com 1ea5a152-5463-44b0-975a-3ed019e804b8
67 kurniadikiki508@gmail.com 21054038-79d8-49a8-8daf-ca4e780cc09a
68 alif.ayana48@gmail.com Alif Muhammad 21fe034a-a9f6-417d-a6ed-e8c26f3005b8
69 minanabdillah4@gmail.com 22c19165-8c32-45f6-9e85-683a4aeaa2ec
70 gammertag31@gmail.com 236035ab-a274-4f6f-869d-6aaaf761e659
71 abangiqi@gmail.com 240ff65c-897c-40e7-aafd-d2dd1114ea66
72 meftah.mafazy@gmail.com 27a09ee3-2c61-4b38-a9f9-1f2810488131
73 islahulkafaa@gmail.com 289c5f8c-5e0f-4bca-8f66-7641d14a7bb0
74 zaidanabdulaziz03@gmail.com Zaidan Abdul Aziz 29049370-3d6f-4ae3-b44a-fe9d4f6edb89
75 adigintings@gmail.com Adi Primanda Ginting 2935f1f9-c070-422f-b9e0-b378b16a32c8
76 fadhil06akbar@gmail.com 29ae041b-b96c-45d0-a0e2-256ccd8b2168
77 ilhamfiqri37@gmail.com 29f3fcaa-9104-494e-844e-79d827f74a06
78 riski01.pku@gmail.com 2a57227d-2920-4839-8849-93ef38a41b9e
79 bisnis.huruhara@gmail.com 2ae31e65-4272-431b-bbc1-59158cc7cf11
80 luthlirik@gmail.com luthfi 2b47f090-1c0f-4982-8893-c0b1af1409e6
81 zackyhokya045@gmail.com 2bb1bf7f-cffb-4b1c-9171-a7e653fb6541
82 bayanalamahul3@gmail.com 2c228e47-a6ad-47c4-984f-99b6e5e8a13d
83 nizaram4dhan@gmail.com Nizar Alif Ramadhan 2cd4994b-ba50-43c1-9d78-f4311ef31108
84 fadhilakbar0612@gmail.com 2cf73de4-90a4-4d1b-a8f0-748ab0a621e7
85 aryhidayatfebriana@gmail.com Rizky Reza 2d0a00a8-2660-4b60-8de6-f85a6ce98b1d
86 hadiidandri2000@gmail.com 2ea30f2a-fbeb-4af6-b727-bebb0b687939
87 ahmadirafif7@gmail.com Rapip 2f08b18b-bd87-4d20-9923-26f4759a4b6e
88 rve27github@gmail.com Radika 2f788a66-e34e-466a-bbfd-3de920711223
89 fikricf147@gmail.com 311d81bf-5b5e-4519-a33b-cdd0ee0ecc82
90 ausathdzil@gmail.com Ausath Ikram 31a21f59-ecea-4bea-9a7b-56dc92c00a12
91 jono210678@gmail.com 34298723-449f-4b95-b6cb-1d7d5ab71540
92 rheatkhs@gmail.com Febiadi Wisnu Akbar 34bad6e8-3372-4634-ac93-b10f8882bbb2
93 sntdashi@gmail.com rawrzn 35159d0a-33cd-4c64-a36d-d37c97e595db
94 muhammadhidayat0278@gmail.com 351fe76c-5a5c-4d5c-a546-f0dd7ccfec0f
95 111202213986@mhs.dinus.ac.id 35930877-5702-4eab-9d47-87ccf4035583
96 khairulumam950@gmail.com khairul umam 3631e3e5-3eac-4297-af3c-6d8648510471
97 muhammad.jibrilmuqoddas@gmail.com Murphy Lawden 369f03f6-dbd3-49a0-af32-ebfb527624b3
98 rafibudiansyah@gmail.com 37844c0a-f42b-4bdb-9731-ab9722de70dc
99 tongkorongankostpaksutha@gmail.com 37d7c1ea-d063-4ceb-93ad-9c9784744516
100 gilangsukmw@gmail.com 38a5bd5f-c462-424b-986b-db04df906813
101 muhammadhaikalaziz28@gmail.com Kaal 38a738a0-3329-4001-bf88-05d4de6b11d3
102 cimicimicilung4868@gmail.com Eka Revandi 38cce9e2-df1d-43a5-bf45-595e25744e16
103 rizqybelajar8@gmail.com 397f8039-f1c9-42ee-88de-b9597caae518
104 syaefulloharnas16@gmail.com Syaefulloh Arnas 398ae3d5-349e-44fd-9428-f9335f89c511
105 oppokunew19@gmail.com 39d68abb-2502-4330-b475-51416bf5269c
106 hrfahrizzal@gmail.com 3ade0045-eb6d-43c8-8781-ea8d281a464e
107 rizkyfadillah0890@gmail.com Rizky Fadillah 3b04b506-ff39-4c2f-9f8f-182c62e4b6f4
108 syahidizanagi@gmail.com 3b2fb3a3-9b55-4338-bf09-8952db4371c0
109 ryanfebrian729@gmail.com Ryan Feb 3b467fcb-af79-4031-85f8-3073e9343058
110 hajopan12@gmail.com 3b4c66b3-4ec3-465b-998c-32c9ab7bf2b4
111 imaim3024@gmail.com AimZ 3b5a0b3a-5930-4cbc-ac78-b1d9d20ac460
112 rafligamercratf@gmail.com rafly 3b901d8e-7c96-483d-b299-98c8e54f5b57
113 jojohyperbackend@gmail.com ZeeHyper02 3b9e0c60-5a32-4d1c-b011-46390971b67f
114 ihenry@gmail.com 3c96f60e-d8ab-4e2d-86f6-6a7f4b40c179
115 dimsartz021@gmail.com Dimas Tri M 3cdffe97-dad4-47e3-825d-d9c13ae815b5
116 fathurrahman081296@gmail.com Fathur Rahman 3da30612-1885-423f-a836-3cc7ca33add9
117 hafidnurazis@gmail.com Hafid Nur 3e20b9ee-f566-4b0f-9e4f-cbb92d31031c
118 helmiputra265@gmail.com 3f60fb92-3440-4f61-82fe-192c6bcdb301
119 farhansyamsuddin3@gmail.com 3fc8adf4-a75a-4272-a431-a9b614e8dbd3
120 fauzanabiyan33@gmail.com Hoshino Abydos ᴾᴿᴼ▄︻デk̷n̷e̷a̷f̷══━一 3ff2ae42-a561-4338-ba61-f444409322c3
121 bagusindrayanaindo@gmail.com Bagus Indrayana 40194320-d827-4a53-bb8f-36303a6713a2
122 daunn871@gmail.com Daun 41412e38-3b07-464f-93a4-457ed1cf6176
123 muhammadirfanbaihaqi538@gmail.com 4155a53f-252e-4b44-a951-851ea5cce606
124 mujahidislami030@gmail.com Mujahid Islami Primaldi Abdullah 41899d5b-9f1f-4ca9-8be8-9837ea83f493
125 muhammadkevinalvarel@gmail.com 418f738b-2187-4432-957d-15eb258a65bb
126 galangryandana@gmail.com 433c45f0-1d8e-442e-95c8-bd9c7b97108e
127 nasruladitri2@gmail.com Nasrul Aditri 43ea0f85-1b69-4235-ae25-523ad1984d46
128 muhammadariefcp@gmail.com Muhammad Arief C P 43f5ef95-ac4b-4d5e-bf57-b6bf0c30936d
129 adityamaullana234@gmail.com Aditya Maulana zidqy 44045f03-964c-4935-9918-cddc36676278
130 g4mless@proton.me g4mless 44192093-fadb-4be9-8662-d02e9c47a1b3
131 justgilang99@gmail.com 46769f5b-f33f-4777-aff1-9c25b9c488e8
132 jezudu@denipl.net 46a4954b-1b91-4647-a5ba-376bbc299402
133 alfisahri33@gmail.com alfi sahri 473b0a56-7583-43e6-8511-2e73889e9b37
134 mraihanaf0@gmail.com Muhammad Raihan Al Farizy 47866d52-c02c-44d9-a89d-76ee1103eed9
135 obenpasaribu@gmail.com 47e2dd3f-339f-4ce5-aa42-4d89a1fd4aea
136 2206073@itg.ac.id ZaenalSyamsyulArief 48a70338-aa6b-4f22-b5b1-458da13d85c6
137 ronyronny12@gmail.com 4b5b6fd7-4f79-412b-967e-0437d3c4c6af
138 malfathiratir@gmail.com 4bda0567-2a5a-4fda-8d8f-a5998e9d8800
139 lakazamlak02@gmail.com 4c4abd7a-772d-49fe-977a-57a917adbe6a
140 nazedev@gmail.com 4cdb4fd8-565e-47c7-9559-215a31f4bd1c
141 revelcahyadi2004@gmail.com Revel Cahyadi 4d2efba3-a0dd-448e-9a40-a7395a15f863
142 pulungwicaksono2006@gmail.com Pulung Wicaksono 4d9a29b1-080e-47d0-836b-f33dfbf84c6c
143 ferospedrosa@gmail.com 501c795f-fd27-4f86-900e-fbbbd02474a7
144 bilgrandov9902003@gmail.com 503377c4-26af-4890-82f1-a3619953b7b7
145 gbrnmhd89@gmail.com Gilbran Mahda 509825be-9d84-451e-9833-f2bbd5507ade
146 jevonit252@gmail.com 512805dd-507c-4193-adad-a1cf5f76a777
147 iwank31052003@gmail.com Iwan Kurniawan 516deb0d-9ec1-4776-835f-6daba29e7c1b
148 rifanajieagung48@gmail.com 5182dafb-4a12-4bcc-b967-6ea50aefe7d0
149 anata@yopmail.com 51832f14-1e08-4543-b1a0-d8107573efbc
150 fajargustiana2@gmail.com Fajar Gustiana 51896ad3-8184-4e4a-a9c8-4eaa39ac8451
151 raflyazizabdillah30@gmail.com 51bb6cfe-bd23-4ef9-9992-96b4e1c3a7ad
152 patrickmarcel65@gmail.com Patrick Marcel 51d0ed9b-5d7c-400e-a75e-b3777dae5be8
153 auranawamadani@gmail.com Aura N,A 529c4fa4-4894-4c88-ba63-857532f7fef3
154 ayamkucing129@gmail.com Elang 52b81c76-fa09-4e96-8dfd-f405c5c09808
155 ayyasi52@gmail.com 543eced6-00b4-4227-82b0-13e626fb1c88
156 itsandka@gmail.com 548a310d-05a7-4b64-9d5b-032d848446fc
157 ryanda.valents3649@student.unri.ac.id Ryanda Valents Anakri 54db9209-843c-465c-82c2-d8c4ea72dc7e
158 noaa277@gmail.com Karakaira 54e0cfb9-55b7-4c00-812f-f04296c11a70
159 larasrahm22@gmail.com 550fc044-10bd-49a1-9c09-50bd4d5325cc
160 zrbagus5@gmail.com Bagus 5568477e-e7a0-4a93-b1de-0e775414111a
161 jonfrymarbun@gmail.com Jonfry Agung Marbun 56f8b4f6-f31e-4faf-87f6-acc8d83bd72d
162 andika@umpo.ac.id 5768d665-77a3-4ae3-b2ee-37e408c0e98e
163 snarsaggaf@gmail.com pii 576d1650-71c8-4a5c-81c9-dda3284c13c4
164 azhararizkyrafi@gmail.com Rizky Rafi Azhara 58871899-6d54-4cd5-bbd0-3d9e2cbe5f9f
165 safuzi333@gmail.com 58ced2da-dd5c-42da-acd5-0082b5e0e835
166 putrasyaddad@gmail.com Syaddad Raihan Putra 59d3324c-a264-4f60-91ca-96d9d5def00a
167 bryanfernandoks11@gmail.com Bryan Fernando Kurniawan Suhartono 5a48dc88-25c8-49e8-8a74-36b493584aa8
168 saehansa@gmail.com 5a8cf51a-fa8a-44ce-ad1a-898c8487513f
169 kontakpribadiatsushi@gmail.com 5ab3bd8c-27a2-4552-8f0c-98370dff4e3b
170 mohkaisanalkalbi@gmail.com 5c15338b-c4a0-4071-ab67-c8f29ffaadaa
171 bayuardana213@gmail.com 5dc2beb9-46c1-4920-9d07-9708de896778
172 danufebriansyah09@gmail.com Danu Febriansyah 5e0d9e2e-5ced-4f94-9797-b8083778fe0e
173 pjuanda12@gmail.com 5e91489c-cacf-4050-b46b-4327255ec74a
174 muzammilulmuttaqin11@gmail.com 5ee5672c-dba1-42b7-b8f9-e76ba4efd21a
175 raffida2013@gmail.com Raffi Deas Alvaro 5f46ecdb-f025-4fc6-a981-dd4c4c93a52a
176 markusdamar2958@gmail.com Damar 5f475128-017f-4021-961c-4d58eef63d99
177 felixcyro008@gmail.com 5fb48143-4d2c-4d71-bf9a-bd9f44647de6
178 hazelbigbosshazel@gmail.com 5fecab2f-2382-404e-8f8f-ed031332b886
179 mhd.rijal203@gmail.com Muhamad Rijal 60fbc970-2079-42c8-8dcb-637c466a7f1c
180 imovic004@gmail.com movic 61c5d3a0-553f-474d-9950-483cb86057df
181 samuelmarubamanik@gmail.com 625aa73b-69db-473f-baad-6791ff865e40
182 muhammad.fahri137296@smp.belajar.id 63a19768-b9c4-480d-95c5-2770393935fd
183 me@anym.dev 63a87b68-4dc0-41c8-a99a-1335c77dd4e0
184 kithly19@gmail.com 64428bf7-052f-4b7b-95ca-802f76e93dcc
185 raissatristan14@gmail.com 645e0a22-a055-4cba-a007-37bd9791f882
186 fairuzfuadi123@gmail.com 653f2a62-66c5-4eae-a7c5-822a85cd2d9b
187 ahdan.abde@gmail.com Ahdan Abde Rifai 65d6021e-a03c-4661-a3eb-5452cc89b588
188 ivanrahmat.p77@gmail.com Ivan Rahmat Prakasa 65d79307-2b78-48af-a9d8-750e66126401
189 hamzahbaik9@gmail.com 65efcb5a-13e3-4552-9336-f7b68705e521
190 andrianxyz82@gmail.com 6677b5ae-3171-428d-aa61-22464b1ba80f
191 billysyahputra630@gmail.com Billy 66dbdf3d-3cf0-4d81-810e-185e61b952b7
192 yusri.irfani@gmail.com 6773b841-2037-4aee-ace1-a8bea7341e25
193 reiyhan.aditya@gmail.com Rey Panda 68876807-64ed-49f9-91d4-d7f68eb0e6c9
194 hexorzplay@gmail.com 689872ad-856d-40f6-9d87-86d580394fb4
195 codewithwan@gmail.com 699646d9-192a-451f-99d1-46d4a42e4eec
196 mtaufiksaadi28@gmail.com 6a9264f2-2731-4e4a-8e29-9fc1a505e6dc
197 alamsantoso.12@gmail.com Emowmow 6ab9934d-8f77-4e32-b4db-d09222fa4e44
198 kunengrestu@gmail.com 6ae98213-5e3a-41c7-b356-03ae6760993d
199 andikacahargen2@gmail.com Anka Tama 6b62be2c-912d-4809-af64-08ff52aef4b8
200 rmz4mc7wg6@privaterelay.appleid.com 6bab0fab-6c05-4ed5-9d4d-bbabaf62b513
201 handikacoco@gmail.com 6bd01f53-7884-40a2-aeb4-34b000120088
202 riziqliliulilalbab@gmail.com RIZIQ LILI ULIL ALBAB 6d10db2d-3ac2-4008-90dd-141311219c53
203 syisahi4696@gmail.com 6dd8c78b-9884-4944-b2d4-de2bb8711da6
204 akhmadwibu05@gmail.com akhmadkhoirudin 6e32e4d3-25e7-47d5-97b5-60955969e22f
205 andika.namikaze02@gmail.com Andika Fitra Darmawan 6eae5e60-544e-4bf2-81af-7b1138ae967a
206 luhalisarafansyah@gmail.com 6eb155d0-aea1-41ca-bcdf-7effa9912366
207 novianybl@gmail.com Novian Project 6ebfa0ad-fde4-42f9-9d0f-8e51e465d534
208 boostlivious@gmail.com Raynaldi Siahaan 6f089bd4-8829-4a62-8c06-104319116e93
209 rasyadbimasatya04@gmail.com Rasyad Bimasatya 6f237adc-f724-424c-baf1-4d13f03887e1
210 rezyaindisaputra@gmail.com 6f43758c-224e-4d9d-9273-f3a3df51a11c
211 rahardisalim23@gmail.com 6f8a13c1-3836-4715-9181-dc21314cd8af
212 alfin.rozzaq.270206@gmail.com Alfin Rozzaq Nirwana 6f974b20-6309-4b44-9c7c-165d4cb458c9
213 muhammad.toffazal00@gmail.com 7055961a-7b40-49d9-ace2-6ce7ad48f14d
214 raaflynr@gmail.com 706b67c2-e410-4143-a7fa-213d35178db4
215 kujouarisu@gmail.com 7216ca14-7cff-4d0b-b8c9-70427af21b4a
216 rohimsaga7@gmail.com 722989b5-f685-4fc3-9bc7-76f04c479d49
217 rayhanlubis01@gmail.com Rayhan Lubis 72bf1d04-8b95-4f90-a88f-f78c0571d841
218 raflyirham1005@gmail.com Rafly Irham Safatulloh 73404aea-a007-4629-b9dc-fb26cb307e57
219 sopiadi2006@gmail.com 7364d4fa-f6f7-429f-a95d-f6775eca6d9f
220 haydar130305@gmail.com 7462e4b2-b446-4562-9bc8-e08c07d157ef
221 arilindra21@gmail.com Moch. Aril Indra Permana 748de15d-374a-4756-89e2-75754e90542f
222 gundowijoyo7@gmail.com bahaywuj 74bd7b16-a75a-4ffd-908d-0b00f6ced98b
223 yayohajid@gmail.com 74f39d03-eb00-4a6c-81fe-f2d3d2f60cf3
224 codesphere01010@gmail.com Dzarel Developer 757980ed-43fe-4092-9d2b-387e86c5b596
225 stefanuslay01@gmail.com Stefanus Lay 763244a7-0bc5-4668-811f-4e20ae8825bb
226 fawfawfaw994@gmail.com 76c148f1-fef9-4489-8421-1cb875d7747f
227 resa.rahman@gmail.com 76da63db-4937-44f4-bd7b-2673db6b7c40
228 bryann.carls@gmail.com 76e6cd37-478a-45f7-ac57-81ff1b646761
229 m.saidibjm4@gmail.com Muhammad Saidi 786820eb-eccf-4c57-b05a-9cc4249672c4
230 securtydit@gmail.com 78d09810-d778-46e4-a69a-c055ebc29998
231 helmygarcia67@gmail.com Helmy 793b5a30-f0c7-4dfe-afc8-0e85e7ccd49c
232 akbarriansyah339@gmail.com Akbar Riansyah 793ce294-f69a-4aba-8259-f20ee2b4c73f
233 revanza.firdaus@gmail.com Muhammad Revanza Firdaus 79c01c0b-4bff-43af-9ec9-d1fa29f3987b
234 rezokushop@gmail.com 7a27e0da-9593-40a7-8462-9daf6e96e3ae
235 aril.permana@paper.id 7a960082-4df8-45fd-99fa-a204d6688caa
236 atmasusanto38.imphenhack@gmail.com 7b247e32-ed65-4865-a1ce-0e9cc4cb8297
237 ihsanwar77@gmail.com 7b4b6a6d-1cef-415a-aca3-f98648f0f340
238 fsparatos@gmail.com 7c30facc-eb6f-412e-88c4-0095f9bfdd1b
239 shalahuddin.4332311020@students.polibatam.ac.id Shalahuddin Al-Ayyubi 7ca6c4b1-be0e-4993-83d8-0eff8945ea50
240 arissetia.m@gmail.com Aris Setiawan 7cbcf002-8e92-4b28-bcee-1776ddf5ec86
241 andikadwipwf@gmail.com 7d5ac7ef-704e-4275-b1b6-59b497548365
242 msayyid.rafeed@gmail.com Muhammad Sayyid Rafee' Djamil 7da76028-0ed7-434e-b51e-d163cb3d3585
243 muhammadriskiakbar2009@gmail.com 7ecd33ed-40b6-4236-981f-5319fdd390d3
244 bobypratama.dev@gmail.com Muhammad Boby Pratama 7f0592c4-b0a6-476d-a436-75a8fe753f32
245 akbarrozaq691@gmail.com 7f1689fe-9d54-4cec-a1f0-db5ffae98d3b
246 220605110044@student.uin-malang.ac.id Ridho Aulia Rahman 801d6313-6a1c-45d3-9a64-1db88269e250
247 muhammadrestu1108@gmail.com Muhammad Restu 805d5fc1-6f9d-4e37-b1bc-514ed4e17bea
248 iyandabes1@gmail.com Astha 80e0229e-e268-4d22-a0c5-4610bd2b4bdd
249 ridhofahmij225@gmail.com Ridha Fahmi J 80f24b96-36a6-44eb-a361-26bd04554609
250 wisnuajipamungkas354@gmail.com 80f40577-19f8-45cb-86d1-dcc60472b9e8
251 mgsmfr@gmail.com 8177c99d-28f5-4a88-8415-9abea3fb934b
252 eguinjonathan08@gmail.com Guinn 8268ab9d-acc1-4379-b9e9-331392668db2
253 fardanyudhistira16@gmail.com Fardan Yudhistira 83635084-c207-40ee-ac11-742b213fe843
254 quyun532@gmail.com 83c6b62f-5c95-4e74-b16f-a9f48a9346cc
255 zakamaragames@gmail.com Muhammad Ilyasa 83cc1c2b-01ba-438c-8c62-7c11a75c531c
256 krisnaguntur71@gmail.com 840aa041-314a-44cc-8b52-e529b7aab3c3
257 mhmdazkanfl@gmail.com Muhammad Azka Naufal 8454c75e-b0e4-4c04-a580-c13cddd63f3c
258 fajarrahman216@gmail.com Computer 848435a2-d024-497a-b020-f320155ef675
259 aswinarung2@gmail.com AswinA 84fe21f4-c03c-4042-b535-d61d92d33288
260 davidk3476@gmail.com 85887410-b9e3-4861-8ebb-e35474bfd003
261 glenrioariesto@gmail.com GlennJackson 865d44a1-be9b-4dbc-8110-02e91a0ad557
262 ferta996@gmail.com Ferta Junindi 86be6a41-8d6d-4f91-b5d7-782d9a2de14e
263 julianisa.ar@gmail.com Fatah 8709fc42-9f88-496c-8610-6cae5253cce9
264 indraaziz15@gmail.com 8786bfb3-7f3e-40ed-9f46-f3ab5b1a938a
265 hshino@student.telkomuniversity.ac.id Muhammad Hashfi Hadyan 87ce6b8d-af52-449b-a11a-854042633edf
266 farhansyahbanna07@gmail.com Farhan 8a1671be-64ea-48ec-9b24-f2589fc33281
267 nawvalovsky@gmail.com 8b023aff-3d10-44dd-ad30-2251604bd319
268 mininsna@gmail.com Minin 8bcee28c-19fa-45b8-bc0a-35e97af0110c
269 rzkalih330@gmail.com 8bf4de04-3335-43cd-9f09-9a0bcddd3e65
270 agusse9986@gmail.com Agus Setiawan 8c49cb67-5a7b-4bff-bd78-141166b1ce39
271 ixanramdhani@gmail.com 8ca8015e-07c1-4d57-86f0-3223165b10af
272 bagas101021@gmail.com Bagas Pamungkas 8d3cdd6e-cf34-4462-a2b3-91874bda4839
273 maulidanfaka391@gmail.com Faksz 8e4ccb11-d491-4aaf-bf78-15fa333ea71d
274 mugteadotco@gmail.com MugTea 8e907eae-5137-4499-a13b-11fd89106ae4
275 denandragagono@gmail.com 90b98bc8-3d98-428f-8549-07457cf4bda0
276 trian.radis448@gmail.com Trian Radis Pengestu 91064af2-a72c-4c48-9cd5-acdc2b6527f9
277 mnazriel177@gmail.com 9130249c-c366-4c47-9d4e-337c35abce6a
278 onesta928@gmail.com 91582716-23d1-42f9-afc8-6eb857bcc289
279 dimasmaulana.idn@gmail.com Dimas Maulana 917aa59a-bf05-47af-a26b-f78f9faeaac0
280 naufalhaidar946@gmail.com 91c5e840-3758-4c7f-9dcc-b621b6ba4d10
281 sandysyarifhartanto@gmail.com Sandy Syarif 92e971bf-863c-4d66-a522-81a1a864c56c
282 77leviann@gmail.com 93cc57f9-48c5-4b71-83bd-63bac1f5717a
283 roxyuciha@gmail.com Robbanie 93d67e12-dbf6-4df7-882f-38ff471c9cf5
284 thisiswahibza@gmail.com 93ffb004-f16d-4dd8-bd4e-55384c056138
285 raazan.muhammad.ikhsan@gmail.com Razan Muhammad Ikhsan 953d0ce6-49ee-4606-9616-23b204e61710
286 farilpratamap@gmail.com 955d4a9e-7e58-4210-86af-d38f8794a565
287 candraggml@gmail.com 9665ef56-2b46-4883-bd1d-29d20c5a6f98
288 aulianaufaln@gmail.com 9815d40b-d962-4a75-8383-8993407f89a7
289 rubenrextonb@gmail.com 98236b68-a066-4538-9ae5-d5b063961244
290 bettyindrawati4@gmail.com Betty Indrawati 98352ac8-241d-4433-b61b-60d9904b7999
291 alfandy115@gmail.com Alfandy Afriandani 98a8d32e-1408-4f29-a374-fba3470bbe7c
292 zahranvidian@gmail.com 9939f383-0ee4-4b8e-be03-08977324edd0
293 alifr5353@gmail.com Alif Ramadhan 9a5f346e-dcfe-47d2-9e43-80dd353d46e3
294 gn.mailwork@gmail.com Grafis Nuresa 9acbdac5-f7aa-4834-90c4-4d19d67410f4
295 zexceed12300@gmail.com Ihsan Nul Amri 9b067871-2da9-48e7-b15f-1e604d422d33
296 haikalrafifas@gmail.com Haikal Rafif 9b212b4f-a019-48cd-9e6a-0f9d3c0ada4d
297 novealdioardhan@gmail.com ardhan novealdio 9b9b68e3-f8fa-47a2-a47b-13af2d5cb22f
298 nabilpasha230606@gmail.com Nabil Pasha 9c63cf90-68af-4566-aa74-562a9a10d73a
299 nash.collage.life@gmail.com Muhammad Nashrulloh 9c963bbf-6eb0-46c8-86ce-ebf1d0d09b38
300 hhhh25737@gmail.com Muhammad Ridwan 9cb022e2-1f94-414d-b9cb-c1781d676a72
301 samuelsiallagan49@gmail.com 9cf09ee4-6ce0-4afe-a36e-9f472035cb34
302 sirrauf412@gmail.com Sir Ananda Rauf 9d4ccd3e-c2c1-4ef9-9fda-007403908ecc
303 bentambunan20@gmail.com 9d539268-8beb-4e57-80fd-6d2293776528
304 falihafiq1928@gmail.com Muhammad Falih Afiq 9d8a38c3-e631-4b31-8f86-e3f6408f7330
305 muhammadalfathir@smkwikrama.sch.id 9e26cc6f-048d-41a1-b657-4f06f5198aaa
306 hazizhaziz549@gmail.com 9e63c2d8-d2c2-4ac7-9c2e-226965c4f402
307 hekate071@gmail.com 9eb70e94-b963-4f4c-9852-7f6e95e7987f
308 mramdani1230@gmail.com MRamdani 9f472bbd-aedb-49be-b8bf-0473d55f2622
309 dewarahmat1233@gmail.com Matt 9fece7da-1c1d-4e07-843c-9cd26aee60b5
310 derriverse@gmail.com a0148ac9-002a-420e-bc23-ac7b3ee9a33c
311 jalagohu@denipl.net a050ea6f-8d2c-4b4b-9f64-306517b63613
312 muhammadrobisasaki@gmail.com sasakirooo a2770339-e7e6-4b20-9cfe-99c8d2471930
313 triwibowoilham2@gmail.com Muhammad Ilham Triwibowo a2c1d665-f360-4cd8-a313-5fee29b91d6e
314 yuratamma@gmail.com Zidna Fadla a2e938b8-7403-4e25-bc34-63b9838983a6
315 sudo.rusydi@gmail.com a32c0e81-1fbd-4bec-8e91-4cd6d10f553b
316 nazihelfikar68@gmail.com a42e221f-c198-463f-ac11-84060fd9b494
317 adzthariq@gmail.com Muhammd Thariq Adzikra a4c434da-2491-40b2-aa57-7b4aa53a69c1
318 saintparid@gmail.com Saint Jameson Parid a53db442-4a6b-4431-afe1-b9d148b977fb
319 asja77502@gmail.com lioXploitcs a57807e3-bafb-4c4c-b23e-ba57c43c2cc2
320 realdrenzzz@gmail.com Drenzzz. a5ea628a-24d9-4754-b461-3eda53da53bf
321 maldenai76@gmail.com a5ee4302-50ff-4463-8f47-9cf60b414960
322 yogaagustiansyah01@gmail.com Yoga Agustiansyah a60c10f3-d73a-4953-8d2c-1e413c881470
323 masuwha@gmail.com a6acc3eb-6b59-4d31-881a-c4a1aecf8cfc
324 jazaniezt07@gmail.com M. Abdillah Aljazani a81baf2a-b875-4c86-84ab-618f403abc60
325 rickogurning333@gmail.com a8e87809-36a3-467e-8a49-8a730bb70724
326 zakyfarhan1326@gmail.com Zaky Muhammad Fauzi a90a9400-0576-4708-8912-c630807b9391
327 sandysyarifhhartanto@gmail.com a91c8500-69fd-4602-9795-779d006a305e
328 andra.pratama@riswan.com a927c690-8ebf-44eb-bf97-1d780729a2c4
329 naufalyuprata@gmail.com a93fd9dd-3e36-4ba1-b668-988dc2d186c6
330 mubarrokwildan1@gmail.com Akazano_abe a9807283-d4de-4990-8985-b22ded98e4cd
331 fackyouu128@gmail.com Reza A Maulana a997e674-f927-474b-b817-9543f722aa9b
332 riantrito@gmail.com a9dfd864-faa1-452a-b074-8498e6e08af5
333 alhikam200@gmail.com ΑlhikamΔirga⌘ a9f7ded3-ed87-486b-bd11-0d4b3cb5d9a7
334 afiffahriafrizall@gmail.com aa088d9f-7251-4673-ad6b-88261666710b
335 miawspecialist@gmail.com iyep aa42b892-74dc-4a00-85b5-c7f78127c4e9
336 muhammaddean27@gmail.com Muhammad Dean Fahreza aa4ba09e-4ed0-449c-ba98-5441844f022b
337 harissabil04@gmail.com Muhammad Haris Sabil Al Karim aa9d683d-0787-4279-8085-541459b1d8fa
338 quyunisnawan34@gmail.com ab36232b-b46c-4d30-8bb3-9b11e3e9ac7b
339 mnabilfs22@gmail.com Muhammad Nabil Farras Sulthan ab89f5c9-95fe-4808-81be-9bdc9c3b21ec
340 mochamadrifkyrifaldy@gmail.com rifkyrifaldy aba1da85-d87b-4d52-bea9-35639c4f1d64
341 muhammadyustanzah@gmail.com ada3b4d9-745b-41c2-95b5-1a8275e29882
342 sulfikaralijun@gmail.com Sulfikar Alijun adbd7ccc-252f-40c4-b5db-8940392da7e2
343 mitsuhaadly@gmail.com Adeley aef3f5b3-23b0-44e7-aa73-edab6ab20c6e
344 mobilezero24@gmail.com Muhammad keisya fadilah af8d0e16-0358-4d96-b822-a4023b06752d
345 arvardy@student.ub.ac.id b022268c-2ac3-43a7-a5cf-c0bae568477a
346 zulfikarm022@gmail.com b04d2b39-25c2-4e65-a28f-8b35edeb6137
347 hasrinata@gmail.com Hasrinata Arya Afendi b1171213-ed39-4333-b58f-2424d3d2b9fe
348 wahyuikbal777@gmail.com wahyu ikbal maulana b1e1ba24-35fe-4dd7-b37f-77d42314b3d9
349 rafiadly259@gmail.com b21cde91-57f4-4f4e-b082-9130dcaff5a6
350 rakhaakbar522@gmail.com Muhammad Rakha Akbar b2208e46-26b9-497c-9105-7bfff4fd89f3
351 diwapraset02@gmail.com Diwa b22a09b9-7079-49c9-9734-0f69f73cdd67
352 anakriryanda@gmail.com b26a5fba-f01d-49eb-a0ab-b0fa67d846a8
353 sxntient.labs@gmail.com b2fb784d-cf93-4d45-a8bf-0fbbefdb8761
354 vlakasofficial@gmail.com Fauzan b35057c8-d5c3-4e20-b027-025f3d959398
355 sandifadel739@gmail.com b43dfb80-8037-4478-a050-e28b0b0e243c
356 alfawwaz.naufal13@gmail.com Naufal Al F b44432b9-c6ce-472c-b53b-fe6f838cc283
357 ilhampradani8@gmail.com b49fddfb-0be3-4d4f-a6d3-927c14ac9b81
358 divoveenda@gmail.com Veenda Putri Divo b4c53c87-6310-4143-bccf-94ee0440c17d
359 fazaradityarma@gmail.com Faza b50caf6d-e433-43d8-bef2-1ff073b13027
360 muhyusufsyamsyam@gmail.com Yusuf Syam b5113861-be40-4651-8ab2-33a8051656a7
361 koding12sam@gmail.com Muel b569b9c4-b173-45a4-b022-5bd0607e84be
362 zenaufa@hotmail.com b5a1b1e9-3bb5-4076-88a1-d36ce4dde6fc
363 naufalfadhilah689@gmail.com Muhammad Naufal Fadhilah b6a3051f-1890-440e-9956-c380e2b5c139
364 putrosrv@gmail.com b6ad4101-23d4-427c-9376-964cf995fc18
365 fajarskyy833@gmail.com b732bc7c-b3c7-4a79-8529-4622445343f3
366 fuwafuwa@tiffincrane.com b76b604d-9621-4aaf-bdf1-bc2da2f8248f
367 aminaprzaa@gmail.com b7e71f93-6fce-4ef0-8eae-1a15634bcb9e
368 abdurrahman.hafizh02@gmail.com b7f0d89b-6bdf-40dd-b5ca-442130b12c00
369 apriliyantoecha1@gmail.com Echa Apriliyanto b8350a6f-621e-4c2f-9d2b-cf79c2a2f091
370 rizkysrg62@gmail.com b864e321-9c43-422a-b980-c91809d3039a
371 kuncibatara@gmail.com b9066990-a721-40f9-8478-5f806c80c751
372 lotteaquamarine@comfythings.com b96bc4aa-4a97-40cc-be49-2560df93751a
373 zlutfhi89@gmail.com b9bb3ff3-5539-4b48-8101-90517a06cc42
374 theownerkill432@gmail.com b9ef4db0-6b7b-4dba-aef7-9434ce104e07
375 aryandasanggadiennata26@gmail.com R3X bb1696b1-7f82-46fa-805b-6ec79e673e40
376 rickyricko302@gmail.com Ricky Verdiyanto bbbf243b-f08e-4911-b4bd-838200c50cb5
377 nalindasepti@gmail.com bbc5e3e7-cd72-4739-81c6-199876b02084
378 gamerzcraft37@gmail.com Gazach bbfac7a8-2a13-48a2-9f29-499f33d68b8c
379 rewyzzi@gmail.com bc02bb7b-8193-4ddc-b8ca-d858b212de2e
380 alfanhuda2004@gmail.com Alfanhuda bc2bec53-ee17-40f7-9640-9556745a171a
381 tengkusripratiwi1608@gmail.com bd35df8a-9a78-4b14-aa8b-8366e9de58bd
382 bataraa@proton.me Batara Krisna Danduru bdb30d1b-9754-4333-b7e0-bce7e7f9ebda
383 hayyannashrulloh25@gmail.com be5f3f84-3435-4800-b019-3b6564113004
384 050393537@ecampus.ut.ac.id Fahrur Rozi be83a7e1-abf4-43ca-a23c-d335449b844f
385 arivopwenz@gmail.com Ari Prabowo be9d3160-e00e-4547-bbdd-c6546fe0af71
386 faaldy16@gmail.com Aldi Fauzi Ilmamuslim beb5d548-c976-4110-87f2-75d4f8465e2b
387 arbasfardan@gmail.com Fardan Arbaz bef2164a-6f12-4d69-847e-5d6ac19f554f
388 bwfzbw@gmail.com jarss (zharfan) bef8c2ba-c959-4896-a0d3-463e2f77570b
389 nabielilyasa133@gmail.com bf0d6b84-0508-4917-8ff5-e23e1d3ce23b
390 xecloud4@gmail.com A. Abqory bfacc996-8020-41fe-b323-c5c3dac7f425
391 christiannathanielp@gmail.com Christian Nathaniel c00a24fd-44b7-477a-9048-05007fdfd76e
392 ardyrkm23@gmail.com c048d109-8d91-49f2-b04f-fc86866c1487
393 risfarishk@gmail.com c084728e-f1c0-4775-b0b5-10ab0441bab6
394 awannshafwan@gmail.com Awan c0f6b85a-4723-484d-8fdf-713f7e5aa9f7
395 willygurning14@gmail.com c19ad425-8df8-4b37-8f35-797767f48672
396 0110224081@student.nurulfikri.ac.id Raffa Yuda Pratama c288a2df-a1e6-4beb-a6ea-aab0b7f912df
397 240605110160@student.uin-malang.ac.id c478b5f7-fc09-4b97-8354-e8889f852450
398 muhammadsultansyah14@gmail.com Muhammad Sultansyah c4aa882a-bfc2-4a1c-a9e7-89f9a37b9fba
399 grecyllagrecy@gmail.com c5611544-3041-4a31-866a-84ad0b781aca
400 ilhamthaariq@gmail.com Muhammad Ilham Rijal Thaariq c5ecbd2b-8d7b-4440-a75e-2bb58b1ed8cb
401 basukiridhoalghifary@gmail.com basukiridho c6323853-13e3-49dc-972c-a7cb789f0b32
402 kacunghabibiganteng@gmail.com KacungHabibi™ c6dab1e3-8177-4784-936b-364a9e58a076
403 nhardiansa@gmail.com Nabil Hardiansa c79d4b2b-e63f-4187-a8cd-520fde333ab3
404 bhosya27@gmail.com Bowoksae c8f35350-384f-4933-be59-d785dbe06abc
405 m.sadiqin46@gmail.com MuhammadSadiqin c92b26ed-ddc1-45e7-9a17-f2d2ea7b9d2f
406 gayuyunma1123@gmail.com c978891b-71d3-423a-8c97-5648304f5a47
407 akbarriffani19@gmail.com LebahRajin c9b2c04a-5027-4ccb-bd0b-2c26c7e9f619
408 ajangrahmat@gmail.com Ajang Rahmat ca299304-dfa7-49ba-aa1d-0ed259ffe0fc
409 auliyanihlah@gmail.com caa569eb-822e-418c-93aa-df23563a178c
410 razandirgham@gmail.com caaaf8b5-ac4b-40b5-9549-f0f8ec9d6cd1
411 profadlibae@gmail.com Acmad Fadli Aditama cabaade4-b256-4798-844a-78f36cb16b0b
412 atnandimas@gmail.com Adnan Rohmat K cb2161a5-7c09-4360-908b-f2e629be1cda
413 sitizahraazizah23@gmail.com Siti Zahra Azizah cc7e4a74-7c3d-44cd-af91-a1b9326cb13e
414 nurdin99.ek@gmail.com Nurdin cd64c94d-ca81-4169-bb4d-e2af2dba4a55
415 fauzanhabib56@gmail.com cd7ebe7e-a914-457f-be95-2e6256342552
416 reyhananf7@gmail.com cdab11c6-ef41-4e2c-ad9d-f9636681e9f0
417 loidthegost@gmail.com ce458e26-313e-47cd-937d-e5e95f77a136
418 omnidevv@gmail.com cf1eadaa-a313-4760-808e-37dc70446ccf
419 m.ilhamjaya1808@gmail.com Muhammad Ilham Jaya cfc296db-5cd7-4485-b8d0-ba330f5c148c
420 risbulgt@gmail.com d00b9542-2bc4-4a1c-acca-e2e58f7363e5
421 lembahku@gmail.com d15d8329-a248-4339-8090-69c40ca2a050
422 fcrizkywijaya@gmail.com Farid Rizky Wijaya d1c950e6-fb9e-4d53-9c2c-e77477a9a1eb
423 bimaadam.dev@gmail.com d263ee7e-e4fb-4b55-a5d9-0207520e0fa8
424 felixarajiph@gmail.com Rajiph iqbal d350da40-7543-4073-b6a5-95c79d705dd3
425 ahmadfawwaz103@gmail.com d396661f-c733-4991-a212-76b9e4ecb02c
426 hafizpramudya17@gmail.com Nflz d44ab980-6982-46b3-8da5-fafef0dedd9f
427 johnobama24keren@gmail.com Hiuaaaaa d4953c02-1a07-476c-b5ca-7dfd1e91d28c
428 aliakbar06110@gmail.com d4d2c768-3296-40fa-8c87-3bf0b9a54fac
429 sanjaya28051@gmail.com d5ff8a92-2b82-42b9-a143-2cd8c376c1ef
430 nawvalovsky@proton.me d731aa31-8893-4496-ad36-db71729e3419
431 agung.astanto14@gmail.com d744ffbb-7011-43b6-a4f8-7ef2f105ba12
432 yumpp24@gmail.com d7587b8e-51fb-404b-ae31-7f5a86c85119
433 novandiramadhan80@gmail.com d8f27598-3fed-47da-be14-86c59f70387a
434 sfatimaple@gmail.com Sfatimah Azzahra d937a04d-da2e-46e0-9e38-b36d244f8daa
435 andriscreen@gmail.com Andri Irawan d938e47a-5a8d-41c2-9952-c42c7d83f291
436 suryami62@outlook.com Surya Ramadhan d9a4b3e0-eed5-4c63-984f-096a8fb0a03d
437 abdalhaqqm@gmail.com Abdalhaqq Muhammad Saih d9b2f557-027c-4c2e-85e0-f157f2df4917
438 alijun.dev@gmail.com Sulfikar Alijun da349dec-eee1-4ed4-bd97-d8322f1ab36e
439 warsenosetyono@gmail.com Warseno daaf8fd0-2660-4462-990b-9eab46df6f47
440 kemasmuhammadalqodri@gmail.com db3ca0d6-67c6-48c0-9da1-e4b45ca3f921
441 fadlankurahan2@gmail.com dd022635-06ed-4ce2-85c1-7026c954e5c7
442 iqbalharimurti1@gmail.com krenz dd38bb9b-c45d-4ce2-b4b3-f99b8c8d7e82
443 obarya24@gmail.com Afdaan dde9ea50-6a95-4a73-a7cf-0c1e9e457c56
444 jexawe1022@okcdeals.com de072603-83a0-4d19-b601-d7bd25534d3d
445 mdjauharil29@gmail.com Areal de4190a8-2a66-4e96-aaf6-c3fb0ca838a7
446 diasdigital03@gmail.com Dias dea38d11-ef5f-492c-b107-17b509326138
447 apirahman55@gmail.com Api Dev df939ac3-de24-473c-8006-cf512fec4e10
448 rvlionxz@gmail.com Reval Maulidan dfad62ce-3285-4d1d-8ad6-8cb3a618a3e8
449 tsqf.hx@gmail.com e09e8094-49ae-4cb0-9e25-8cb548f6b37e
450 bloomsky133@gmail.com e18f82ff-7809-4274-b81c-b3dae3bac731
451 asepharyanalm001@gmail.com Asep Haryana Saputra e198581d-62bc-4098-8dc8-94302face1d9
452 kussokurae990@gmail.com e1dc4e80-c85e-440f-977e-32ac86ac4a8e
453 kaoricicak000@gmail.com e210703d-e0f2-493a-b976-ad02b714fd4d
454 felixvalentino58@gmail.com e21dbd95-0379-4087-84d4-735a7e280990
455 ferdialf.dev@gmail.com FerdiAlfian_ e2248b8b-b6cb-4a9c-9746-eba0bc8d03ce
456 rafiqgans32@gmail.com Ainur Rafiq e26b3480-1f75-4038-9c73-c5db5c478e08
457 thed0704@gmail.com e27f8e2b-2ba4-41c8-8ef1-1a1c8299ec38
458 voidstayprivate@proton.me Corteza Familia e4747f46-e751-4e42-8190-5c8be219d50e
459 hoshikochan93@gmail.com Rechan Dinata e5770488-35c2-4298-a97e-b79f863c0c2a
460 ytbchanelsukiran@gmail.com e582bdf9-209e-4aa0-b052-043a664a5a27
461 putraaldhyansyah@gmail.com e59ed718-2a1b-4f86-a4e1-6063384a5fc1
462 gilarwaluyo@gmail.com Abimanyu Gilar e5d0bd59-1f07-49b7-8242-4ddb620e2a8b
463 noisyboyss887@gmail.com Kangmas Darr e65bf229-f745-41b4-a54e-875a765aa745
464 farelfebryan06@gmail.com e68e50fd-fc75-42a0-8b84-70bfaf508d01
465 wisdom.wahyuaji@gmail.com Wisdom Wahyu Aji e6a9a555-7af1-4abb-8ad7-1162e3be258a
466 b19marcel@gmail.com e6c844ce-a58a-414e-b0e3-8daf404a79f5
467 devakhri.farhan@gmail.com Devakhri Farhan e725132c-af86-4da9-a220-57288a436ab2
468 haikaljitra35@gmail.com e7bec056-7f63-4642-a763-afa39c3c340e
469 evanluis198@gmail.com Evan Luis Tanjung e8d69460-4eb9-48c7-a921-4738ad7c8dc9
470 ikhwannurrizkif@gmail.com e913c217-2dde-4498-909c-4b2d5afd89d1
471 ryandraa27@gmail.com e98fbabe-4120-4d16-b11f-e447467c8ef9
472 mtaufikska@gmail.com e99519f0-630b-4293-8487-a17d667fa708
473 tudebaliirl@gmail.com TudeStillLearning! e99abe65-5ce7-49c5-930b-b7e456d55661
474 adam.daniamm@gmail.com e9b65cdd-9c7c-471d-97ac-b02f0434e87f
475 boboi0704@gmail.com e9bc3d6c-67af-4687-8b3b-a122a93710ef
476 nathantanusubroto15@gmail.com e9ef0f76-533a-4016-ab14-420934da3fe3
477 mikmself@gmail.com Muhamad Irga Khoirul Mahfis eab56176-5f01-4ff2-8dca-51bebf7771fc
478 yuenvengozatheowonata@gmail.com Yuenvengoza Theowonata eac6a2e3-017f-4c92-ae73-38bc43520640
479 dexangga06@gmail.com eb0176ef-9cd5-474d-9bc2-115afb6eb831
480 reeimu28@gmail.com Moruten eb2c7101-f5d4-453c-80e8-1f3f77d350dc
481 henryfaaa@gmail.com eb350f16-56c0-4e05-a483-7300ab363c6b
482 kelvinlihandy2005@gmail.com Kelvin Lihandy ec3d9492-cc18-497e-b63a-a1c06281bcaa
483 alyssashane2607@gmail.com ece0ce22-4193-46f0-af02-d0153e896ee4
484 yandrajafarsidiq8@gmail.com Yandra Jafar Sidiq ee2aff01-ddc0-40fd-afda-db8e0c0e9556
485 rikogans754@gmail.com eef4fdd0-0198-4749-b099-0d558ab2ce22
486 arkandi.2020@gmail.com Muhammad Arkandi Syahputra Santosa ef13c12f-2cca-4ddf-9dad-3d387a89bbf8
487 alfaris.mgk@gmail.com f01ba8b4-7ee2-4561-bc22-73bbfb712ea0
488 bilorpanas@gmail.com f05211c3-aa26-473b-a6a4-225424349e81
489 limakaki881@gmail.com yth.mas.kanjeng.gus.mas.lui.s.pd.kom.h.i.alm.h.lc.hc.pc.amd.intel.ndx.aka. f0cace35-b865-4475-9ddb-5fce24f655f2
490 arvanardana1@gmail.com Arvan Ardana f1df8edd-2549-4c98-be46-01584aa134eb
491 farreldiego29@gmail.com Farrel Diego f26c75f5-6ae7-446b-aa47-695ba13e7c7f
492 wizzkingchannel@gmail.com HabiebAnugrah f34efb80-ad1d-434f-a319-e1d54973ff12
493 adityakurniasaputra903@gmail.com Kurnias f377d789-ecf1-4a12-b72b-a8ffe30cc123
494 dav1nalifianda@gmail.com Davin Alifianda Adytia f3a25278-ec1d-496a-a89c-030c5c521111
495 rifqinanda60@gmail.com f3fef42a-9d51-40d6-b6e7-59f25bf068c8
496 notmadz132@gmail.com Mu'adz Abdulloh f40b14d5-0cc4-4605-9893-8f27dd34de71
497 qodria589@gmail.com f44ce041-e64c-4cbd-a104-83964aa37ccb
498 fahrezaxgamer@gmail.com Fahreza Pratama Hidayat f47cacb7-e099-4816-96c9-77f858414235
499 dyas@live.com Drestanto Muhammad Dyasputro f57ac77c-7ea9-4b92-aeb5-a7131a5052dc
500 ahmadgozali4040@gmail.com Ahmad Ghozali f6938963-132b-4471-90c1-2a252923b89f
501 jony.huang.me@gmail.com Jony Wijaya f786208b-28cc-4484-935f-b88f2f75d6d0
502 afrizaahmad18@gmail.com normies f7b6ebf3-3bce-46df-b18f-22763e552e6e
503 satyabr147@gmail.com f833772b-a037-48ae-90eb-c3b02a06a3b9
504 gmrikza05@gmail.com f83bbabf-f6e8-4023-9626-2a97a0c4c994
505 andikadibya92@gmail.com Andika Dibya Azzumardi f8cc3af4-3a3a-4a3d-8314-972725366ffb
506 rizkynursanto48@gmail.com Rizky Adi N. fa57208f-b1a7-4766-9067-d4531833f4b9
507 muh.indaarr@gmail.com Muhammad Indar fa7f0fc6-7449-42ac-a80f-560960345ac4
508 laluarya633@gmail.com fae5c5a9-6a26-48d6-b04d-68ebf4d81e7b
509 muhammadfadlanrais@gmail.com fbc4c505-01ef-40b2-a257-c2430fe1bdc5
510 rifkybujanabisri@gmail.com Rifky Bujana Bisri fc00d1bf-65da-431b-9994-52a3cfc88273
511 alazmimuhammadhabib@gmail.com Izumi fc05d2d3-0a55-4407-b20e-99d7bf24a3ec
512 scamgarapan@gmail.com fc22e953-b532-48b2-be6a-0fbc3b3774fb
513 fajaralfrzi@gmail.com fc701b59-0510-44f3-a85e-0cd22e9f777f
514 imanwealth00@gmail.com WebDevShoul fcad381c-ab34-4f62-b6f7-4569c24625f3
515 muhammadalifaditya92@gmail.com Alif Aditya fea8d6e4-a831-43a9-b98c-6a29c20dab53
516 udinhilif@gmail.com Rio Fernanda fef16ed6-438c-44c0-9070-66d3f602beae
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -13,11 +13,10 @@
},
"release": {
"version": {
"generatorOptions": {
"packageRoot": "dist/{projectRoot}",
"currentVersionResolver": "git-tag",
"fallbackCurrentVersionResolver": "disk"
}
"currentVersionResolver": "git-tag",
"fallbackCurrentVersionResolver": "disk",
"preserveLocalDependencyProtocols": false,
"manifestRootsToUpdate": ["dist/{projectRoot}"]
}
}
}
+13
View File
@@ -65,3 +65,16 @@ export const postGoogleCallback = async (code: string, state: string): Promise<T
});
return data;
};
export const getGitHubAuthUrl = async (): Promise<string> => {
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080';
return `${baseUrl}/auth/github/login`;
};
export const postGitHubCallback = async (code: string, state: string): Promise<TGoogleCallbackResponse> => {
const { data } = await api({
method: 'GET',
url: `/auth/github/callback?code=${code}&state=${state}`,
});
return data;
};
+114
View File
@@ -0,0 +1,114 @@
import { api } from '../index';
import type {
TCreateTeamRequest,
TUpdateTeamRequest,
TInviteMemberRequest,
TJoinTeamRequest,
TManageMemberRequest,
TSubmitProjectRequest,
TTeamListResponse,
TTeamDetailResponse,
TTeamMembersResponse,
TTeamInvitationsResponse,
TTeamJoinRequestsResponse,
TProjectSubmissionResponse,
} from '../../types/teams';
const TEAMS_BASE_URL = '/teams';
// Team CRUD
export const getTeams = async (params?: {
page?: number;
limit?: number;
city?: string;
visibility?: string;
search?: string;
}) => {
const response = await api.get<TTeamListResponse>(TEAMS_BASE_URL, { params });
return response.data;
};
export const getTeamById = async (teamId: string) => {
const response = await api.get<TTeamDetailResponse>(`${TEAMS_BASE_URL}/${teamId}`);
return response.data;
};
export const createTeam = async (data: TCreateTeamRequest) => {
const response = await api.post<TTeamDetailResponse>(TEAMS_BASE_URL, data);
return response.data;
};
export const updateTeam = async (teamId: string, data: TUpdateTeamRequest) => {
const response = await api.put<TTeamDetailResponse>(`${TEAMS_BASE_URL}/${teamId}`, data);
return response.data;
};
// Team Members
export const getTeamMembers = async (teamId: string) => {
const response = await api.get<TTeamMembersResponse>(`${TEAMS_BASE_URL}/${teamId}/members`);
return response.data;
};
export const inviteMember = async (teamId: string, data: TInviteMemberRequest) => {
const response = await api.post(`${TEAMS_BASE_URL}/${teamId}/invite`, data);
return response.data;
};
export const manageMember = async (teamId: string, userId: string, data: TManageMemberRequest) => {
const response = await api.put(`${TEAMS_BASE_URL}/${teamId}/members/${userId}`, data);
return response.data;
};
export const removeMember = async (teamId: string, userId: string) => {
const response = await api.delete(`${TEAMS_BASE_URL}/${teamId}/members/${userId}`);
return response.data;
};
// Join Requests
export const joinTeam = async (teamId: string, data: TJoinTeamRequest) => {
const response = await api.post(`${TEAMS_BASE_URL}/${teamId}/join-request`, data);
return response.data;
};
export const getTeamJoinRequests = async (teamId: string) => {
const response = await api.get<TTeamJoinRequestsResponse>(`${TEAMS_BASE_URL}/${teamId}/join-requests`);
return response.data;
};
export const respondToJoinRequest = async (teamId: string, requestId: string, action: 'approve' | 'reject') => {
const response = await api.put(`${TEAMS_BASE_URL}/${teamId}/join-requests/${requestId}`, { action });
return response.data;
};
// Invitations
export const getMyInvitations = async () => {
const response = await api.get<TTeamInvitationsResponse>(`${TEAMS_BASE_URL}/invitations/me`);
return response.data;
};
export const respondToInvitation = async (invitationId: string, action: 'accept' | 'reject') => {
const response = await api.put(`${TEAMS_BASE_URL}/invitations/${invitationId}`, { action });
return response.data;
};
// User's Teams
export const getMyTeams = async () => {
const response = await api.get<TTeamListResponse>(`${TEAMS_BASE_URL}/me`);
return response.data;
};
// Project Submission
export const submitProject = async (teamId: string, data: TSubmitProjectRequest) => {
const response = await api.post<TProjectSubmissionResponse>(`${TEAMS_BASE_URL}/${teamId}/submission`, data);
return response.data;
};
export const getTeamSubmission = async (teamId: string) => {
const response = await api.get<TProjectSubmissionResponse>(`${TEAMS_BASE_URL}/${teamId}/submission`);
return response.data;
};
export const leaveTeam = async (teamId: string) => {
const response = await api.post(`${TEAMS_BASE_URL}/${teamId}/leave`);
return response.data;
};
+522
View File
@@ -0,0 +1,522 @@
export 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 const INDONESIAN_CITIES_SET = new Set(INDONESIAN_CITIES);
export function isValidIndonesianCity(city: string): boolean {
return INDONESIAN_CITIES_SET.has(city);
}
+83 -58
View File
@@ -1,83 +1,108 @@
import { useMutation, UseMutationResult } from '@tanstack/react-query';
import { postLogin, postRegister, postSendOtp, postVerifyEmail, getGoogleAuthUrl, postGoogleCallback } from '../../api/auth';
import {
TLoginRequest,
TLoginResponse,
TRegisterRequest,
TSendOTPRequest,
TVerifyEmailRequest,
TGoogleCallbackResponse,
} from '../../types/auth';
import { supabase } from '../../supabase';
import { useMutation } from '@tanstack/react-query';
import * as authApi from '../../api/auth';
import { TResponseError, TResponseMessage } from '../../types/common';
export * from './use-auth-store';
export const usePostLogin = (): UseMutationResult<
TLoginResponse,
TResponseError,
TLoginRequest,
unknown
> => {
// React Query hooks for auth API
export const usePostLogin = () => {
return useMutation({
mutationKey: ['post-login'],
mutationFn: async (payload) => await postLogin(payload),
mutationFn: authApi.postLogin,
});
};
export const usePostRegister = (): UseMutationResult<
TResponseMessage,
TResponseError,
TRegisterRequest,
unknown
> => {
export const usePostRegister = () => {
return useMutation({
mutationKey: ['post-register'],
mutationFn: async (payload) => await postRegister(payload),
mutationFn: authApi.postRegister,
});
};
export const usePostVerifyEmail = (): UseMutationResult<
TResponseMessage,
TResponseError,
TVerifyEmailRequest,
unknown
> => {
export const usePostVerifyEmail = () => {
return useMutation({
mutationKey: ['post-verify-email'],
mutationFn: async (payload) => await postVerifyEmail(payload),
mutationFn: authApi.postVerifyEmail,
});
};
export const usePostSendOTP = (): UseMutationResult<
TResponseMessage,
TResponseError,
TSendOTPRequest,
unknown
> => {
export const usePostSendOtp = () => {
return useMutation({
mutationKey: ['post-send-otp'],
mutationFn: async (payload) => await postSendOtp(payload),
mutationFn: authApi.postSendOtp,
});
};
export const useGoogleAuth = () => {
const redirectToGoogle = async (): Promise<string> => {
return await getGoogleAuthUrl();
export const useGoogleCallback = () => {
return useMutation({
mutationFn: ({ code, state }: { code: string; state: string }) =>
authApi.postGoogleCallback(code, state),
});
};
// Supabase GitHub OAuth hook
export const useGitHubAuth = () => {
const signInWithGitHub = async () => {
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: `${globalThis.location.origin}/auth/callback`,
},
});
if (error) {
throw error;
}
// Return the OAuth URL for debugging
return data;
};
return {
redirectToGoogle,
signInWithGitHub,
};
};
export const useGoogleCallback = (): UseMutationResult<
TGoogleCallbackResponse,
TResponseError,
{ code: string; state: string },
unknown
> => {
return useMutation({
mutationKey: ['google-callback'],
mutationFn: async ({ code, state }) => await postGoogleCallback(code, state),
});
};
// Supabase Email/Password authentication hook
export const useEmailAuth = () => {
const signInWithEmail = async (email: string, password: string) => {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
throw error;
}
return data;
};
const signUpWithEmail = async (email: string, password: string, fullname: string) => {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
full_name: fullname,
},
},
});
if (error) {
throw error;
}
return data;
};
const signOut = async () => {
const { error } = await supabase.auth.signOut();
if (error) {
throw error;
}
};
return {
signInWithEmail,
signUpWithEmail,
signOut,
};
};
@@ -0,0 +1,46 @@
import { create } from 'zustand';
import { TLoginItem } from '../../types';
import { SessionToken, SessionUser } from '../../storage';
export enum ESessionStatus {
Authenticated = 'authenticated',
Authenticating = 'authenticating',
Unauthenticated = 'unauthenticated',
}
type SessionState = {
isLoading: boolean;
session?: TLoginItem;
status?: ESessionStatus;
setLoading: (val: boolean) => void;
setSession: (payload: TLoginItem) => void;
clearSession: () => void;
};
export const useAuthStore = create<SessionState>((set) => {
const session = SessionToken.get();
const user = SessionUser.get();
const isAuthenticated = !!session;
return {
isLoading: false,
session: isAuthenticated ? { token: session.token, user } : undefined,
status: isAuthenticated
? ESessionStatus.Authenticated
: ESessionStatus.Unauthenticated,
setLoading: (val) => set({ isLoading: val }),
setSession: (data) => {
SessionToken.set({ token: data.token });
SessionUser.set(data.user);
set({
session: data,
status: ESessionStatus.Authenticated,
});
},
clearSession: () => {
SessionToken.remove();
SessionUser.remove();
set({ session: undefined, status: ESessionStatus.Unauthenticated });
},
};
});
+2
View File
@@ -3,3 +3,5 @@ export * from './gacha';
export * from './users';
export * from './mentors';
export * from './upload';
export * from './teams';
export * from './messages';
+181
View File
@@ -0,0 +1,181 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { supabase } from '../../supabase';
import { useAuthStore } from '../auth';
import { useEffect } from 'react';
export type Message = {
id: string;
team_id: string;
user_id: string;
message: string;
created_at: string;
updated_at: string;
user?: {
id: string;
fullname: string;
avatar: string;
email: string;
};
};
// Query keys
export const messageKeys = {
all: ['messages'] as const,
team: (teamId: string) => [...messageKeys.all, 'team', teamId] as const,
};
// Fetch messages for a team
export const useTeamMessages = (teamId: string) => {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: messageKeys.team(teamId),
queryFn: async () => {
const { data: messages, error } = await supabase
.from('team_messages')
.select(`
id,
team_id,
user_id,
message,
created_at,
updated_at,
user:users(id, fullname, avatar, email)
`)
.eq('team_id', teamId)
.order('created_at', { ascending: true });
if (error) {
console.error('Failed to fetch messages:', error);
throw new Error(error.message || 'Failed to fetch messages');
}
return messages as Message[];
},
enabled: !!teamId,
});
// Subscribe to realtime updates
useEffect(() => {
if (!teamId) return;
const channel = supabase
.channel(`team_messages:${teamId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'team_messages',
filter: `team_id=eq.${teamId}`,
},
async (payload) => {
console.log('[Realtime] New message:', payload);
// Fetch the full message with user data
const { data: newMessage } = await supabase
.from('team_messages')
.select(`
id,
team_id,
user_id,
message,
created_at,
updated_at,
user:users(id, fullname, avatar, email)
`)
.eq('id', payload.new.id)
.single();
if (newMessage) {
queryClient.setQueryData<Message[]>(
messageKeys.team(teamId),
(old) => [...(old || []), newMessage as Message]
);
}
}
)
.on(
'postgres_changes',
{
event: 'DELETE',
schema: 'public',
table: 'team_messages',
filter: `team_id=eq.${teamId}`,
},
(payload) => {
console.log('[Realtime] Message deleted:', payload);
queryClient.setQueryData<Message[]>(
messageKeys.team(teamId),
(old) => old?.filter((msg) => msg.id !== payload.old.id) || []
);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [teamId, queryClient]);
return query;
};
// Send a message
export const useSendMessage = (teamId: string) => {
const queryClient = useQueryClient();
const { session } = useAuthStore();
return useMutation({
mutationFn: async (message: string) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to send messages');
}
const { data, error } = await supabase
.from('team_messages')
.insert({
team_id: teamId,
user_id: session.user.id,
message,
})
.select()
.single();
if (error) {
console.error('Failed to send message:', error);
throw new Error(error.message || 'Failed to send message');
}
return data;
},
onSuccess: () => {
// Realtime will handle adding the message to the list
// But we can invalidate to ensure consistency
queryClient.invalidateQueries({ queryKey: messageKeys.team(teamId) });
},
});
};
// Delete a message
export const useDeleteMessage = (teamId: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (messageId: string) => {
const { error } = await supabase
.from('team_messages')
.delete()
.eq('id', messageId);
if (error) {
console.error('Failed to delete message:', error);
throw new Error(error.message || 'Failed to delete message');
}
},
onSuccess: () => {
// Realtime will handle removing the message from the list
queryClient.invalidateQueries({ queryKey: messageKeys.team(teamId) });
},
});
};
+788
View File
@@ -0,0 +1,788 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as teamsApi from '../../api/teams';
import { supabase, getAuthenticatedClient } from '../../supabase';
import { useAuthStore } from '../auth';
import type {
TCreateTeamRequest,
TUpdateTeamRequest,
TInviteMemberRequest,
TJoinTeamRequest,
TSubmitProjectRequest,
} from '../../types/teams';
// Query keys
export const teamKeys = {
all: ['teams'] as const,
lists: () => [...teamKeys.all, 'list'] as const,
list: (filters?: Record<string, unknown>) => [...teamKeys.lists(), filters] as const,
details: () => [...teamKeys.all, 'detail'] as const,
detail: (id: string) => [...teamKeys.details(), id] as const,
members: (id: string) => [...teamKeys.detail(id), 'members'] as const,
joinRequests: (id: string) => [...teamKeys.detail(id), 'join-requests'] as const,
submission: (id: string) => [...teamKeys.detail(id), 'submission'] as const,
myTeams: () => [...teamKeys.all, 'my-teams'] as const,
myInvitations: () => [...teamKeys.all, 'my-invitations'] as const,
};
// Team CRUD Hooks
export const useTeams = (params?: {
page?: number;
limit?: number;
city?: string;
visibility?: string;
search?: string;
}) => {
return useQuery({
queryKey: teamKeys.list(params),
queryFn: async () => {
try {
// Supabase client now has auth context from setSession()
let query = supabase.from('teams').select(`
*,
members:team_members(id)
`);
// Filter by visibility
if (params?.visibility) {
query = query.eq('visibility', params.visibility);
}
// Filter by city
if (params?.city) {
query = query.eq('city', params.city);
}
// Search by name
if (params?.search) {
query = query.ilike('name', `%${params.search}%`);
}
// Pagination
if (params?.page && params?.limit) {
const from = (params.page - 1) * params.limit;
const to = from + params.limit - 1;
query = query.range(from, to);
}
const { data, error } = await query;
if (error) {
// If error is 401/403, it means RLS policies need to be set up
// Return empty array for now
console.warn('Teams query error (RLS policies may need to be configured):', error);
return { data: [] };
}
return { data: data || [] };
} catch (err) {
console.error('Failed to fetch teams:', err);
return { data: [] };
}
},
});
};
export const useTeamById = (teamId: string, enabled = true) => {
return useQuery({
queryKey: teamKeys.detail(teamId),
queryFn: async () => {
// Supabase client now has auth context from setSession()
const { data: team, error } = await supabase
.from('teams')
.select(`
*,
leader:users!leader_id(id, email, fullname, avatar),
members:team_members(
id,
role,
status,
joined_at,
user:users(id, email, fullname, avatar)
)
`)
.eq('id', teamId)
.single();
if (error) {
console.error('Failed to fetch team:', error);
throw new Error(error.message || 'Failed to fetch team');
}
// Count active members
const activeMemberCount = team?.members?.filter((m: any) => m.status === 'active').length || 0;
// Check if team has a submission
const { data: submission } = await supabase
.from('project_submissions')
.select('id')
.eq('team_id', teamId)
.maybeSingle();
return {
data: {
...team,
member_count: activeMemberCount,
has_submission: !!submission,
},
};
},
enabled: enabled && !!teamId,
});
};
export const useCreateTeam = () => {
const queryClient = useQueryClient();
const { session } = useAuthStore();
return useMutation({
mutationFn: async (data: TCreateTeamRequest) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to create a team');
}
// Supabase client now has auth context from setSession()
const { data: team, error: teamError } = await supabase
.from('teams')
.insert({
name: data.name,
logo: data.logo,
banner: data.banner,
description: data.description,
city: data.city,
visibility: data.visibility,
leader_id: session.user.id,
})
.select()
.single();
if (teamError) {
console.error('Failed to create team:', teamError);
throw new Error(teamError.message || 'Failed to create team');
}
// Insert team creator as leader in team_members table
const { error: memberError } = await supabase
.from('team_members')
.insert({
team_id: team.id,
user_id: session.user.id,
role: 'leader',
status: 'active',
});
if (memberError) {
// If duplicate key error (23505), it means leader is already a member (possibly by trigger)
// This is acceptable, so we can ignore it
if (memberError.code === '23505') {
console.log('Team leader already exists in team_members (likely added by trigger)');
} else {
// For other errors, clean up and throw
console.error('Failed to add team leader as member:', memberError);
await supabase.from('teams').delete().eq('id', team.id);
throw new Error('Failed to set up team membership');
}
}
return { data: team };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
queryClient.invalidateQueries({ queryKey: teamKeys.myTeams() });
},
});
};
export const useUpdateTeam = (teamId: string) => {
const queryClient = useQueryClient();
const { session } = useAuthStore();
return useMutation({
mutationFn: async (data: TUpdateTeamRequest) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to update a team');
}
// Supabase client now has auth context from setSession()
const { data: team, error } = await supabase
.from('teams')
.update({
name: data.name,
logo: data.logo,
banner: data.banner,
description: data.description,
city: data.city,
visibility: data.visibility,
})
.eq('id', teamId)
.select()
.single();
if (error) {
console.error('Failed to update team:', error);
throw new Error(error.message || 'Failed to update team');
}
return { data: team };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
},
});
};
// Team Members Hooks
export const useTeamMembers = (teamId: string, enabled = true) => {
return useQuery({
queryKey: teamKeys.members(teamId),
queryFn: async () => {
// Supabase client now has auth context from setSession()
const { data: members, error } = await supabase
.from('team_members')
.select(`
id,
team_id,
user_id,
role,
status,
joined_at,
user:users(id, email, fullname, avatar)
`)
.eq('team_id', teamId)
.order('joined_at', { ascending: true });
if (error) {
console.error('Failed to fetch team members:', error);
throw new Error(error.message || 'Failed to fetch team members');
}
return { data: members || [] };
},
enabled: enabled && !!teamId,
});
};
export const useInviteMember = (teamId: string) => {
const queryClient = useQueryClient();
const { session } = useAuthStore();
return useMutation({
mutationFn: async (data: TInviteMemberRequest) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to invite a member');
}
// Insert invitation into team_invitations table
const { data: invitation, error } = await supabase
.from('team_invitations')
.insert({
team_id: teamId,
inviter_id: session.user.id,
invitee_email: data.email,
status: 'pending',
})
.select()
.single();
if (error) {
console.error('Failed to create invitation:', error);
throw new Error(error.message || 'Failed to send invitation');
}
return { data: invitation };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.members(teamId) });
},
});
};
export const useManageMember = (teamId: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ userId, data }: { userId: string; data: any }) =>
teamsApi.manageMember(teamId, userId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.members(teamId) });
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
},
});
};
export const useRemoveMember = (teamId: string) => {
const queryClient = useQueryClient();
const { session } = useAuthStore();
return useMutation({
mutationFn: async (userId: string) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to remove a member');
}
// Verify the current user is the team leader
const { data: team, error: teamError } = await supabase
.from('teams')
.select('leader_id')
.eq('id', teamId)
.single();
if (teamError || !team) {
throw new Error('Team not found');
}
if (team.leader_id !== session.user.id) {
throw new Error('Only the team leader can remove members');
}
// Cannot remove the leader
if (userId === team.leader_id) {
throw new Error('Cannot remove the team leader');
}
// Delete the team member record
const { error: deleteError } = await supabase
.from('team_members')
.delete()
.eq('team_id', teamId)
.eq('user_id', userId);
if (deleteError) {
console.error('Failed to remove member:', deleteError);
throw new Error(deleteError.message || 'Failed to remove member');
}
return { success: true };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.members(teamId) });
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
},
});
};
// Join Requests Hooks
export const useJoinTeam = () => {
const queryClient = useQueryClient();
const { session } = useAuthStore();
return useMutation({
mutationFn: async ({ teamId, data }: { teamId: string; data: TJoinTeamRequest }) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to join a team');
}
// Insert join request into team_join_requests table
const { data: joinRequest, error } = await supabase
.from('team_join_requests')
.insert({
team_id: teamId,
user_id: session.user.id,
message: data.message,
status: 'pending',
})
.select()
.single();
if (error) {
console.error('Failed to create join request:', error);
throw new Error(error.message || 'Failed to send join request');
}
return { data: joinRequest };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
},
});
};
export const useTeamJoinRequests = (teamId: string, enabled = true) => {
return useQuery({
queryKey: teamKeys.joinRequests(teamId),
queryFn: async () => {
// Fetch join requests with user information
const { data: requests, error } = await supabase
.from('team_join_requests')
.select(`
id,
team_id,
user_id,
message,
status,
created_at,
user:users(id, email, fullname, avatar)
`)
.eq('team_id', teamId)
.eq('status', 'pending')
.order('created_at', { ascending: false });
if (error) {
console.error('Failed to fetch join requests:', error);
throw new Error(error.message || 'Failed to fetch join requests');
}
return { data: requests || [] };
},
enabled: enabled && !!teamId,
});
};
export const useRespondToJoinRequest = (teamId: string) => {
const queryClient = useQueryClient();
const { session } = useAuthStore();
return useMutation({
mutationFn: async ({ requestId, action }: { requestId: string; action: 'approve' | 'reject' }) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to respond to join requests');
}
// First, get the join request details
const { data: joinRequest, error: fetchError } = await supabase
.from('team_join_requests')
.select('id, team_id, user_id, status')
.eq('id', requestId)
.single();
if (fetchError || !joinRequest) {
throw new Error('Join request not found');
}
if (joinRequest.status !== 'pending') {
throw new Error('Join request has already been processed');
}
if (action === 'approve') {
// Update join request status
const { error: updateError } = await supabase
.from('team_join_requests')
.update({ status: 'accepted' })
.eq('id', requestId);
if (updateError) {
throw new Error('Failed to update join request: ' + updateError.message);
}
// Add user as team member
const { error: memberError } = await supabase
.from('team_members')
.insert({
team_id: joinRequest.team_id,
user_id: joinRequest.user_id,
role: 'member',
status: 'active',
});
if (memberError) {
// If member creation fails, rollback join request update
await supabase
.from('team_join_requests')
.update({ status: 'pending' })
.eq('id', requestId);
// Parse Supabase error for user-friendly message
let errorMsg = memberError.message;
if (errorMsg.includes('Team already has 5 members') || errorMsg.includes('Team cannot have more than 5 members')) {
errorMsg = 'Team is full! Maximum 5 members allowed.';
} else if (errorMsg.includes('already in a team') || errorMsg.includes('User is already in a team')) {
errorMsg = 'This user is already in another team.';
} else if (errorMsg.includes('Bulk insert')) {
errorMsg = 'Invalid operation detected.';
}
throw new Error(errorMsg);
}
return { success: true, action: 'accepted' };
} else {
// Reject join request
const { error: updateError } = await supabase
.from('team_join_requests')
.update({ status: 'rejected' })
.eq('id', requestId);
if (updateError) {
throw new Error('Failed to update join request: ' + updateError.message);
}
return { success: true, action: 'rejected' };
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.joinRequests(teamId) });
queryClient.invalidateQueries({ queryKey: teamKeys.members(teamId) });
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
},
});
};
// Invitations Hooks
export const useMyInvitations = () => {
const { session } = useAuthStore();
return useQuery({
queryKey: teamKeys.myInvitations(),
queryFn: async () => {
if (!session?.user?.email) {
return { data: [] };
}
// Query team_invitations where invitee_email matches current user's email
const { data: invitations, error } = await supabase
.from('team_invitations')
.select(`
id,
team_id,
inviter_id,
invitee_email,
invitee_id,
status,
created_at,
team:teams(
id,
name,
logo,
banner,
description,
city,
visibility,
leader_id
),
inviter:users!team_invitations_inviter_id_fkey(
id,
fullname,
email,
avatar
)
`)
.eq('invitee_email', session.user.email)
.eq('status', 'pending');
if (error) {
console.error('Failed to fetch invitations:', error);
return { data: [] };
}
return { data: invitations || [] };
},
enabled: !!session?.user?.email,
});
};
export const useRespondToInvitation = () => {
const queryClient = useQueryClient();
const { session } = useAuthStore();
return useMutation({
mutationFn: async ({ invitationId, action }: { invitationId: string; action: 'accept' | 'reject' }) => {
if (!session?.user?.id) {
throw new Error('User not authenticated');
}
// First, get the invitation details
const { data: invitation, error: fetchError } = await supabase
.from('team_invitations')
.select('id, team_id, invitee_email, status')
.eq('id', invitationId)
.single();
if (fetchError || !invitation) {
throw new Error('Invitation not found');
}
if (invitation.status !== 'pending') {
throw new Error('Invitation has already been responded to');
}
if (action === 'accept') {
// Update invitation status and set invitee_id
const { error: updateError } = await supabase
.from('team_invitations')
.update({
status: 'accepted',
invitee_id: session.user.id,
})
.eq('id', invitationId);
if (updateError) {
throw new Error('Failed to update invitation: ' + updateError.message);
}
// Create team_members record
const { error: memberError } = await supabase
.from('team_members')
.insert({
team_id: invitation.team_id,
user_id: session.user.id,
role: 'member',
status: 'active',
});
if (memberError) {
// If member creation fails, rollback invitation update
await supabase
.from('team_invitations')
.update({
status: 'pending',
invitee_id: null,
})
.eq('id', invitationId);
throw new Error('Failed to add member to team: ' + memberError.message);
}
return { success: true, action: 'accepted' };
} else {
// Reject invitation
const { error: updateError } = await supabase
.from('team_invitations')
.update({
status: 'rejected',
invitee_id: session.user.id,
})
.eq('id', invitationId);
if (updateError) {
throw new Error('Failed to update invitation: ' + updateError.message);
}
return { success: true, action: 'rejected' };
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.myInvitations() });
queryClient.invalidateQueries({ queryKey: teamKeys.myTeams() });
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
},
});
};
// User's Teams
export const useMyTeams = () => {
const { session } = useAuthStore();
return useQuery({
queryKey: teamKeys.myTeams(),
queryFn: async () => {
if (!session?.user?.id) {
return { data: [] };
}
// Query team_members to find teams where user is a member
const { data: memberships, error: membershipsError } = await supabase
.from('team_members')
.select(`
team_id,
team:teams(
id,
name,
logo,
banner,
description,
city,
visibility,
leader_id,
created_at
)
`)
.eq('user_id', session.user.id)
.eq('status', 'active');
if (membershipsError) {
console.error('Failed to fetch user teams:', membershipsError);
return { data: [] };
}
// Extract teams from memberships
const teams = memberships?.map((m: any) => m.team).filter(Boolean) || [];
return { data: teams };
},
enabled: !!session?.user?.id,
});
};
// Project Submission Hooks
export const useSubmitProject = (teamId: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: TSubmitProjectRequest) => {
return await teamsApi.submitProject(teamId, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.submission(teamId) });
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
},
});
};
export const useTeamSubmission = (teamId: string, enabled = true) => {
return useQuery({
queryKey: teamKeys.submission(teamId),
queryFn: async () => {
const submission = await teamsApi.getTeamSubmission(teamId);
return { data: submission };
},
enabled: enabled && !!teamId,
});
};
// Leave Team Hook
export const useLeaveTeam = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (teamId: string) => {
return await teamsApi.leaveTeam(teamId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.myTeams() });
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
},
});
};
// Get Teams by User ID
export const useTeamsByUserId = (userId: string) => {
return useQuery({
queryKey: ['teams-by-user', userId],
queryFn: async () => {
if (!userId) {
return { data: [] };
}
// Query team_members to find teams where user is a member
const { data: memberships, error: membershipsError } = await supabase
.from('team_members')
.select(`
team_id,
team:teams(
id,
name,
logo,
banner,
description,
city,
visibility,
leader_id,
created_at
)
`)
.eq('user_id', userId)
.eq('status', 'active');
if (membershipsError) {
console.error('Failed to fetch user teams:', membershipsError);
return { data: [] };
}
// Extract teams from memberships
const teams = memberships?.map((m: any) => m.team).filter(Boolean) || [];
return { data: teams };
},
enabled: !!userId,
});
};
+104 -24
View File
@@ -1,39 +1,119 @@
import { useMutation, UseMutationResult } from '@tanstack/react-query';
import { uploadService, UploadResponse } from '../../api/upload';
import { TResponseError } from '../../types/common';
import { useMutation } from '@tanstack/react-query';
import { supabase, getAuthenticatedClient } from '../../supabase';
import { useAuthStore } from '../auth';
// Supabase Storage-based upload hooks
export const useUploadFile = () => {
const { session } = useAuthStore();
export const useUploadFile = (): UseMutationResult<
UploadResponse,
TResponseError,
File,
unknown
> => {
return useMutation({
mutationKey: ['upload-file'],
mutationFn: (file) => uploadService.uploadFile(file),
mutationFn: async (file: File) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to upload files');
}
// Generate a unique file name
const fileExt = file.name.split('.').pop();
const fileName = `${session.user.id}-${Date.now()}.${fileExt}`;
const filePath = `teams/${fileName}`;
// Supabase client now has auth context from setSession()
const { error } = await supabase.storage
.from('hackathon-uploads')
.upload(filePath, file, {
cacheControl: '3600',
upsert: true,
});
if (error) {
console.error('Failed to upload file:', error);
throw new Error(error.message || 'Failed to upload file');
}
// Get public URL
const { data: publicUrlData } = supabase.storage
.from('hackathon-uploads')
.getPublicUrl(filePath);
return { data: { url: publicUrlData.publicUrl } };
},
});
};
export const useUploadAvatar = (): UseMutationResult<
UploadResponse,
TResponseError,
File,
unknown
> => {
export const useUploadAvatar = () => {
const { session } = useAuthStore();
return useMutation({
mutationKey: ['upload-avatar'],
mutationFn: (file) => uploadService.uploadAvatar(file),
mutationFn: async (file: File) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to upload avatar');
}
// Generate a unique file name
const fileExt = file.name.split('.').pop();
const fileName = `${session.user.id}-${Date.now()}.${fileExt}`;
const filePath = `avatars/${fileName}`;
// Supabase client now has auth context from setSession()
const { error } = await supabase.storage
.from('hackathon-uploads')
.upload(filePath, file, {
cacheControl: '3600',
upsert: true,
});
if (error) {
console.error('Failed to upload avatar:', error);
throw new Error(error.message || 'Failed to upload avatar');
}
// Get public URL
const { data: publicUrlData } = supabase.storage
.from('hackathon-uploads')
.getPublicUrl(filePath);
return { data: { url: publicUrlData.publicUrl } };
},
});
};
export const useUploadCV = (): UseMutationResult<
UploadResponse,
TResponseError,
File,
unknown
> => {
export const useUploadCV = () => {
const { session } = useAuthStore();
return useMutation({
mutationKey: ['upload-cv'],
mutationFn: (file) => uploadService.uploadCV(file),
mutationFn: async (file: File) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to upload CV');
}
// Generate a unique file name
const fileExt = file.name.split('.').pop();
const fileName = `${session.user.id}-${Date.now()}.${fileExt}`;
const filePath = `cvs/${fileName}`;
// Supabase client now has auth context from setSession()
const { error } = await supabase.storage
.from('hackathon-uploads')
.upload(filePath, file, {
cacheControl: '3600',
upsert: true,
});
if (error) {
console.error('Failed to upload CV:', error);
throw new Error(error.message || 'Failed to upload CV');
}
// Get public URL
const { data: publicUrlData } = supabase.storage
.from('hackathon-uploads')
.getPublicUrl(filePath);
return { data: { url: publicUrlData.publicUrl } };
},
});
};

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