Compare commits

..
Author SHA1 Message Date
Hafid Nur aa4686254b Merge branch 'develop' of github.com:IMPHNEN/imphnen-frontend-service into backoffice/for-hackathon 2025-12-02 18:13:03 +07:00
Hafid Nur 2d80ed05db feat(backoffice): little adjustment in hackathon users management UI and API contract 2025-12-02 18:11:34 +07:00
Maulana SodiqinandClaude d923237969 hide presentation URL field from submission pages
🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: remove react-helmet-async

* chore: update landing page

* chore: update UI for signup and login

- add back button for better UX

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

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

* feat(hackathon): update UI

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

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

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

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

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

* feat(hackathon): browse team page

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

* feat(hackathon): update team page

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

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

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

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

* feat(hackathon): dark mode for user page

---------

Co-authored-by: Maulana Sodiqin <sodiqincahyana1@gmail.com>
Co-authored-by: Muhammad Zakir Ramadhan <61570975+zakirkun@users.noreply.github.com>
2025-11-27 16:25:21 +07:00
bc5b072ad8 feat(hackathon): Improve dark mode and UI/UX in Hackathon website (#64)
* feat: update landing page

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

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

* chore: remove react-helmet-async

* chore: update landing page

* chore: update UI for signup and login

- add back button for better UX

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

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

* feat(hackathon): update UI

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

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

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

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

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

* feat(hackathon): browse team page

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

* feat(hackathon): update team page

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

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

---------

Co-authored-by: Maulana Sodiqin <sodiqincahyana1@gmail.com>
Co-authored-by: Muhammad Zakir Ramadhan <61570975+zakirkun@users.noreply.github.com>
2025-11-27 15:12:45 +07:00
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
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
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
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
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
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 Rizkyanto 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
Hafid NurandMaulana Sodiqin 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
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
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 Nur 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
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 Nur 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 Nur e067450fcb fix(landing): Fix community links & meta tag for SEO (#57) 2025-10-06 08:57:13 +07:00
Asep Haryana Saputra a281f279f8 full json (#56) 2025-08-27 19:28:40 +07:00
Asep Haryana Saputra a157e11e13 hackatons (#55)
* feat(hackathon): add hackathon page static

* feat(hackathon): enhance animation effects on hackathon cards

* feat(hackathon): update hackathon screenshots and add new projects
2025-08-24 22:57:03 +07:00
Muhamad Rijal b38afd2b44 Feat/backoffice dimentorin (#53)
* feat(backoffice): slicing backoffice dimentorin

* feat(backoffice): slicing backoffice dimentorin - user management

* feat(backoffice): slicing backoffice dimentorin - modal detail user management

* feat(backoffice): slicing backoffice dimentorin - session management

* feat(backoffice): slicing backoffice dimentorin - modal detail session

* feat(backoffice): slicing backoffice dimentorin - modal detail session

* feat(backoffice): slicing backoffice dimentorin - setting page

* feat(backoffice): slicing backoffice dimentorin - content & roadmap

* feat(backoffice): slicing backoffice dimentorin - feedback & review
2025-08-20 21:58:28 +07:00
Asep Haryana Saputra 3c301ec2c4 feat(auth): add Google authentication endpoints and hooks (#51)
- Implemented `getGoogleAuthUrl` and `postGoogleCallback` in auth API.
- Added corresponding hooks `useGoogleAuth` and `useGoogleCallback` for handling Google authentication.

feat(api): enhance API structure with mentors and upload services

- Created `mentors` and `upload` API modules with respective services for managing mentor details and file uploads.
- Added session management utilities to handle authentication tokens in cookies.

feat(users): expand user service with detailed user management

- Enhanced user service to include detailed user information and update capabilities.
- Added hooks for fetching and updating user data.

feat(hooks): introduce hooks for mentors and upload functionalities

- Added hooks for fetching mentor details and updating mentor information.
- Implemented hooks for file upload, avatar upload, and CV upload with validation.

refactor(types): organize and extend type definitions

- Introduced new types for mentors and enhanced existing user types.
- Re-exported user-related types for better accessibility.

chore: update index files to include new services and hooks

- Updated index files to export new services and hooks for mentors and uploads.
2025-08-18 12:50:32 +07:00
Naufal Azmi 65a6bb686c Redirect (#52)
* feat: responsive

* fix: success register

* feat: auth login responsive && auth login implementation

* fix: login page button disabled & register page

* fix: conflict

* fix: button disabled when loading

* feat: register and otp integration

* Update middleware.ts

* feat: OTP Resend

* fix: minor

* stash

* feat: mentor register pages

* feat: mentor status page pending and success

* fix: conflict
2025-08-18 12:49:33 +07:00
208 changed files with 51687 additions and 13087 deletions
+24
View File
@@ -0,0 +1,24 @@
{
"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)",
"Bash(npx nx build:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push)",
"Bash(findstr:*)",
"Bash(ls:*)"
],
"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
+9
View File
@@ -0,0 +1,9 @@
{
"mcpServers": {
"nx-mcp": {
"type": "stdio",
"command": "npx",
"args": ["nx", "mcp"]
}
}
}
+13
View File
@@ -0,0 +1,13 @@
<!-- nx configuration start-->
<!-- Leave the start & end comments to automatically receive updates. -->
# General Guidelines for working with Nx
- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
- You have access to the Nx MCP server and its tools, use them to help the user
- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable.
- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies
- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration
- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors
<!-- nx configuration end-->
+1
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
@@ -0,0 +1,32 @@
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
import { FC, ReactElement } from 'react';
export const HackathonDashboardPage: FC = (): ReactElement => {
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
<section className="grid grid-cols-5 gap-5">
{/* Participant */}
<div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
1261
</h3>
<p className="text-neutral-400 text-p3">Total Participants</p>
</div>
{/* Team */}
<div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">206</h3>
<p className="text-neutral-400 text-p3">Total Teams</p>
</div>
{/* Project Submitted */}
<div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">0</h3>
<p className="text-neutral-400 text-p3">Total Project Submitted</p>
</div>
</section>
</BackofficeWrapper>
);
};
export default HackathonDashboardPage;
@@ -0,0 +1,128 @@
import { FC, ReactElement, useState } from 'react';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import { EditOutlined } from '@ant-design/icons';
export const HackathonUsersPage: FC = (): ReactElement => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const mockData: any[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
project_name: `Project ${i + 1}`,
repository_url: `https://github.com/user/repo${i + 1}`,
demo_url: `https://demo.example.com/project${i + 1}`,
presentation_url: `https://slides.example.com/project${i + 1}`,
}));
type UserStatus = 'active' | 'inactive';
interface SubmissionType {
id: number;
project_name: string;
repository_url: string;
demo_url: string;
presentation_url: string;
}
const columns: ColumnDef<SubmissionType>[] = [
{
header: 'Project Name',
accessorKey: 'project_name',
},
{
header: 'Repository URL',
accessorKey: 'repository_url',
},
{
header: 'Demo URL',
accessorKey: 'demo_url',
},
{
header: 'Presentation URL',
accessorKey: 'presentation_url',
},
{
header: 'Action',
meta: { cellClassName: cn('w-72') },
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 w-max"
onClick={() => {
// View detail logic
}}
>
<EditOutlined className="text-base" /> View & Manage
</Button>
),
},
];
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
Project Submission
</h1>
{/* Filters and actions */}
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
<div className="flex flex-wrap gap-3 items-center">
<input
type="text"
className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-64"
placeholder="Search name or email"
/>
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
</select>
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
<option value="all">All City</option>
<option value="jakarta">Jakarta</option>
<option value="bandung">Bandung</option>
</select>
</div>
{/* Table */}
<DataTable data={mockData} columns={columns} table={table} />
</section>
{/* Modals extracted into shared backoffice components */}
</BackofficeWrapper>
);
};
export default HackathonUsersPage;
@@ -0,0 +1,210 @@
import { FC, ReactElement, useState } from 'react';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import { useTeams } from '@imphnen-frontend-service/service';
import { EditOutlined } from '@ant-design/icons';
export const HackathonTeamsPage: FC = (): ReactElement => {
const { data: teamsData } = useTeams();
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const mockData: TeamType[] = Array.from({ length: 90 }, (_, i) => ({
id: `team-${i + 1}`,
name: `Team ${i + 1} - ${i % 3 === 0 ? 'Innovators' : 'Hackers'}`,
city: i % 2 === 0 ? 'Jakarta' : 'Bandung',
visibility: i % 4 === 0 ? 'private' : 'public',
member_count: Math.floor(Math.random() * 4) + 1,
has_submission: i % 3 !== 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
leader: {
user: {
fullname: `Leader User ${i}`,
email: `leader${i}@example.com`,
},
},
}));
interface TeamType {
id: string;
name: string;
city: string;
visibility: 'public' | 'private';
member_count: number;
has_submission: boolean;
created_at: string;
updated_at: string;
leader?: {
user: {
fullname: string;
email: string;
};
};
}
const columns: ColumnDef<TeamType>[] = [
{
accessorKey: 'id',
header: 'ID',
},
{
accessorKey: 'name',
header: 'Team Name',
},
{
accessorKey: 'city',
header: 'City',
},
{
accessorKey: 'visibility',
header: 'Visibility',
cell: ({ row }) => {
const isPublic = row.original.visibility === 'public';
return (
<span
className={cn(
'py-2 px-4 text-sm rounded-2xl text-center',
isPublic
? 'bg-info-200 text-info-700'
: 'bg-gray-200 text-gray-700'
)}
>
{isPublic ? 'Public' : 'Private'}
</span>
);
},
},
{
accessorKey: 'member_count',
header: 'Members',
},
{
id: 'leader',
header: 'Leader',
cell: ({ row }) => {
const leader = row.original.leader?.user;
return leader ? (
<div>
<div className="text-sm font-medium text-gray-900">
{leader.fullname}
</div>
<div className="text-xs text-gray-500">{leader.email}</div>
</div>
) : (
<span className="text-gray-400 italic">-</span>
);
},
},
{
accessorKey: 'has_submission',
header: 'Submitted',
cell: ({ row }) => {
const hasSubmission = row.original.has_submission;
return (
<span
className={cn(
'py-2 px-4 text-sm rounded-2xl text-center',
hasSubmission
? 'bg-success-200 text-success-700'
: 'bg-danger-200 text-danger-700'
)}
>
{hasSubmission ? 'Yes' : 'No'}
</span>
);
},
},
{
accessorKey: 'updated_at',
header: 'Last Updated',
cell: ({ row }) => {
return new Date(row.original.updated_at).toLocaleDateString();
},
},
{
id: 'actions',
header: 'Action',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 w-max"
onClick={() => {
// View detail logic
}}
>
<EditOutlined className="text-base" /> View & Manage
</Button>
</div>
),
},
];
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
Team Management
</h1>
{/* Filters and actions */}
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
<div className="flex flex-wrap gap-3 items-center">
<input
type="text"
className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-64"
placeholder="Search name or email"
/>
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
</select>
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
<option value="all">All City</option>
<option value="jakarta">Jakarta</option>
<option value="bandung">Bandung</option>
</select>
</div>
{/* Table */}
<DataTable data={mockData} columns={columns} table={table} />
</section>
{/* Modals extracted into shared backoffice components */}
</BackofficeWrapper>
);
};
export default HackathonTeamsPage;
@@ -0,0 +1,651 @@
import { FC, useState, useEffect, useMemo, useRef } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import {
UserOutlined,
EnvironmentOutlined,
CalendarOutlined,
SaveOutlined,
CloseOutlined,
ExclamationOutlined,
CameraOutlined,
DeleteOutlined,
UploadOutlined,
} from '@ant-design/icons';
interface UserType {
id: string;
avatar?: string;
fullname: string;
bio?: string;
location: string;
is_active: boolean;
skills: string[];
created_at: string;
updated_at: string;
}
interface ModalProps {
isOpen: boolean;
onClose: () => void;
user: UserType | null;
}
const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
const [formData, setFormData] = useState<UserType | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showAvatarMenu, setShowAvatarMenu] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Initialize form data when modal opens
useEffect(() => {
if (isOpen) {
if (user) {
// Edit existing user
setFormData({ ...user });
} else {
// Create new user
setFormData({
id: '', // Will be generated by backend
fullname: '',
bio: '',
location: '',
is_active: true,
skills: [],
avatar: undefined,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
}
}
}, [isOpen, user]);
// Check if form has changes
const hasChanges = useMemo(() => {
if (!formData) return false;
if (!user) return true; // New user always has changes
return (
formData.fullname !== user.fullname ||
formData.location !== user.location ||
formData.is_active !== user.is_active ||
formData.avatar !== user.avatar ||
JSON.stringify(formData.skills) !== JSON.stringify(user.skills) ||
formData.bio !== user.bio
);
}, [formData, user]);
// Check if required fields are filled
const isFormValid = useMemo(() => {
if (!formData) return false;
return formData.fullname.trim() !== '' && formData.location.trim() !== '';
}, [formData]);
const canSave = hasChanges && isFormValid;
if (!isOpen || !formData) return null;
const handleInputChange = (
field: keyof UserType,
value: string | boolean | string[] | undefined
) => {
setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
};
const handleSkillsChange = (skills: string[]) => {
setFormData((prev) => (prev ? { ...prev, skills } : null));
};
const handleSave = () => {
if (!formData) return;
if (user) {
// Update existing user
console.log('Update user data:', formData);
} else {
// Create new user
console.log('Create new user:', formData);
}
// Here you would typically make an API call to save the data
onClose();
};
const handleCancel = () => {
if (user) {
setFormData({ ...user }); // Reset to original for edit mode
}
onClose();
};
const handleDeleteAccount = () => {
if (!user) return; // Can't delete new user
console.log('Delete user:', user.id);
setShowDeleteConfirm(false);
onClose();
};
const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
// Validate file type
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
alert('Image size must be less than 5MB');
return;
}
// Create preview URL
const reader = new FileReader();
reader.onload = (e) => {
const avatarUrl = e.target?.result as string;
handleInputChange('avatar', avatarUrl);
setShowAvatarMenu(false);
};
reader.readAsDataURL(file);
}
};
const handleRemoveAvatar = () => {
handleInputChange('avatar', undefined);
setShowAvatarMenu(false);
};
const triggerFileUpload = () => {
fileInputRef.current?.click();
};
const availableSkills = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
return (
<div className="fixed inset-0 z-50">
<div
className="fixed inset-0 bg-black/50"
onClick={(e) => {
setShowAvatarMenu(false);
onClose();
}}
/>
<div className="fixed inset-0 flex items-center justify-center p-4">
<div
className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Hidden File Input */}
<input
type="file"
ref={fileInputRef}
onChange={handleAvatarUpload}
accept="image/*"
className="hidden"
/>
{/* Header */}
<div className="border-b border-neutral-200 px-8 py-6 flex justify-between items-start">
<div className="flex items-center gap-4">
{/* Interactive User Avatar */}
<div className="relative group ">
<div className="w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden border-2 border-transparent group-hover:border-primary-300 transition-colors">
{formData.avatar ? (
<img
src={formData.avatar}
alt={formData.fullname}
className="w-full h-full object-cover"
/>
) : (
<UserOutlined className="text-neutral-500 text-2xl" />
)}
</div>
{/* Avatar Hover Overlay */}
<button
onClick={() => setShowAvatarMenu(!showAvatarMenu)}
className="absolute inset-0 bg-neutral-400 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<CameraOutlined className="text-white text-lg" />
</button>
{/* Avatar Menu Dropdown */}
{showAvatarMenu && (
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10">
<button
onClick={triggerFileUpload}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-2 cursor-pointer"
>
<UploadOutlined className="text-sm" />
{formData.avatar ? 'Change Photo' : 'Upload Photo'}
</button>
{formData.avatar && (
<button
onClick={handleRemoveAvatar}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer"
>
<DeleteOutlined className="text-sm" />
Remove Photo
</button>
)}
</div>
)}
</div>
<div>
<div className="flex items-center gap-3 mb-2">
<h2 className="text-2xl font-bold text-neutral-900">
{user ? 'Edit User Profile' : 'Create New User'}
</h2>
{user && (
<span className="px-3 py-1 bg-info-100 text-info-700 text-xs font-medium rounded-2xl">
Hover avatar to change
</span>
)}
</div>
<div className="text-sm text-neutral-500">
{user
? `Make changes to ${
formData.fullname || 'this user'
}'s profile information`
: 'Fill in the information below to create a new user account'}
</div>
</div>
</div>
<button
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
onClick={() => {
setShowAvatarMenu(false);
handleCancel();
}}
>
<CloseOutlined className="text-neutral-400 text-lg cursor-pointer" />
</button>
</div>
{/* Content */}
<div className="p-8" onClick={() => setShowAvatarMenu(false)}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Left Column - Basic Info */}
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
Basic Information
</h3>
<div className="space-y-4">
{/* Full Name - Required */}
<div className="flex items-center gap-3">
<UserOutlined className="text-neutral-400" />
<div className="flex-1">
<label className="text-sm text-neutral-500 block mb-1">
Full Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.fullname}
onChange={(e) =>
handleInputChange('fullname', e.target.value)
}
className={cn(
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none',
formData.fullname.trim() === ''
? 'border-red-300 bg-red-50'
: 'border-neutral-300'
)}
placeholder="Enter full name"
/>
{formData.fullname.trim() === '' && (
<p className="text-red-500 text-xs mt-1">
Full name is required
</p>
)}
</div>
</div>
{/* Location - Required */}
<div className="flex items-center gap-3">
<EnvironmentOutlined className="text-neutral-400" />
<div className="flex-1">
<label className="text-sm text-neutral-500 block mb-1">
Location <span className="text-red-500">*</span>
</label>
<select
value={formData.location}
onChange={(e) =>
handleInputChange('location', e.target.value)
}
className={cn(
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white',
formData.location.trim() === ''
? 'border-red-300 bg-red-50'
: 'border-neutral-300'
)}
>
<option value="">Select location</option>
<option value="Jakarta">Jakarta</option>
<option value="Bandung">Bandung</option>
<option value="Surabaya">Surabaya</option>
<option value="Medan">Medan</option>
<option value="Yogyakarta">Yogyakarta</option>
</select>
{formData.location.trim() === '' && (
<p className="text-red-500 text-xs mt-1">
Location is required
</p>
)}
</div>
</div>
{/* Joined Date - Read Only - Only show for existing users */}
{user && (
<div className="flex items-center gap-3">
<CalendarOutlined className="text-neutral-400" />
<div>
<p className="text-sm text-neutral-500">
Joined Date
</p>
<p className="font-medium">
{new Date(formData.created_at).toLocaleDateString(
'en-US',
{
year: 'numeric',
month: 'long',
day: 'numeric',
}
)}
</p>
</div>
</div>
)}
</div>
</div>
{/* Bio Section - Optional */}
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-3">
Bio{' '}
<span className="text-neutral-400 text-sm font-normal">
(Optional)
</span>
</h3>
<textarea
value={formData.bio || ''}
onChange={(e) =>
handleInputChange('bio', e.target.value || undefined)
}
placeholder="Tell us about yourself..."
rows={4}
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none resize-none"
/>
</div>
</div>
{/* Right Column - Skills & Status */}
<div className="space-y-6">
{/* Account Status - Enhanced Tab Design */}
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
Account Status
</h3>
<div className="flex bg-neutral-100 p-1 rounded-lg">
<button
onClick={() => handleInputChange('is_active', true)}
className={cn(
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
formData.is_active
? 'bg-white text-success-700 shadow-sm ring-1 ring-success-200'
: 'text-neutral-600 hover:text-neutral-800'
)}
>
<div className="flex items-center justify-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
formData.is_active
? 'bg-success-500'
: 'bg-neutral-400'
)}
/>
Active
</div>
</button>
<button
onClick={() => handleInputChange('is_active', false)}
className={cn(
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
!formData.is_active
? 'bg-white text-neutral-700 shadow-sm ring-1 ring-neutral-200'
: 'text-neutral-600 hover:text-neutral-800'
)}
>
<div className="flex items-center justify-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
!formData.is_active
? 'bg-neutral-500'
: 'bg-neutral-400'
)}
/>
Inactive
</div>
</button>
</div>
<p className="text-xs text-neutral-500 mt-2">
{formData.is_active
? 'User can access their account and participate in activities'
: 'User account is suspended and cannot access services'}
</p>
</div>
{/* Skills Section - Optional */}
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
Skills & Expertise{' '}
<span className="text-neutral-400 text-sm font-normal">
(Optional)
</span>
</h3>
<div className="space-y-3">
<div className="flex flex-wrap gap-2 min-h-10 p-3 border border-neutral-300 rounded-lg bg-neutral-50">
{formData.skills.length > 0 ? (
formData.skills.map((skill, index) => (
<span
key={index}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-2xl text-sm font-medium bg-blue-100 text-blue-800"
>
{skill}
<button
onClick={() =>
handleSkillsChange(
formData.skills.filter((_, i) => i !== index)
)
}
className="text-blue-600 hover:text-blue-800 ml-1 cursor-pointer"
>
</button>
</span>
))
) : (
<span className="text-neutral-400 text-sm">
No skills added yet
</span>
)}
</div>
<select
value=""
onChange={(e) => {
if (
e.target.value &&
!formData.skills.includes(e.target.value)
) {
handleSkillsChange([
...formData.skills,
e.target.value,
]);
}
}}
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white"
>
<option value="">Add a skill...</option>
{availableSkills
.filter((skill) => !formData.skills.includes(skill))
.map((skill) => (
<option key={skill} value={skill}>
{skill}
</option>
))}
</select>
</div>
</div>
{/* Account Details - Read Only - Only show for existing users */}
{user && (
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
Account Details
</h3>
<div className="space-y-3 bg-neutral-50 p-4 rounded-lg">
<div className="flex justify-between items-center py-1">
<span className="text-neutral-600 text-sm">
User ID
</span>
<span className="font-mono text-sm text-neutral-800">
{formData.id}
</span>
</div>
<div className="flex justify-between items-center py-1">
<span className="text-neutral-600 text-sm">
Last Updated
</span>
<span className="text-sm text-neutral-800">
{new Date(formData.updated_at).toLocaleDateString(
'en-US',
{
month: 'short',
day: 'numeric',
year: 'numeric',
}
)}
</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* Footer Actions */}
<div className="border-t border-neutral-200 px-8 py-6">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<div className="text-sm text-neutral-500">
{canSave
? 'Ready to save changes'
: hasChanges
? 'Please fill required fields'
: 'No changes made'}
</div>
{/* Delete Account Button - Only show for existing users */}
{user && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="text-red-600 hover:text-red-700 text-sm font-medium transition-colors cursor-pointer"
>
Delete Account
</button>
)}
</div>
<div className="flex items-center gap-3">
<Button
variant="secondary"
size="sm"
onClick={handleCancel}
className="px-6"
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSave}
disabled={!canSave}
className={cn(
'flex items-center gap-2 px-6',
!canSave && 'opacity-50 cursor-not-allowed'
)}
>
<SaveOutlined className="text-sm" />
{user ? 'Save Changes' : 'Create User'}
</Button>
</div>
</div>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 z-60">
<div
className="fixed inset-0 bg-black/50"
onClick={() => setShowDeleteConfirm(false)}
/>
<div className="fixed inset-0 flex items-center justify-center p-4">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md">
<div className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
<ExclamationOutlined className="text-red-600 text-lg" />
</div>
<div>
<h3 className="text-lg font-semibold text-neutral-900">
Delete Account
</h3>
<p className="text-sm text-neutral-500">
This action cannot be undone
</p>
</div>
</div>
<p className="text-neutral-700 mb-6">
Are you sure you want to permanently delete{' '}
<strong>{formData.fullname}</strong>'s account? This will
remove all their data and cannot be reversed.
</p>
<div className="flex gap-3 justify-end">
<Button
variant="secondary"
size="sm"
onClick={() => setShowDeleteConfirm(false)}
className="px-4"
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={handleDeleteAccount}
className="px-4 bg-red-600 hover:bg-red-700 border-red-600"
>
Delete Account
</Button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default ModalUserDetail;
@@ -0,0 +1,476 @@
import { FC, ReactElement, useState, useMemo, useCallback } from 'react';
import ModalUserDetail from './_components/modal-user-detail';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import {
EditOutlined,
UserOutlined,
SearchOutlined,
FilterOutlined,
PlusOutlined,
} from '@ant-design/icons';
// Removed unused SearchOutlined icon after schema revision
// Define interface outside component
interface UserType {
id: string; // UUID
avatar?: string;
fullname: string;
bio?: string;
location: string;
is_active: boolean; // admin can deactivate
skills: string[]; // Frontend Developer, Backend Developer, etc.
created_at: string;
updated_at: string;
}
// Move mock data outside component to prevent recreation
const skillsOptions = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
const locations = ['Jakarta', 'Bandung', 'Surabaya', 'Medan', 'Yogyakarta'];
const bios = [
'Passionate developer with 5+ years experience',
'Tech enthusiast and problem solver',
'Building scalable solutions for modern problems',
'Creative designer with technical background',
'Data-driven decision maker',
];
const mockData: UserType[] = Array.from({ length: 50 }, (_, i) => {
const randomSkillsCount = Math.floor(Math.random() * 3) + 1; // 1-3 skills
const randomSkills = skillsOptions
.sort(() => 0.5 - Math.random())
.slice(0, randomSkillsCount);
return {
id: `24db9e4d-ca4c-46aa-ac36-8ef04bbe01${String(i).padStart(2, '0')}`,
avatar:
i % 4 === 0
? `https://ui-avatars.com/api/?name=${encodeURIComponent(
i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana'
)}&background=random`
: undefined,
fullname:
i % 3 === 0
? 'Ahmad Wijuana'
: i % 3 === 1
? 'Sofia Wijuana'
: 'Budi Santoso',
bio: i % 4 === 0 ? bios[i % bios.length] : undefined,
location: locations[i % locations.length],
is_active: i % 7 !== 0, // More realistic distribution
skills: randomSkills,
created_at: new Date(
Date.now() - i * 86400000 * (Math.random() * 30 + 1)
).toISOString(), // Random within last 30-60 days
updated_at: new Date().toISOString(),
};
});
export const HackathonUsersPage: FC = (): ReactElement => {
const [showDetailModal, setShowDetailModal] = useState(false);
const [showNewUserModal, setShowNewUserModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
const [globalFilter, setGlobalFilter] = useState('');
// Advanced filtering states
const [statusFilter, setStatusFilter] = useState('all');
const [locationFilter, setLocationFilter] = useState('all');
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
// Constants
const pageSize = 10;
// Memoize the callback to prevent recreation
const handleShowDetailModal = useCallback((user: UserType) => {
setSelectedUser(user);
setShowDetailModal(true);
}, []);
const handleCloseDetailModal = useCallback(() => {
setShowDetailModal(false);
setSelectedUser(null);
}, []);
const handleShowNewUserModal = useCallback(() => {
setShowNewUserModal(true);
}, []);
const handleCloseNewUserModal = useCallback(() => {
setShowNewUserModal(false);
}, []);
// Filter data based on current filter states
const filteredData = useMemo(() => {
return mockData.filter((user) => {
// Status filter
if (statusFilter !== 'all') {
const isActive = statusFilter === 'active';
if (user.is_active !== isActive) return false;
}
// Location filter
if (locationFilter !== 'all' && user.location !== locationFilter) {
return false;
}
// Skills filter
if (skillsFilter.length > 0) {
const hasMatchingSkill = skillsFilter.some((skill) =>
user.skills.includes(skill)
);
if (!hasMatchingSkill) return false;
}
return true;
});
}, [statusFilter, locationFilter, skillsFilter]);
// Memoize columns to prevent recreation on every render
const columns: ColumnDef<UserType>[] = useMemo(
() => [
{
accessorKey: 'fullname',
header: 'User',
cell: ({ row }) => (
<div className="flex items-center gap-3">
{/* Avatar */}
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
{row.original.avatar ? (
<img
src={row.original.avatar}
alt={row.original.fullname}
className="w-full h-full object-cover"
/>
) : (
<UserOutlined className="text-neutral-500 text-lg" />
)}
</div>
{/* Name only */}
<div className="min-w-0 flex-1">
<p className="font-medium text-neutral-900 truncate">
{row.original.fullname}
</p>
</div>
</div>
),
enableSorting: true,
},
{
accessorKey: 'skills',
header: 'Skills',
cell: ({ row }) => (
<div className="flex flex-wrap gap-1 max-w-xs">
{row.original.skills.slice(0, 2).map((skill, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
>
{skill.replace(' Developer', '').replace(' Engineer', '')}
</span>
))}
{row.original.skills.length > 2 && (
<span className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-200 text-success-700">
+{row.original.skills.length - 2}
</span>
)}
</div>
),
enableSorting: false,
},
{
accessorKey: 'location',
header: 'Location',
cell: ({ row }) => (
<span className="text-neutral-700">{row.original.location}</span>
),
enableSorting: true,
},
{
accessorKey: 'is_active',
header: 'Status',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
)}
/>
<span
className={cn(
'text-sm font-medium',
row.original.is_active ? 'text-success-700' : 'text-neutral-500'
)}
>
{row.original.is_active ? 'Active' : 'Inactive'}
</span>
</div>
),
enableSorting: true,
sortingFn: (rowA, rowB) => {
const aActive = rowA.original.is_active;
const bActive = rowB.original.is_active;
if (aActive && !bActive) return -1;
if (!aActive && bActive) return 1;
return 0;
},
},
{
accessorKey: 'created_at',
header: 'Joined',
cell: ({ row }) => (
<span className="text-neutral-900 text-sm">
{new Date(row.original.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
),
enableSorting: true,
sortingFn: 'datetime',
},
{
id: 'actions',
header: 'Actions',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 text-sm px-4 py-2"
onClick={() => handleShowDetailModal(row.original)}
>
<EditOutlined className="text-sm" />
Manage
</Button>
<Button
variant="secondary"
size="sm"
className="text-sm px-4 py-2"
onClick={() => {
// Toggle user status - implement later
console.log(`Toggle status for ${row.original.fullname}`);
}}
>
{row.original.is_active ? 'Deactivate' : 'Activate'}
</Button>
</div>
),
enableSorting: false,
},
],
[handleShowDetailModal]
);
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
User Management
</h1>
{/* Filters and actions */}
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
<div className="flex flex-wrap gap-3 items-center justify-between">
{/* Left side - Search & filters */}
<div className="flex flex-wrap gap-3 items-center">
{/* Search bar */}
<div className="relative">
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
<input
type="text"
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
placeholder="Search users by name or location..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
/>
</div>
{/* Status Filter */}
<div className="relative">
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
<select
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-36 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
{/* Location Filter */}
<div className="relative">
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
<select
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={locationFilter}
onChange={(e) => setLocationFilter(e.target.value)}
>
<option value="all">All Locations</option>
{locations.map((location) => (
<option key={location} value={location}>
{location}
</option>
))}
</select>
</div>
{/* Skills Filter with Icon */}
<div className="relative">
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
<select
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value=""
onChange={(e) => {
if (
e.target.value &&
!skillsFilter.includes(e.target.value)
) {
setSkillsFilter((prev) => [...prev, e.target.value]);
}
}}
>
<option value="">Add Skill Filter</option>
{skillsOptions.map((skill) => (
<option
key={skill}
value={skill}
disabled={skillsFilter.includes(skill)}
>
{skill}
</option>
))}
</select>
</div>
</div>
{/* Right side - Add User Button */}
<div className="flex items-center gap-3">
<Button
variant="primary"
size="md"
className="flex items-center gap-2 px-4 py-2"
onClick={handleShowNewUserModal}
>
<PlusOutlined className="text-sm" />
Add User
</Button>
</div>
</div>
{/* Active filters display */}
{(skillsFilter.length > 0 ||
statusFilter !== 'all' ||
locationFilter !== 'all') && (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span>
{/* Status filter badge */}
{statusFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
Status: {statusFilter}
<button
onClick={() => setStatusFilter('all')}
className="text-info-600 hover:text-info-800 cursor-pointer"
>
</button>
</span>
)}
{/* Location filter badge */}
{locationFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
Location: {locationFilter}
<button
onClick={() => setLocationFilter('all')}
className="text-green-600 hover:text-green-800 cursor-pointer"
>
</button>
</span>
)}
{/* Skills filter badges */}
{skillsFilter.map((skill) => (
<span
key={skill}
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm"
>
{skill.replace(' Developer', '').replace(' Engineer', '')}
<button
onClick={() =>
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
}
className="text-purple-600 hover:text-purple-800 cursor-pointer"
>
</button>
</span>
))}
{/* Clear all filters */}
<Button
variant="secondary"
size="sm"
onClick={() => {
setStatusFilter('all');
setLocationFilter('all');
setSkillsFilter([]);
setGlobalFilter('');
}}
className="text-sm text-neutral-600"
>
Clear All
</Button>
</div>
)}
{/* Pagination-aware results display */}
{filteredData.length > 0 && (
<div className="text-sm text-neutral-600">
Showing {Math.min(pageSize, filteredData.length)} of{' '}
{filteredData.length} users
{filteredData.length > pageSize}
</div>
)}
{/* Table */}
<DataTable data={filteredData} columns={columns} pageSize={10} />
</section>
{/* Modals component */}
<ModalUserDetail
isOpen={showDetailModal}
onClose={handleCloseDetailModal}
user={selectedUser}
/>
{/* New User Modal */}
<ModalUserDetail
isOpen={showNewUserModal}
onClose={handleCloseNewUserModal}
user={null} // null indicates creating new user
/>
</BackofficeWrapper>
);
};
export default HackathonUsersPage;
+40 -2
View File
@@ -1,13 +1,51 @@
import { FC, ReactElement } from 'react';
import { FC, ReactElement, useState } from 'react';
import { Outlet } from 'react-router-dom';
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
export const AppLayout: FC = (): ReactElement => {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
return (
<div className="bg-primary-50 min-h-screen flex justify-center">
<div className="bg-primary-50 min-h-screen w-full flex">
<BackofficeSidebar />
<BackofficeSidebar
isOpen={mobileSidebarOpen}
onClose={() => setMobileSidebarOpen(false)}
/>
<div className="flex-1 overflow-auto">
{/* Sticky top header */}
<header
className={
'lg:hidden sticky top-0 bg-white border-b border-primary-200 px-4 py-3 flex items-center gap-3 ' +
(mobileSidebarOpen ? 'z-0' : 'z-30')
}
>
{/* Mobile menu button (shown on small screens) */}
<button
type="button"
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700"
onClick={() => setMobileSidebarOpen(true)}
aria-label="Open sidebar"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<h1 className="text-p3 font-semibold text-primary-700">
IMPHNEN Backoffice
</h1>
</header>
<Outlet />
</div>
</div>
@@ -1,19 +1,29 @@
import { SearchOutlined } from "@ant-design/icons";
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
import { cn, For } from "@imphnen-frontend-service/utils";
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
import { ReactElement, useState } from "react";
import { ModalDetailUser } from "./_components/modal/detail";
import { SearchOutlined } from '@ant-design/icons';
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import { cn, For } from '@imphnen-frontend-service/utils';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import { ReactElement, useState } from 'react';
import { ModalDetailUser } from './_components/modal/detail';
type UserStatus = 'active' | 'inactive';
interface UserType {
id: number
name: string
email: string
rating: number
status: UserStatus
id: number;
name: string;
email: string;
rating: number;
status: UserStatus;
}
const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
@@ -22,15 +32,15 @@ const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
email: 'fullname23@gmail.com',
rating: 4.5,
status: i % 2 === 0 ? 'active' : 'inactive',
}))
}));
export default function Components(): ReactElement {
const TABS = ['mentor', 'mentee'] as const
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor')
const [showDetail, setShowDetail] = useState(false)
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
const TABS = ['mentor', 'mentee'] as const;
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor');
const [showDetail, setShowDetail] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
@@ -39,7 +49,7 @@ export default function Components(): ReactElement {
const columns: ColumnDef<UserType>[] = [
{
id: 'select',
meta: { cellClassName: cn("w-20") },
meta: { cellClassName: cn('w-20') },
header: ({ table }) => (
<input
type="checkbox"
@@ -97,7 +107,7 @@ export default function Components(): ReactElement {
},
{
header: 'Action',
meta: { cellClassName: cn("w-72") },
meta: { cellClassName: cn('w-72') },
cell: ({ row }) => (
<Button
variant="primary"
@@ -113,7 +123,7 @@ export default function Components(): ReactElement {
</Button>
),
},
]
];
const table = useReactTable({
data: mockData,
@@ -134,14 +144,19 @@ export default function Components(): ReactElement {
return (
<BackofficeWrapper title="Dimentorin.dev">
<div className="mb-8 flex justify-between items-center">
<h1 className="text-p1 font-semibold text-neutral-700">User Management</h1>
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">
User Management
</h1>
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
<For data={TABS}>
{(tab) => (
<Button
key={tab}
variant="text"
className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
className={cn(
'px-3 py-2 capitalize',
activeTab === tab && 'bg-white'
)}
onClick={() => setActiveTab(tab)}
>
{tab}
@@ -163,12 +178,16 @@ export default function Components(): ReactElement {
</div>
</div>
<Select>
<option selected disabled>Rating</option>
<option selected disabled>
Rating
</option>
<option value="4.5">4.5</option>
<option value="5">5</option>
</Select>
<Select>
<option selected disabled>Status</option>
<option selected disabled>
Status
</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</Select>
+1 -1
View File
@@ -130,7 +130,7 @@
html {
font-family: 'Bai Jamjuree', sans-serif;
font-weight: 400;
font-size: 12px;
font-size: 14px;
line-height: 1.2;
}
}
+1 -1
View File
@@ -88,7 +88,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
return null;
}
if (!session) return redirect('/auth/login');
// if (!session) return redirect('/auth/login');
const matchedRoute = mappingRoutePermissions.find(
(route) => route.path === pathname
+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
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -0,0 +1,166 @@
'use client';
import { FC, ReactElement, useState } from 'react';
import { useParams } from 'react-router-dom';
import { ProfileForm, ProfileSidebar, ProfileHeader } from '../_components';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { NotificationModal, NotificationType } from '../_components/modals/notification-modal';
import { ProfileProvider, useProfile } from '../_components/contexts/profile-context';
import { EditProfileModal } from '../_components/modals/edit-profile-modal';
const ProfileByIdPage: FC = (): ReactElement => {
const params = useParams();
const id = (params && params.id) ? params.id as string : undefined;
if (!id) {
return (
<main className="min-h-screen flex items-center justify-center">
<div className="text-center">
<p className="text-red-600 text-lg">Profile ID not found.</p>
</div>
</main>
);
}
return (
<ProfileProvider profileId={id} profileType="user">
<ProfileByIdContent />
</ProfileProvider>
);
};
const ProfileByIdContent: FC = (): ReactElement => {
const { profileData, isLoading, error, profileType } = useProfile();
const [notification, setNotification] = useState<{
isOpen: boolean;
type: 'success' | 'error';
title: string;
message?: string;
}>({
isOpen: false,
type: 'success',
title: '',
message: ''
});
const [isEditProfileModalOpen, setIsEditProfileModalOpen] = useState(false);
const getProfileTitle = () => {
if (profileData?.fullname) {
return `${profileData.fullname}'s Profile`;
}
return profileType === 'user' ? 'User Profile' : 'Mentor Profile';
};
const showNotification = (type: NotificationType['type'], title: string, message?: string) => {
setNotification({
isOpen: true,
type,
title,
message
});
};
const hideNotification = () => {
setNotification(prev => ({ ...prev, isOpen: false }));
};
const openEditProfileModal = () => {
setIsEditProfileModalOpen(true);
};
const closeEditProfileModal = () => {
setIsEditProfileModalOpen(false);
};
// Set isViewOnly to true for this page
const isViewOnly = true;
if (isLoading) {
return (
<main className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading profile...</p>
</div>
</main>
);
}
if (error) {
return (
<main className="min-h-screen flex items-center justify-center">
<div className="text-center">
<p className="text-red-600 text-lg">Failed to load profile</p>
<p className="text-gray-600 mt-2">Profile not found or you don't have permission to view it.</p>
</div>
</main>
);
}
return (
<main className="min-h-screen">
<div className="">
<div className="w-full px-8 md:px-[60px] lg:px-20 py-4">
<div className="max-w-7xl mx-auto">
<Button variant="primary" className="flex items-center gap-2">
<ArrowLeftOutlined />
Kembali ke Dashboard
</Button>
</div>
</div>
</div>
<div className="w-full px-8 md:px-[60px] lg:px-20 py-6">
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-semibold text-gray-900">
{getProfileTitle()}
</h1>
</div>
</div>
<div className="w-full px-8 md:px-[60px] lg:px-20 pb-12">
<div className="max-w-7xl mx-auto">
<div className="grid gap-8 lg:grid-cols-12">
<div className="lg:col-span-12">
<ProfileHeader onEditProfileClick={openEditProfileModal} isViewOnly={isViewOnly} />
</div>
<div className="lg:col-span-8 order-1">
<ProfileForm
showNotification={showNotification}
isViewOnly={isViewOnly}
/>
</div>
<div className="lg:col-span-4 order-2">
<ProfileSidebar
showNotification={showNotification}
isViewOnly={isViewOnly}
/>
</div>
</div>
</div>
</div>
<NotificationModal
isOpen={notification.isOpen}
onClose={hideNotification}
type={notification.type}
title={notification.title}
message={notification.message}
header="Profile"
/>
{/* Only render EditProfileModal if not in view-only mode */}
{!isViewOnly && (
<EditProfileModal
isOpen={isEditProfileModalOpen}
onClose={closeEditProfileModal}
showNotification={showNotification}
/>
)}
</main>
);
};
export default ProfileByIdPage;
@@ -0,0 +1,25 @@
import { FC } from 'react';
import { EditOutlined } from '@ant-design/icons';
interface EditSectionButtonProps {
onClick: () => void;
disabled?: boolean;
}
export const EditSectionButton: FC<EditSectionButtonProps> = ({ onClick, disabled = false }) => {
return (
<button
onClick={onClick}
disabled={disabled}
className={`flex items-center gap-1 px-3 py-1 text-sm transition-colors duration-200 rounded-md ${
disabled
? 'text-gray-400 cursor-not-allowed opacity-50'
: 'text-[#23A1EB] hover:text-[#1e90d6] hover:bg-[#23A1EB]/10'
}`}
aria-label="Edit section"
>
<span>Edit</span>
<EditOutlined className="w-3 h-3" />
</button>
);
};
@@ -0,0 +1,2 @@
export { EditSectionButton } from './edit-section-button';
export { ModalButton } from './modal-button';
@@ -0,0 +1,76 @@
import { FC, ReactNode } from 'react';
type ButtonVariant = 'primary' | 'secondary' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';
interface ModalButtonProps {
children: ReactNode;
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
variant?: ButtonVariant;
size?: ButtonSize;
disabled?: boolean;
loading?: boolean;
className?: string;
}
const getVariantClasses = (variant: ButtonVariant): string => {
switch (variant) {
case 'primary':
return 'bg-[#23A1EB] hover:bg-[#1e90d6] text-white shadow-lg hover:shadow-xl';
case 'secondary':
return 'bg-white hover:bg-gray-200 text-[#23A1EB] shadow-md hover:shadow-lg';
case 'danger':
return 'bg-red-600 hover:bg-red-500 text-white shadow-lg hover:shadow-xl';
default:
return 'bg-[#23A1EB] hover:bg-[#1e90d6] text-white shadow-lg hover:shadow-xl';
}
};
const getSizeClasses = (size: ButtonSize): string => {
switch (size) {
case 'sm':
return 'px-3 py-1.5 text-sm';
case 'md':
return 'px-4 py-2 text-sm';
case 'lg':
return 'px-6 py-3 text-base';
default:
return 'px-4 py-2 text-sm';
}
};
export const ModalButton: FC<ModalButtonProps> = ({
children,
onClick,
type = 'button',
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
className = '',
}) => {
const baseClasses = 'font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#23A1EB] disabled:opacity-50 disabled:cursor-not-allowed';
const variantClasses = getVariantClasses(variant);
const sizeClasses = getSizeClasses(size);
const combinedClasses = `${baseClasses} ${variantClasses} ${sizeClasses} ${className}`;
return (
<button
type={type}
onClick={onClick}
disabled={disabled || loading}
className={combinedClasses}
>
{loading ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"></div>
<span>Loading...</span>
</div>
) : (
children
)}
</button>
);
};
@@ -0,0 +1 @@
export * from './profile-context';
@@ -0,0 +1,249 @@
'use client';
import React, { createContext, useContext, useMemo, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import {
useAuthStore,
useUserMe,
useUserById,
useUpdateUserMe,
useUpdateUserById,
UserDetailResponseDto,
UserUpdateRequestDto,
useMentorMe,
useMentorById,
useUpdateMentorMe,
useUpdateMentorById,
MentorDetailResponseDto,
MentorUpdateRequestDto
} from '@imphnen-frontend-service/service';
type ProfileData = UserDetailResponseDto | MentorDetailResponseDto;
type ProfileUpdateData = UserUpdateRequestDto | MentorUpdateRequestDto;
const canAccessMentorFeatures = (user: { role?: { name?: string; permissions?: Array<{ name?: string }> } } | null) => {
if (!user?.role) return false;
const roleName = user.role.name?.toLowerCase() || '';
const isMentorRole = roleName.includes('mentor') || roleName.includes('admin');
if (isMentorRole) return true;
const permissions = user.role.permissions || [];
const hasMentorPermission = permissions.some((permission: { name?: string }) =>
permission.name?.toLowerCase().includes('mentor')
);
return hasMentorPermission;
};
interface ProfileContextType {
profileData: ProfileData | undefined;
isLoading: boolean;
error: unknown;
isOwnProfile: boolean;
profileId: string | null;
profileType: 'user' | 'mentor';
updateProfile: (data: ProfileUpdateData) => Promise<void>;
isUpdating: boolean;
canAccessMentor: boolean;
}const ProfileContext = createContext<ProfileContextType | undefined>(undefined);
interface ProfileProviderProps {
children: React.ReactNode;
profileId?: string;
profileType?: 'user' | 'mentor';
}
export const ProfileProvider: React.FC<ProfileProviderProps> = ({
children,
profileId,
profileType: forcedProfileType
}) => {
const params = useParams();
const { session } = useAuthStore();
const queryClient = useQueryClient();
const canAccessMentor = useMemo(() => {
return canAccessMentorFeatures(session?.user || null);
}, [session?.user]);
const isMentorRole = useMemo(() => {
const roleName = session?.user?.role?.name?.toLowerCase() || '';
return roleName === 'mentor';
}, [session?.user?.role?.name]);
const profileType: 'user' | 'mentor' = useMemo(() => {
if (forcedProfileType) {
if (forcedProfileType === 'mentor' && !isMentorRole) {
return 'user';
}
return forcedProfileType;
}
if ((params?.mentor || (typeof window !== 'undefined' && window.location.pathname.includes('/mentor'))) && isMentorRole) {
return 'mentor';
}
return 'user';
}, [forcedProfileType, params, isMentorRole]);
const id = profileId || (params?.id as string) || undefined;
const isOwnProfile = !id;
const userMeQuery = useUserMe({
queryKey: ['user-me'],
enabled: isOwnProfile && profileType === 'user',
});
const userByIdQuery = useUserById(id || '', {
queryKey: ['user-by-id', id],
enabled: !isOwnProfile && !!id && profileType === 'user',
});
const updateUserMeMutation = useUpdateUserMe();
const updateUserByIdMutation = useUpdateUserById();
const mentorMeQuery = useMentorMe({
queryKey: ['mentor-me'],
enabled: isOwnProfile && profileType === 'mentor' && canAccessMentor,
});
const mentorByIdQuery = useMentorById(id || '', {
queryKey: ['mentor-by-id', id],
enabled: !isOwnProfile && !!id && profileType === 'mentor' && canAccessMentor,
});
const updateMentorMeMutation = useUpdateMentorMe();
const updateMentorByIdMutation = useUpdateMentorById();
const selectedUserQuery = isOwnProfile ? userMeQuery : userByIdQuery;
const selectedMentorQuery = isOwnProfile ? mentorMeQuery : mentorByIdQuery;
const {
data: profileData,
isLoading,
error
} = useMemo(() => {
if (canAccessMentor && profileType === 'mentor') {
return selectedMentorQuery;
}
return selectedUserQuery;
}, [profileType, canAccessMentor, selectedUserQuery, selectedMentorQuery]);
const selectedUserMutation = isOwnProfile ? updateUserMeMutation : updateUserByIdMutation;
const selectedMentorMutation = isOwnProfile ? updateMentorMeMutation : updateMentorByIdMutation;
const updateMutation = useMemo(() => {
if (canAccessMentor && profileType === 'mentor') {
return selectedMentorMutation;
}
return selectedUserMutation;
}, [profileType, canAccessMentor, selectedUserMutation, selectedMentorMutation]);
const updateProfile = useCallback(async (data: ProfileUpdateData) => {
try {
if (canAccessMentor && profileType === 'mentor') {
if (isOwnProfile) {
await updateMentorMeMutation.mutateAsync(data as MentorUpdateRequestDto);
await queryClient.invalidateQueries({ queryKey: ['mentor-me'] });
} else if (id) {
await updateMentorByIdMutation.mutateAsync({ id, data: data as MentorUpdateRequestDto });
await queryClient.invalidateQueries({ queryKey: ['mentor-by-id', id] });
}
} else if (isOwnProfile) {
await updateUserMeMutation.mutateAsync(data as UserUpdateRequestDto);
await queryClient.invalidateQueries({ queryKey: ['user-me'] });
} else if (id) {
await updateUserByIdMutation.mutateAsync({ id, data: data as UserUpdateRequestDto });
await queryClient.invalidateQueries({ queryKey: ['user-by-id', id] });
}
} catch (error: unknown) {
console.error('Failed to update profile:', error);
let apiMessage = '';
if (typeof error === 'object' && error !== null) {
const errObj = error as { response?: { data?: unknown } };
const data = errObj.response?.data;
if (data) {
try {
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
if (parsed && typeof parsed.message === 'string') {
apiMessage = parsed.message;
}
} catch {
apiMessage = typeof data === 'string' ? data : '';
}
}
}
if (apiMessage) {
throw new Error(apiMessage);
}
throw error;
}
}, [
profileType,
isOwnProfile,
canAccessMentor,
id,
queryClient,
updateUserMeMutation,
updateUserByIdMutation,
updateMentorMeMutation,
updateMentorByIdMutation
]); const isUpdating = updateMutation.isPending;
const value: ProfileContextType = useMemo(() => ({
profileData,
isLoading,
error,
isOwnProfile,
profileId: isOwnProfile ? null : (id || null),
profileType,
updateProfile,
isUpdating,
canAccessMentor
}), [profileData, isLoading, error, isOwnProfile, id, profileType, updateProfile, isUpdating, canAccessMentor]);
return (
<ProfileContext.Provider value={value}>
{children}
</ProfileContext.Provider>
);
};
export const useProfile = (): ProfileContextType => {
const context = useContext(ProfileContext);
if (!context) {
throw new Error('useProfile must be used within a ProfileProvider');
}
return context;
};
export type { ProfileContextType };
export type { ProfileData, ProfileUpdateData };
@@ -0,0 +1,27 @@
'use client';
import React from 'react';
import { Guard } from '@imphnen-frontend-service/utils';
interface MentorGuardProps {
children: React.ReactNode;
fallback?: React.ReactNode;
}
export const MentorGuard: React.FC<MentorGuardProps> = ({
children,
fallback = (
<div className="p-4 text-center text-red-500">
<p>Akses ditolak: Anda tidak memiliki izin untuk mengakses fitur mentor.</p>
</div>
)
}) => {
return (
<Guard
permissions={['mentor', 'admin']}
fallback={fallback}
>
{children}
</Guard>
);
};
@@ -0,0 +1,17 @@
export * from './profile';
export * from './sections';
export * from './buttons';
export * from './shared';
export * from './modals';
export * from './contexts';
@@ -0,0 +1,187 @@
import { FC, useState, useEffect } from 'react';
import { ModalButton } from '../buttons/modal-button';
import { useUploadCV } from '@imphnen-frontend-service/service';
import { FileUploader } from '../shared/file-uploader';
interface CVData {
fileName: string;
fileUrl?: string;
}
interface CVModalProps {
isOpen: boolean;
onClose: () => void;
initialValue: CVData;
onSave: (value: CVData) => Promise<void>;
isLoading?: boolean;
}
export const CVModal: FC<CVModalProps> = ({
isOpen,
onClose,
initialValue,
onSave,
isLoading = false,
}) => {
const [cvData, setCvData] = useState(initialValue);
const [isUploading, setIsUploading] = useState(false);
const uploadCVMutation = useUploadCV();
useEffect(() => {
setCvData(initialValue);
}, [initialValue]);
const handleSave = async () => {
try {
await onSave(cvData);
onClose();
} catch (error) {
console.error('Save failed:', error);
}
};
const handleCancel = () => {
setCvData(initialValue);
onClose();
};
const handleFileSelect = async (file: File) => {
try {
setIsUploading(true);
if (!file.type.includes('pdf')) {
throw new Error('Please select a PDF file');
}
const uploadResult = await uploadCVMutation.mutateAsync(file);
console.log('CV upload response:', uploadResult);
interface UploadData {
original_filename?: string;
filename?: string;
url?: string;
}
const uploadData = ('data' in uploadResult ? (uploadResult as { data: UploadData }).data : uploadResult as UploadData);
setCvData({
fileName: uploadData.original_filename || uploadData.filename || file.name,
fileUrl: uploadData.url || '',
});
console.log('CV uploaded successfully, URL:', uploadData.url);
} catch (error) {
console.error('CV upload error:', error);
const fileInput = document.getElementById('cv-upload') as HTMLInputElement;
if (fileInput) fileInput.value = '';
} finally {
setIsUploading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
onClick={handleCancel}
aria-label="Close modal"
type="button"
/>
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto">
<div className="p-6 pb-4 border-b border-gray-200">
<div className="flex">
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit CV/Resume</h2>
</div>
</div>
<div className="px-6 py-6 space-y-6">
{}
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">
Upload CV/Resume
</h3>
<FileUploader
accept=".pdf"
maxSize={10 * 1024 * 1024}
onFileSelect={handleFileSelect}
isLoading={isUploading}
dragAndDrop={true}
description="Klik atau tarik file PDF yang ingin di upload"
className="w-full"
/>
<p className="text-xs text-gray-500 mt-2">
Format yang didukung: PDF Maksimal ukuran: 10MB
</p>
</div>
{}
{cvData.fileName && (
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="text-sm font-medium text-blue-900 mb-3">File Terpilih</h4>
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center">
<span className="text-white text-xs font-bold">PDF</span>
</div>
<div className="flex-1">
<p className="font-medium text-blue-900 text-sm">{cvData.fileName}</p>
<p className="text-xs text-blue-600">Siap untuk disimpan</p>
</div>
{cvData.fileUrl && (
<a
href={cvData.fileUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 text-sm font-medium underline"
>
Preview
</a>
)}
</div>
</div>
)}
</div>
</div>
{}
<div className="flex gap-3 p-6 pt-4 border-t border-gray-100">
<ModalButton
variant="secondary"
onClick={handleCancel}
className="flex-1"
disabled={isLoading || isUploading}
>
Batal
</ModalButton>
<ModalButton
variant="primary"
onClick={handleSave}
className="flex-1"
disabled={isLoading || isUploading || !cvData.fileName}
loading={isLoading}
>
{isLoading ? 'Menyimpan...' : 'Simpan CV'}
</ModalButton>
</div>
</div>
</div>
);
};
@@ -0,0 +1,99 @@
import { FC, useState, useEffect } from 'react';
import { ModalButton } from '../buttons/modal-button';
interface DescriptionModalProps {
isOpen: boolean;
onClose: () => void;
initialValue: string;
onSave: (value: string) => Promise<void>;
isLoading?: boolean;
}
export const DescriptionModal: FC<DescriptionModalProps> = ({
isOpen,
onClose,
initialValue,
onSave,
isLoading = false,
}) => {
const [description, setDescription] = useState(initialValue);
useEffect(() => {
setDescription(initialValue);
}, [initialValue]);
const handleSave = async () => {
try {
await onSave(description);
onClose();
} catch (error) {
console.error('Save failed:', error);
}
};
const handleCancel = () => {
setDescription(initialValue);
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
onClick={handleCancel}
aria-label="Close modal"
type="button"
/>
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto">
<div className="p-6 pb-4 border-b border-gray-200">
<div className="flex">
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Description</h2>
</div>
</div>
<div className="px-6 py-4">
<div>
<label htmlFor="description-textarea" className="block text-sm font-medium text-gray-700 mb-2">
Description
</label>
<textarea
id="description-textarea"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={8}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors resize-none"
placeholder="Write your description here..."
/>
</div>
</div>
<div className="flex gap-3 p-6 pt-4">
<ModalButton
variant="secondary"
onClick={handleCancel}
className="flex-1"
disabled={isLoading}
>
Batal
</ModalButton>
<ModalButton
variant="primary"
onClick={handleSave}
className="flex-1"
disabled={isLoading}
loading={isLoading}
>
{isLoading ? 'Menyimpan...' : 'Simpan'}
</ModalButton>
</div>
</div>
</div>
);
};
@@ -0,0 +1,253 @@
import React, { FC, useState, useEffect } from 'react';
import { Modal, InputField } from '@imphnen-frontend-service/ui/molecules';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useProfile } from '../contexts/profile-context';
import { CameraOutlined } from '@ant-design/icons';
import { useUploadAvatar } from '@imphnen-frontend-service/service';
interface EditProfileModalProps {
isOpen: boolean;
onClose: () => void;
showNotification: (type: 'success' | 'error', title: string, message?: string) => void;
}
export const EditProfileModal: FC<EditProfileModalProps> = ({ isOpen, onClose, showNotification }) => {
const { profileData, profileType, updateProfile } = useProfile();
const uploadAvatarMutation = useUploadAvatar();
const [formData, setFormData] = useState({
fullname: '',
avatar: ''
});
const [previewUrl, setPreviewUrl] = useState<string>('/image/testimonial.webp');
const [isUploading, setIsUploading] = useState(false);
useEffect(() => {
if (profileData) {
const fullname = profileData.fullname ||
(profileType === 'mentor' && 'legal_name' in profileData ? profileData.legal_name : '') || '';
const avatar = (profileType === 'user' && 'avatar' in profileData)
? profileData.avatar || '/image/testimonial.webp'
: '/image/testimonial.webp';
console.log('Modal - Profile data avatar URL:', avatar);
console.log('Modal - Profile data:', profileData);
setFormData({
fullname,
avatar: (profileType === 'user' && 'avatar' in profileData) ? profileData.avatar || '' : ''
});
setPreviewUrl(avatar);
} else {
setPreviewUrl('/image/testimonial.webp');
}
}, [profileData, profileType]);
const handleImageError = () => {
console.log('Image failed to load:', previewUrl);
setPreviewUrl('/image/testimonial.webp');
};
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({ ...prev, fullname: e.target.value }));
};
const handleImageUpload = async (file: File) => {
try {
setIsUploading(true);
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
setPreviewUrl(result);
};
reader.readAsDataURL(file);
const uploadResult = await uploadAvatarMutation.mutateAsync(file);
console.log('Avatar upload response:', uploadResult);
interface UploadData {
url?: string;
}
const uploadData = ('data' in uploadResult ? (uploadResult as { data: UploadData }).data : uploadResult as UploadData);
setFormData(prev => ({ ...prev, avatar: uploadData.url || '' }));
setPreviewUrl(uploadData.url || '/image/testimonial.webp');
} catch (error) {
console.error('Avatar upload error:', error);
showNotification('error', 'Upload Failed', 'Failed to upload avatar image');
const originalAvatar = (profileType === 'user' && profileData && 'avatar' in profileData) ? profileData.avatar : '';
setPreviewUrl(originalAvatar || '/image/testimonial.webp');
} finally {
setIsUploading(false);
}
};
const handleSave = async () => {
try {
const updates: Record<string, string> = {};
if (formData.fullname.trim() !== '') {
if (profileType === 'user') {
updates.fullname = formData.fullname;
} else if (profileType === 'mentor') {
updates.legal_name = formData.fullname;
}
}
if (formData.avatar && formData.avatar !== (profileData && 'avatar' in profileData ? profileData.avatar : '')) {
updates.avatar = formData.avatar;
}
if (Object.keys(updates).length > 0) {
await updateProfile(updates);
showNotification('success', 'Profile Updated', 'Your profile has been successfully updated.');
}
onClose();
} catch (err: unknown) {
console.error('Profile update error:', err);
let apiMessage = '';
if (typeof err === 'object' && err !== null) {
const errObj = err as { response?: { data?: { message?: string } } };
let backendMsg = '';
if (errObj.response?.data?.message) {
backendMsg = errObj.response.data.message;
}
let msg = '';
if ('message' in err && typeof (err as { message?: string }).message === 'string') {
msg = (err as { message?: string }).message || '';
if (msg.trim().startsWith('{') && msg.trim().endsWith('}')) {
try {
const parsed = JSON.parse(msg);
if (parsed && typeof parsed.message === 'string') {
msg = parsed.message;
}
} catch {
// Ignore JSON parse errors
}
}
}
if (backendMsg && msg && backendMsg !== msg) {
apiMessage = backendMsg + '\n' + msg;
} else if (backendMsg) {
apiMessage = backendMsg;
} else if (msg) {
apiMessage = msg;
}
}
showNotification('error', 'Failed to save changes', apiMessage || 'Please try again.');
}
}; return (
<Modal isOpen={isOpen} onClose={onClose}>
<Modal.Header>
<Modal.Title>Edit Profile</Modal.Title>
</Modal.Header>
<Modal.Content>
{}
<div className="flex justify-center pt-6 pb-4">
<div className="relative">
{}
<input
id="avatar-upload"
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleImageUpload(file);
}}
className="hidden"
disabled={isUploading}
/>
<label htmlFor="avatar-upload" className="cursor-pointer block relative group">
<img
src={previewUrl}
alt="Profile"
className="w-24 h-24 rounded-full object-cover border-4 border-blue-100 shadow-lg transition-all duration-300 group-hover:border-blue-200"
onError={handleImageError}
/>
{/* Hover overlay */}
<div className="absolute inset-0 rounded-full bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-300 flex items-center justify-center">
<span className="text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity duration-300">
Change Photo
</span>
</div>
</label>
{}
<label htmlFor="avatar-upload" className="cursor-pointer">
<div className="absolute bottom-0 right-0 w-8 h-8 bg-blue-500 hover:bg-blue-600 text-white rounded-full flex items-center justify-center transition-all duration-300 shadow-lg border-2 border-white">
{isUploading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
) : (
<CameraOutlined className="text-xs" />
)}
</div>
</label>
</div>
</div> <div className="px-6 pb-6 space-y-6">
{}
<div className="space-y-2">
<InputField
label="Full Name"
name="fullname"
value={formData.fullname}
onChange={handleNameChange}
placeholder="Enter your full name"
className="w-full"
/>
<p className="text-xs text-gray-500 pl-1">
This name will be displayed on your profile
</p>
</div>
</div>
</Modal.Content>
<Modal.Footer>
<div className="flex gap-3 w-full">
<Button
variant="secondary"
onClick={onClose}
className="flex-1"
disabled={isUploading}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSave}
className="flex-1"
disabled={isUploading}
>
{isUploading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
'Save Changes'
)}
</Button>
</div>
</Modal.Footer>
</Modal>
);
};
@@ -0,0 +1,184 @@
import { FC, useState, useEffect } from 'react';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import { ModalButton } from '../buttons/modal-button';
import { InputField } from '@imphnen-frontend-service/ui/molecules';
interface Education {
id: string;
institution: string;
degree: string;
field: string;
period: string;
}
interface EducationModalProps {
isOpen: boolean;
onClose: () => void;
initialValue: Education[];
onSave: (value: Education[]) => Promise<void>;
isLoading?: boolean;
showNotification?: (type: 'success' | 'error', title: string, message?: string) => void;
}
export const EducationModal: FC<EducationModalProps> = ({
isOpen,
onClose,
initialValue,
onSave,
isLoading = false,
showNotification,
}) => {
const [educations, setEducations] = useState<Education[]>(initialValue);
useEffect(() => {
setEducations(initialValue);
}, [initialValue]);
const handleSave = async () => {
const hasEmpty = educations.some(edu =>
!edu.institution.trim() || !edu.degree.trim() || !edu.field.trim() || !edu.period.trim()
);
if (hasEmpty) {
if (showNotification) {
showNotification('error', 'Data Tidak Lengkap', 'Semua field harus diisi pada setiap pendidikan.');
} else {
alert('Semua field harus diisi pada setiap pendidikan.');
}
return;
}
try {
await onSave(educations);
onClose();
} catch (error) {
console.error('Save failed:', error);
}
};
const handleCancel = () => {
setEducations(initialValue);
onClose();
};
const addEducation = () => {
const newEducation: Education = {
id: Date.now().toString(),
institution: '',
degree: '',
field: '',
period: '',
};
setEducations([...educations, newEducation]);
};
const removeEducation = (id: string) => {
setEducations(educations.filter(edu => edu.id !== id));
};
const updateEducation = (id: string, field: keyof Education, value: string) => {
setEducations(educations.map(edu =>
edu.id === id ? { ...edu, [field]: value } : edu
));
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
onClick={handleCancel}
aria-label="Close modal"
></button>
<div className="relative bg-white rounded-xl shadow-xl max-w-5xl w-full max-h-[95vh] overflow-hidden">
<div className="p-6 border-b border-gray-200">
<div className="flex">
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Education</h2>
</div>
</div>
<div className="p-6 max-h-[60vh] overflow-y-auto">
<div className="space-y-6">
{educations.map((education, index) => (
<div key={education.id} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">Education {index + 1}</h3>
<ModalButton
variant="danger"
size="sm"
onClick={() => removeEducation(education.id)}
className="text-red-500 hover:text-red-700"
>
<DeleteOutlined />
</ModalButton>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InputField
label="Institution"
value={education.institution}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateEducation(education.id, 'institution', e.target.value)}
placeholder="Enter institution name"
/>
<InputField
label="Degree"
value={education.degree}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateEducation(education.id, 'degree', e.target.value)}
placeholder="Enter degree"
/>
<InputField
label="Field"
value={education.field}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateEducation(education.id, 'field', e.target.value)}
placeholder="Enter field of study"
/>
<InputField
label="Period"
value={education.period}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateEducation(education.id, 'period', e.target.value)}
placeholder="e.g., Sep 2022 - Current"
/>
</div>
</div>
))}
<ModalButton
variant="secondary"
onClick={addEducation}
className="w-full border-2 border-dashed border-gray-300 rounded-lg p-4 text-gray-500 hover:border-[#23A1EB] hover:text-[#23A1EB] transition-colors flex items-center justify-center gap-2"
>
<PlusOutlined />
Add Education
</ModalButton>
</div>
</div>
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 bg-gray-50">
<ModalButton
variant="secondary"
className="bg-white shadow-md"
onClick={handleCancel}
disabled={isLoading}
>
Batal
</ModalButton>
<ModalButton
variant="primary"
onClick={handleSave}
disabled={isLoading}
loading={isLoading}
>
{isLoading ? 'Menyimpan...' : 'Simpan'}
</ModalButton>
</div>
</div>
</div>
);
};
@@ -0,0 +1,182 @@
import { FC, useState, useEffect } from 'react';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import { ModalButton } from '../buttons/modal-button';
import { InputField } from '@imphnen-frontend-service/ui/molecules';
interface Experience {
id: string;
company: string;
position: string;
duration: string;
period: string;
}
interface ExperienceModalProps {
isOpen: boolean;
onClose: () => void;
initialValue: Experience[];
onSave: (value: Experience[]) => Promise<void>;
isLoading?: boolean;
showNotification?: (type: 'success' | 'error', title: string, message?: string) => void;
}
export const ExperienceModal: FC<ExperienceModalProps> = ({
isOpen,
onClose,
initialValue,
onSave,
isLoading = false,
showNotification,
}) => {
const [experiences, setExperiences] = useState<Experience[]>(initialValue);
useEffect(() => {
setExperiences(initialValue);
}, [initialValue]);
const handleSave = async () => {
const hasEmpty = experiences.some(exp =>
!exp.company.trim() || !exp.position.trim() || !exp.duration.trim() || !exp.period.trim()
);
if (hasEmpty) {
if (showNotification) {
showNotification('error', 'Data Tidak Lengkap', 'Semua field harus diisi pada setiap pengalaman kerja.');
} else {
alert('Semua field harus diisi pada setiap pengalaman kerja.');
}
return;
}
try {
await onSave(experiences);
onClose();
} catch (error) {
console.error('Save failed:', error);
}
};
const handleCancel = () => {
setExperiences(initialValue);
onClose();
};
const addExperience = () => {
const newExperience: Experience = {
id: Date.now().toString(),
company: '',
position: '',
duration: '',
period: '',
};
setExperiences([...experiences, newExperience]);
};
const removeExperience = (id: string) => {
setExperiences(experiences.filter(exp => exp.id !== id));
};
const updateExperience = (id: string, field: keyof Experience, value: string) => {
setExperiences(experiences.map(exp =>
exp.id === id ? { ...exp, [field]: value } : exp
));
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
onClick={handleCancel}
aria-label="Close modal"
/>
<div className="relative bg-white rounded-xl shadow-xl max-w-5xl w-full max-h-[95vh] overflow-hidden">
<div className="p-6 border-b border-gray-200">
<div className="flex">
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Experience</h2>
</div>
</div>
<div className="p-6 max-h-[60vh] overflow-y-auto">
<div className="space-y-6">
{experiences.map((experience, index) => (
<div key={experience.id} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">Experience {index + 1}</h3>
<ModalButton
variant="danger"
size="sm"
onClick={() => removeExperience(experience.id)}
className="text-red-500 hover:text-red-700"
>
<DeleteOutlined />
</ModalButton>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InputField
label="Company"
value={experience.company}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateExperience(experience.id, 'company', e.target.value)}
placeholder="Enter company name"
/>
<InputField
label="Position"
value={experience.position}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateExperience(experience.id, 'position', e.target.value)}
placeholder="Enter position"
/>
<InputField
label="Duration"
value={experience.duration}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateExperience(experience.id, 'duration', e.target.value)}
placeholder="e.g., 7 Months"
/>
<InputField
label="Period"
value={experience.period}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateExperience(experience.id, 'period', e.target.value)}
placeholder="e.g., Jan 2024 - Present"
/>
</div>
</div>
))}
<ModalButton
variant="secondary"
onClick={addExperience}
className="w-full border-2 border-dashed border-gray-300 rounded-lg p-4 text-gray-500 hover:border-[#23A1EB] hover:text-[#23A1EB] transition-colors flex items-center justify-center gap-2"
>
<PlusOutlined />
Add Experience
</ModalButton>
</div>
</div>
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 bg-gray-50">
<ModalButton
variant="secondary"
className="bg-white shadow-md"
onClick={handleCancel}
disabled={isLoading}
>
Batal
</ModalButton>
<ModalButton
variant="primary"
onClick={handleSave}
disabled={isLoading}
loading={isLoading}
>
{isLoading ? 'Menyimpan...' : 'Simpan'}
</ModalButton>
</div>
</div>
</div>
);
};
@@ -0,0 +1,10 @@
export { NotificationModal } from './notification-modal';
export { CVModal } from './cv-modal';
export { DescriptionModal } from './description-modal';
export { EducationModal } from './education-modal';
export { ExperienceModal } from './experience-modal';
export { SocialMediaModal } from './social-media-modal';
export { SkillsModal } from './skills-modal';
export { EditProfileModal } from './edit-profile-modal';
export { LanguagesModal } from './languages-modal';
export { PersonalInfoModal } from './personal-info-modal';
@@ -0,0 +1,153 @@
import { FC, useState, useEffect } from 'react';
import { Input } from '@imphnen-frontend-service/ui/atoms';
import { ModalButton } from '../buttons/modal-button';
interface Language {
id?: string;
name: string;
level: string;
}
interface LanguagesModalProps {
isOpen: boolean;
onClose: () => void;
initialValue: Language[];
onSave: (languages: Language[]) => void;
isLoading?: boolean;
}
export const LanguagesModal: FC<LanguagesModalProps> = ({
isOpen,
onClose,
initialValue,
onSave,
isLoading = false,
}) => {
const [languages, setLanguages] = useState<Language[]>(initialValue);
useEffect(() => {
const languagesWithIds = initialValue.map(lang => ({
...lang,
id: lang.id || `lang-${Date.now()}-${Math.random()}`
}));
setLanguages(languagesWithIds);
}, [initialValue]);
const handleAddLanguage = () => {
setLanguages([...languages, {
id: `lang-${Date.now()}-${Math.random()}`,
name: '',
level: ''
}]);
};
const handleLanguageChange = (index: number, field: keyof Language, value: string) => {
const newLanguages = [...languages];
newLanguages[index] = { ...newLanguages[index], [field]: value };
setLanguages(newLanguages);
};
const handleRemoveLanguage = (index: number) => {
const newLanguages = languages.filter((_, i) => i !== index);
setLanguages(newLanguages);
};
const handleSave = () => {
const languagesToSave = languages.map(({ id, ...lang }) => lang);
onSave(languagesToSave);
onClose();
};
const handleCancel = () => {
const languagesWithIds = initialValue.map(lang => ({
...lang,
id: lang.id || `lang-${Date.now()}-${Math.random()}`
}));
setLanguages(languagesWithIds);
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{}
<button
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
onClick={handleCancel}
type="button"
aria-label="Close modal"
/>
{}
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto max-h-[95vh] overflow-hidden">
{}
<div className="p-6 pb-4 border-b border-gray-200">
<div className="flex">
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Languages</h2>
</div>
</div>
{}
<div className="p-6 max-h-[60vh] overflow-y-auto">
<div className="space-y-4">
{languages.map((language, index) => (
<div key={language.id} className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-2">
<div className="flex-1">
<p className="text-sm font-medium text-gray-700 mb-1">Language Name</p>
<Input
value={language.name}
onChange={(e) => handleLanguageChange(index, 'name', e.target.value)}
placeholder="e.g., English"
className="w-full"
/>
</div>
<div className="flex-1">
<p className="text-sm font-medium text-gray-700 mb-1">Level</p>
<Input
value={language.level}
onChange={(e) => handleLanguageChange(index, 'level', e.target.value)}
placeholder="e.g., Fluent"
className="w-full"
/>
</div>
<div className="sm:flex-shrink-0">
<ModalButton variant="danger" onClick={() => handleRemoveLanguage(index)} className="w-full sm:w-auto">
Remove
</ModalButton>
</div>
</div>
))}
<ModalButton variant="secondary" onClick={handleAddLanguage} className="w-full">
Add Language
</ModalButton>
</div>
</div>
{}
<div className="flex gap-3 p-6 pt-4">
<ModalButton
variant="secondary"
onClick={handleCancel}
className="flex-1 bg-white shadow-md"
disabled={isLoading}
>
Cancel
</ModalButton>
<ModalButton
variant="primary"
onClick={handleSave}
className="flex-1"
disabled={isLoading}
loading={isLoading}
>
{isLoading ? 'Saving...' : 'Save'}
</ModalButton>
</div>
</div>
</div>
);
};
@@ -0,0 +1,89 @@
import { FC } from 'react';
import { CloseOutlined } from '@ant-design/icons';
import { ModalButton } from '../buttons/modal-button';
export type NotificationType = {
isOpen: boolean;
onClose: () => void;
type: 'success' | 'error';
title: string;
message?: string;
header: string;
}
interface NotificationModalProps extends NotificationType {
isOpen: boolean;
onClose: () => void;
type: 'success' | 'error';
title: string;
message?: string;
header: string;
}
export const NotificationModal: FC<NotificationModalProps> = ({
isOpen,
onClose,
type,
title,
message,
header,
}) => {
if (!isOpen) return null;
const isSuccess = type === 'success';
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
<button
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
onClick={onClose}
aria-label="Close modal"
/>
<div className="relative bg-white rounded-xl shadow-xl max-w-md w-full overflow-hidden">
<div className="p-6 pb-4 border-b border-gray-200">
<div className="flex">
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">{header}</h2>
</div>
</div>
<div className="p-8 text-center">
<div className="mb-6">
{isSuccess ? (
<div className="w-30 h-30 mx-auto mb-4">
<img
src="/image/success.png"
alt="Success"
width={80}
height={80}
className="w-full h-full object-contain"
/>
</div>
) : (
<div className="w-20 h-20 bg-red-500 rounded-full flex items-center justify-center mx-auto mb-4">
<CloseOutlined className="text-white text-3xl" />
</div>
)}
<h2 className={`text-lg font-medium ${isSuccess ? 'text-green-600' : 'text-red-600'}`}>
{title}
</h2>
{!isSuccess && message && (
<div className="mt-2 text-sm text-red-500 whitespace-pre-line">{message}</div>
)}
</div>
<ModalButton
variant="primary"
onClick={onClose}
className="w-full bg-[#23A1EB] hover:bg-[#23A1EB]/90"
>
Selesai
</ModalButton>
</div>
</div>
</div>
);
};
@@ -0,0 +1,137 @@
import { FC, useState, useEffect } from 'react';
import { ModalButton } from '../buttons/modal-button';
interface PersonalInfo {
email: string;
phone: string;
location: string;
}
interface PersonalInfoModalProps {
isOpen: boolean;
onClose: () => void;
initialValue: PersonalInfo;
onSave: (value: PersonalInfo) => Promise<void>;
isLoading?: boolean;
}
export const PersonalInfoModal: FC<PersonalInfoModalProps> = ({
isOpen,
onClose,
initialValue,
onSave,
isLoading = false,
}) => {
const [personalInfo, setPersonalInfo] = useState(initialValue);
useEffect(() => {
setPersonalInfo(initialValue);
}, [initialValue]);
const handleSave = async () => {
try {
await onSave(personalInfo);
onClose();
} catch (error) {
console.error('Save failed:', error);
}
};
const handleCancel = () => {
setPersonalInfo(initialValue);
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
onClick={handleCancel}
aria-label="Close modal"
type="button"
/>
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto">
<div className="p-6 pb-4 border-b border-gray-200">
<div className="flex">
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Personal Information</h2>
</div>
</div>
<div className="px-6 py-4 space-y-4">
<div>
<label htmlFor="personal-email" className="block text-sm font-medium text-gray-700 mb-2">
Email
</label>
<input
id="personal-email"
type="email"
value={personalInfo.email}
onChange={(e) => setPersonalInfo({ ...personalInfo, email: e.target.value })}
placeholder="Enter your email"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
/>
</div>
<div>
<label htmlFor="personal-phone" className="block text-sm font-medium text-gray-700 mb-2">
Phone
</label>
<input
id="personal-phone"
type="tel"
value={personalInfo.phone}
onChange={(e) => setPersonalInfo({ ...personalInfo, phone: e.target.value })}
placeholder="Enter your phone number"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
/>
</div>
<div>
<label htmlFor="personal-location" className="block text-sm font-medium text-gray-700 mb-2">
Location
</label>
<input
id="personal-location"
type="text"
value={personalInfo.location}
onChange={(e) => setPersonalInfo({ ...personalInfo, location: e.target.value })}
placeholder="Enter your location"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
/>
</div>
</div>
<div className="flex gap-3 p-6 pt-4">
<ModalButton variant="secondary"
onClick={handleCancel}
className="flex-1 bg-white shadow-md"
disabled={isLoading}
>
Batal
</ModalButton>
<ModalButton variant="primary"
onClick={handleSave}
className="flex-1"
disabled={isLoading}
loading={isLoading}
>
{isLoading ? 'Menyimpan...' : 'Simpan'}
</ModalButton>
</div>
</div>
</div>
);
};
@@ -0,0 +1,105 @@
import { FC, useState } from 'react';
import { ModalButton } from '../buttons/modal-button';
interface ProfileBasicInfo {
name: string;
title: string;
}
interface ProfileBasicInfoModalProps {
isOpen: boolean;
onClose: () => void;
initialValue: ProfileBasicInfo;
onSave: (value: ProfileBasicInfo) => void;
}
export const ProfileBasicInfoModal: FC<ProfileBasicInfoModalProps> = ({
isOpen,
onClose,
initialValue,
onSave,
}) => {
const [profileInfo, setProfileInfo] = useState(initialValue);
const handleSave = () => {
onSave(profileInfo);
onClose();
};
const handleCancel = () => {
setProfileInfo(initialValue);
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
onClick={handleCancel}
aria-label="Close modal"
type="button"
/>
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto">
<div className="p-6 pb-4 border-b border-gray-200">
<div className="flex">
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Profile</h2>
</div>
</div>
<div className="px-6 py-4 space-y-4">
<div>
<label htmlFor="profile-name" className="block text-sm font-medium text-gray-700 mb-2">
Full Name
</label>
<input
id="profile-name"
type="text"
value={profileInfo.name}
onChange={(e) => setProfileInfo({ ...profileInfo, name: e.target.value })}
placeholder="Enter your full name"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
/>
</div>
<div>
<label htmlFor="profile-title" className="block text-sm font-medium text-gray-700 mb-2">
Professional Title
</label>
<input
id="profile-title"
type="text"
value={profileInfo.title}
onChange={(e) => setProfileInfo({ ...profileInfo, title: e.target.value })}
placeholder="Enter your professional title"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
/>
</div>
</div>
<div className="flex gap-3 p-6 pt-4">
<ModalButton
variant="secondary"
onClick={handleCancel}
className="flex-1"
>
Batal
</ModalButton>
<ModalButton
variant="primary"
onClick={handleSave}
className="flex-1"
>
Simpan
</ModalButton>
</div>
</div>
</div>
);
};
@@ -0,0 +1,168 @@
import { FC, useState, useEffect } from 'react';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import { ModalButton } from '../buttons/modal-button';
import { InputField } from '@imphnen-frontend-service/ui/molecules';
interface Skill {
id: string;
name: string;
}
interface SkillsModalProps {
isOpen: boolean;
onClose: () => void;
initialValue: Skill[];
onSave: (value: Skill[]) => Promise<void>;
isLoading?: boolean;
}
export const SkillsModal: FC<SkillsModalProps> = ({
isOpen,
onClose,
initialValue,
onSave,
isLoading = false,
}) => {
const [skills, setSkills] = useState<Skill[]>(initialValue);
const [newSkill, setNewSkill] = useState({ name: '' });
useEffect(() => {
setSkills(initialValue);
}, [initialValue]);
const handleSave = async () => {
try {
await onSave(skills);
onClose();
} catch (error) {
console.error('Save failed:', error);
}
};
const handleCancel = () => {
setSkills(initialValue);
setNewSkill({ name: '' });
onClose();
};
const addSkill = () => {
if (newSkill.name.trim()) {
const skill: Skill = {
id: Date.now().toString(),
name: newSkill.name.trim(),
};
setSkills([...skills, skill]);
setNewSkill({ name: '' });
}
};
const removeSkill = (id: string) => {
setSkills(skills.filter(skill => skill.id !== id));
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
onClick={handleCancel}
type="button"
aria-label="Close modal"
/>
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto max-h-[95vh] overflow-hidden">
<div className="p-6 pb-4 border-b border-gray-200">
<div className="flex">
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Skills</h2>
</div>
</div>
<div className="p-6 max-h-[60vh] overflow-y-auto">
<div className="mb-6 p-4 border border-gray-200 rounded-lg bg-gray-50">
<h3 className="text-sm font-medium text-gray-700 mb-3">Add New Skill</h3>
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<InputField
label="Skill Name"
value={newSkill.name}
onChange={(e) => setNewSkill({ ...newSkill, name: e.target.value })}
placeholder="e.g., React, JavaScript, etc."
/>
</div>
<div className="flex sm:items-end">
<ModalButton
onClick={addSkill}
variant="primary"
size="sm"
className="w-full sm:w-auto"
>
<PlusOutlined />
<span className="sm:hidden ml-2">Add Skill</span>
</ModalButton>
</div>
</div>
</div>
<div>
<h3 className="text-sm font-medium text-gray-700 mb-3">Current Skills</h3>
{skills.length === 0 ? (
<p className="text-gray-500 text-sm py-4">No skills added yet.</p>
) : (
<div className="space-y-2">
{skills.map((skill) => (
<div
key={skill.id}
className="flex items-center justify-between p-3 border border-gray-200 rounded-md bg-white"
>
<div>
<span className="font-medium text-gray-900">{skill.name}</span>
</div>
<ModalButton
onClick={() => removeSkill(skill.id)}
variant="danger"
size="sm"
className="text-red-500 hover:text-red-700"
>
<DeleteOutlined />
</ModalButton>
</div>
))}
</div>
)}
</div>
</div>
<div className="flex gap-3 p-6 pt-4">
<ModalButton variant="secondary"
onClick={handleCancel}
className="flex-1"
disabled={isLoading}
>
Batal
</ModalButton>
<ModalButton variant="primary"
onClick={handleSave}
className="flex-1"
disabled={isLoading}
loading={isLoading}
>
{isLoading ? 'Menyimpan...' : 'Simpan'}
</ModalButton>
</div>
</div>
</div>
);
};
@@ -0,0 +1,118 @@
import { FC, useState, useEffect } from 'react';
import { ModalButton } from '../buttons/modal-button';
interface SocialLink {
platform: string;
placeholder: string;
value: string;
}
interface SocialMediaModalProps {
isOpen: boolean;
onClose: () => void;
initialValue: SocialLink[];
onSave: (value: SocialLink[]) => Promise<void>;
isLoading?: boolean;
}
export const SocialMediaModal: FC<SocialMediaModalProps> = ({
isOpen,
onClose,
initialValue,
onSave,
isLoading = false,
}) => {
const [socialLinks, setSocialLinks] = useState<SocialLink[]>(initialValue);
useEffect(() => {
setSocialLinks(initialValue);
}, [initialValue]);
const handleSave = async () => {
try {
await onSave(socialLinks);
onClose();
} catch (error) {
console.error('Save failed:', error);
}
};
const handleCancel = () => {
setSocialLinks(initialValue);
onClose();
};
const handleSocialLinkChange = (index: number, value: string) => {
const updated = [...socialLinks];
updated[index].value = value;
setSocialLinks(updated);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
onClick={handleCancel}
aria-label="Close modal"
type="button"
/>
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto">
<div className="p-6 pb-4 border-b border-gray-200">
<div className="flex">
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Social Media</h2>
</div>
</div>
<div className="px-6 py-4 space-y-4 max-h-[60vh] overflow-y-auto">
{socialLinks.map((link, index) => (
<div key={link.platform}>
<label htmlFor={`social-${index}`} className="block text-sm font-medium text-gray-700 mb-2">
{link.platform}
</label>
<input
id={`social-${index}`}
type="text"
placeholder={link.placeholder}
value={link.value}
onChange={(e) => handleSocialLinkChange(index, e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
/>
</div>
))}
</div>
<div className="flex gap-3 p-6 pt-4">
<ModalButton
variant="secondary"
onClick={handleCancel}
className="flex-1"
disabled={isLoading}
>
Batal
</ModalButton>
<ModalButton
variant="primary"
onClick={handleSave}
className="flex-1"
disabled={isLoading}
loading={isLoading}
>
{isLoading ? 'Menyimpan...' : 'Simpan'}
</ModalButton>
</div>
</div>
</div>
);
};
@@ -0,0 +1,8 @@
export { ProfileHeader } from './profile-header';
export { ProfileInfo } from './profile-info';
export { ProfileTabs } from './profile-tabs';
export { ProfileForm } from './profile-form';
export { ProfileSidebar } from './profile-sidebar';
export type { SocialLink, Experience, Education, Language, PersonalInfo, ContactInfo, CvResume, NotificationState, ProfileFormProps } from './profile-form-types';
export { useProfileFormState, useProfileDataSync } from './profile-form-hooks';
export { useProfileHandlers } from './profile-form-handlers';
@@ -0,0 +1,120 @@
import type { MentorUpdateRequestDto, UserUpdateRequestDto } from '@imphnen-frontend-service/service';
import { useProfile } from '../contexts/profile-context';
import type { SocialLink, ProfileUpdateData, Experience, Education } from './profile-form-types';
export const useProfileHandlers = (
showNotification: (type: 'success' | 'error', title: string, message?: string) => void
) => {
const { updateProfile, profileType } = useProfile();
const handleProfileUpdate = async (updates: ProfileUpdateData) => {
try {
const result = await updateProfile(updates);
showNotification('success', 'Perubahan Berhasil Disimpan');
return result;
} catch (err) {
console.error('Profile update error:', err);
showNotification('error', 'Gagal menyimpan perubahan', 'Silakan coba lagi');
throw err;
}
};
const handlePersonalInfoSave = async (personalData: { phone: string; location: string }) => {
await handleProfileUpdate({
phone_for_verification: personalData.phone,
location: personalData.location
});
};
const handleContactInfoSave = async (contactData: { phone: string; location: string }) => {
await handleProfileUpdate({
phone_for_verification: contactData.phone,
location: contactData.location
});
};
const handleSocialMediaSave = async (newSocialLinks: SocialLink[]) => {
const linkedIn = newSocialLinks.find(link => link.platform === 'LinkedIn')?.value;
const github = newSocialLinks.find(link => link.platform === 'Github')?.value;
const portfolio = newSocialLinks.find(link => link.platform === 'Portfolio')?.value;
const twitter = newSocialLinks.find(link => link.platform === 'Twitter')?.value;
const updates: ProfileUpdateData = {
linkedin_url: linkedIn || undefined,
github_url: github || undefined,
twitter_url: twitter || undefined,
};
if (profileType === 'mentor') {
(updates as MentorUpdateRequestDto).portfolio_url = portfolio || undefined;
} else if (profileType === 'user') {
(updates as UserUpdateRequestDto).website_url = portfolio || undefined;
}
await handleProfileUpdate(updates);
};
const handleDescriptionSave = async (newDescription: string) => {
await handleProfileUpdate({
bio: newDescription || null
});
};
const handleSkillsSave = async (newSkills: string[]) => {
const updates: ProfileUpdateData = {};
if (profileType === 'user') {
(updates as UserUpdateRequestDto).skills = newSkills;
} else if (profileType === 'mentor') {
(updates as MentorUpdateRequestDto).expertise = newSkills;
}
await handleProfileUpdate(updates);
};
const handleLanguagesSave = async (newLanguages: Array<{ name: string; level: string }>) => {
await handleProfileUpdate({
languages: newLanguages.map(lang => lang.name)
} as MentorUpdateRequestDto | UserUpdateRequestDto);
};
const handleExperiencesSave = async (newExperiences: Experience[]) => {
try {
await handleProfileUpdate({
experience: newExperiences
});
} catch (error) {
console.error('Experience update error:', error);
}
};
const handleEducationSave = async (newEducations: Education[]) => {
try {
await handleProfileUpdate({
education: newEducations
});
} catch (error) {
console.error('Education update error:', error);
}
};
const handleCvResumeSave = async (cvData: { fileName?: string; fileUrl?: string }) => {
await handleProfileUpdate({
cv_url: cvData.fileUrl || cvData.fileName || null
});
};
return {
handlePersonalInfoSave,
handleContactInfoSave,
handleSocialMediaSave,
handleDescriptionSave,
handleSkillsSave,
handleLanguagesSave,
handleExperiencesSave,
handleEducationSave,
handleCvResumeSave
};
};
@@ -0,0 +1,253 @@
import { useState, useEffect } from 'react';
import { useProfile } from '../contexts/profile-context';
import type {
SocialLink,
Experience,
Education,
Language,
PersonalInfo,
ContactInfo,
CvResume,
NotificationState
} from './profile-form-types';
export const useProfileFormState = () => {
const { profileData, profileType } = useProfile();
const [notification, setNotification] = useState<NotificationState>({
isOpen: false,
type: 'success',
title: '',
message: ''
});
const [socialLinks, setSocialLinks] = useState<SocialLink[]>([
{
platform: 'LinkedIn',
placeholder: 'linkedin.com/in/yourprofile',
value: ''
},
{
platform: 'Github',
placeholder: 'github.com/yourusername',
value: ''
},
{
platform: 'Portfolio',
placeholder: 'yourportfolio.com',
value: ''
},
{
platform: 'Twitter',
placeholder: 'twitter.com/yourusername',
value: ''
}
]);
const [experiences, setExperiences] = useState<Experience[]>([]);
const [education, setEducation] = useState<Education[]>([]);
const [skills, setSkills] = useState<string[]>([]);
const [languages, setLanguages] = useState<Language[]>([]);
const [personalInfo, setPersonalInfo] = useState<PersonalInfo>({
fullname: '',
title: '',
bio: '',
birthdate: '',
gender: ''
});
const [contactInfo, setContactInfo] = useState<ContactInfo>({
email: '',
phone: '',
location: ''
});
const [cvResume, setCvResume] = useState<CvResume>({
cvUrl: '',
resumeUrl: ''
});
return {
profileData,
profileType,
notification,
setNotification,
socialLinks,
setSocialLinks,
experiences,
setExperiences,
education,
setEducation,
skills,
setSkills,
languages,
setLanguages,
personalInfo,
setPersonalInfo,
contactInfo,
setContactInfo,
cvResume,
setCvResume
};
};
export const useProfileDataSync = (state: ReturnType<typeof useProfileFormState>) => {
const {
profileData,
profileType,
setSocialLinks,
setExperiences,
setEducation,
setSkills,
setLanguages,
setPersonalInfo,
setContactInfo,
setCvResume
} = state;
useEffect(() => {
if (profileData) {
const linkedinUrl = 'linkedin_url' in profileData ? profileData.linkedin_url || '' : '';
const githubUrl = 'github_url' in profileData ? profileData.github_url || '' : '';
let portfolioUrl = '';
if (profileType === 'mentor' && 'portfolio_url' in profileData) {
portfolioUrl = profileData.portfolio_url || '';
} else if (profileType === 'user' && 'website_url' in profileData) {
portfolioUrl = profileData.website_url || '';
}
const twitterUrl = 'twitter_url' in profileData ? profileData.twitter_url || '' : '';
setSocialLinks([
{
platform: 'LinkedIn',
placeholder: 'linkedin.com/in/yourprofile',
value: linkedinUrl
},
{
platform: 'Github',
placeholder: 'github.com/yourusername',
value: githubUrl
},
{
platform: 'Portfolio',
placeholder: 'yourportfolio.com',
value: portfolioUrl
},
{
platform: 'Twitter',
placeholder: 'twitter.com/yourusername',
value: twitterUrl
}
]);
}
}, [profileData, profileType, setSocialLinks]);
useEffect(() => {
if (profileData) {
const experiences = 'experience' in profileData ? profileData.experience || [] : [];
setExperiences(experiences);
}
}, [profileData, setExperiences]);
useEffect(() => {
if (profileData) {
const education = 'education' in profileData ? profileData.education || [] : [];
setEducation(education);
}
}, [profileData, setEducation]);
useEffect(() => {
if (profileData) {
let skills: string[] = [];
if ('skills' in profileData) {
skills = profileData.skills || [];
} else if ('expertise' in profileData) {
skills = profileData.expertise || [];
}
setSkills(skills);
}
}, [profileData, setSkills]);
useEffect(() => {
if (profileData) {
const languages: Language[] = 'languages' in profileData
? (profileData.languages || []).map(lang => ({ name: lang, level: 'Intermediate' }))
: [];
setLanguages(languages);
}
}, [profileData, setLanguages]);
useEffect(() => {
if (profileData) {
const bio = 'bio' in profileData ? profileData.bio || '' : '';
let fullname = '';
if ('fullname' in profileData) {
fullname = profileData.fullname || '';
} else if ('legal_name' in profileData) {
fullname = profileData.legal_name || '';
}
const title = 'current_role' in profileData ? profileData.current_role || '' : '';
const birthdate = 'birthdate' in profileData ? profileData.birthdate || '' : '';
const gender = 'gender' in profileData ? profileData.gender || '' : '';
setPersonalInfo({
fullname,
title,
bio,
birthdate,
gender
});
}
}, [profileData, setPersonalInfo]);
useEffect(() => {
if (profileData) {
const email = 'email' in profileData ? profileData.email || '' : '';
let phone = '';
if ('phone_number' in profileData) {
phone = profileData.phone_number || '';
} else if ('phone_for_verification' in profileData) {
phone = profileData.phone_for_verification || '';
}
let location = '';
if ('location' in profileData) {
location = profileData.location || '';
} else if ('domicile' in profileData) {
location = profileData.domicile || '';
}
setContactInfo({
email,
phone,
location
});
}
}, [profileData, setContactInfo]);
useEffect(() => {
if (profileData) {
const cvUrl = profileType === 'mentor' && 'cv_url' in profileData ? profileData.cv_url || '' : '';
setCvResume({
cvUrl,
resumeUrl: ''
});
}
}, [profileData, profileType, setCvResume]);
};
@@ -0,0 +1,60 @@
import type { MentorUpdateRequestDto, UserUpdateRequestDto } from '@imphnen-frontend-service/service';
export interface SocialLink {
platform: string;
placeholder: string;
value: string;
}
export interface Experience {
id: string;
company: string;
position: string;
duration: string;
period: string;
}
export interface Education {
id: string;
institution: string;
degree: string;
field: string;
period: string;
}
export interface Language {
name: string;
level: string;
}
export interface PersonalInfo {
fullname: string;
title: string;
bio: string;
birthdate: string;
gender: string;
}
export interface ContactInfo {
email: string;
phone: string;
location: string;
}
export interface CvResume {
cvUrl: string;
resumeUrl: string;
}
export interface NotificationState {
isOpen: boolean;
type: 'success' | 'error';
title: string;
message?: string;
}
export interface ProfileFormProps {
showNotification: (type: 'success' | 'error', title: string, message?: string) => void;
}
export type ProfileUpdateData = Partial<MentorUpdateRequestDto | UserUpdateRequestDto>;
@@ -0,0 +1,251 @@
import { FC, useState, useEffect } from 'react';
import { NotificationModal } from '../modals';
import { ExperiencesSection } from '../sections/experiences-section';
import { CvResumeSection } from '../sections/cv-resume-section';
import { DescriptionSection } from '../sections/description-section';
import { EducationSection } from '../sections/education-section';
import type { MentorUpdateRequestDto, UserUpdateRequestDto } from '@imphnen-frontend-service/service';
import { useProfile } from '../contexts/profile-context';
interface Experience {
id: string;
company: string;
position: string;
duration: string;
period: string;
}
interface Education {
id: string;
institution: string;
degree: string;
field: string;
period: string;
}
interface ProfileFormProps {
showNotification: (type: 'success' | 'error', title: string, message?: string) => void;
isViewOnly?: boolean; // Add isViewOnly prop
}
export const ProfileForm: FC<ProfileFormProps> = ({ showNotification, isViewOnly = false }) => {
const { profileData, updateProfile, isUpdating } = useProfile();
const [notification, setNotification] = useState<{
isOpen: boolean;
type: 'success' | 'error';
title: string;
message?: string;
}>({
isOpen: false,
type: 'success',
title: '',
message: ''
});
const [experiences, setExperiences] = useState<Experience[]>([]);
const [education, setEducation] = useState<Education[]>([]);
const [personalInfo, setPersonalInfo] = useState({
fullname: '',
title: '',
bio: '',
birthdate: '',
gender: ''
});
const [cvResume, setCvResume] = useState({
cvUrl: '',
resumeUrl: ''
});
useEffect(() => {
if (profileData) {
const experiences = 'experience' in profileData ? profileData.experience || [] : [];
setExperiences(experiences);
}
}, [profileData]);
useEffect(() => {
if (profileData) {
const education = 'education' in profileData ? profileData.education || [] : [];
setEducation(education);
}
}, [profileData]);
useEffect(() => {
if (profileData) {
const bio = 'bio' in profileData ? profileData.bio || '' : '';
let fullname = '';
if ('fullname' in profileData) {
fullname = profileData.fullname || '';
} else if ('legal_name' in profileData) {
fullname = profileData.legal_name || '';
}
const title = 'current_role' in profileData ? profileData.current_role || '' : '';
const birthdate = 'birthdate' in profileData ? profileData.birthdate || '' : '';
const gender = 'gender' in profileData ? profileData.gender || '' : '';
setPersonalInfo({
fullname,
title,
bio,
birthdate,
gender
});
}
}, [profileData]);
useEffect(() => {
if (profileData) {
const cvUrl = 'cv_url' in profileData ? profileData.cv_url || '' : '';
setCvResume({
cvUrl,
resumeUrl: ''
});
}
}, [profileData]);
function isErrorWithResponse(err: unknown): err is { response: { data: { message: string } } } {
return (
typeof err === 'object' &&
err !== null &&
typeof (err as { response?: { data?: { message?: unknown } } }).response?.data?.message === 'string'
);
}
function isErrorWithMessage(err: unknown): err is { message: string } {
return (
typeof err === 'object' &&
err !== null &&
'message' in err &&
typeof (err as { message?: unknown }).message === 'string'
);
}
function tryParseJsonMessage(msg: string): string {
const trimmed = msg.trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed.message === 'string') {
return parsed.message;
}
} catch {
return msg;
}
}
return msg;
}
function extractApiMessage(err: unknown): string {
if (isErrorWithResponse(err)) {
return err.response.data.message;
}
if (isErrorWithMessage(err)) {
const msg = err.message || '';
return tryParseJsonMessage(msg);
}
return '';
}
const handleProfileUpdate = async (updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto>) => {
if (isViewOnly) { // Prevent updates if in view-only mode
showNotification('error', 'Akses Ditolak', 'Anda tidak memiliki izin untuk mengedit profil ini.');
return;
}
try {
const result = await updateProfile(updates);
showNotification('success', 'Perubahan Berhasil Disimpan');
return result;
} catch (err: unknown) {
console.error('Profile update error:', err);
const apiMessage = extractApiMessage(err);
showNotification('error', 'Gagal menyimpan perubahan', apiMessage || 'Silakan coba lagi');
throw err;
}
};
return (
<div className="space-y-6">
{}
<DescriptionSection
initialDescription={personalInfo.bio}
onSave={async (newDescription) => {
await handleProfileUpdate({
bio: newDescription || null
});
}}
showNotification={showNotification}
isLoading={isUpdating}
isViewOnly={isViewOnly} // Pass isViewOnly
/>
{}
<CvResumeSection
initialFileName={cvResume.cvUrl}
fullname={personalInfo.fullname}
onSave={async (cvData) => {
await handleProfileUpdate({
cv_url: cvData.fileUrl || null
});
}}
showNotification={showNotification}
isLoading={isUpdating}
isViewOnly={isViewOnly} // Pass isViewOnly
/>
{}
<ExperiencesSection
initialExperiences={experiences}
onSave={async (newExperiences) => {
try {
await handleProfileUpdate({
experience: newExperiences
});
} catch (error) {
console.error('Experience update error:', error);
}
}}
showNotification={showNotification}
isLoading={isUpdating}
isViewOnly={isViewOnly} // Pass isViewOnly
/>
{}
<EducationSection
initialEducation={education}
onSave={async (newEducations) => {
try {
await handleProfileUpdate({
education: newEducations
});
} catch (error) {
console.error('Education update error:', error);
}
}}
showNotification={showNotification}
isLoading={isUpdating}
isViewOnly={isViewOnly} // Pass isViewOnly
/>
<NotificationModal
isOpen={notification.isOpen}
onClose={() => setNotification(prev => ({ ...prev, isOpen: false }))}
type={notification.type}
title={notification.title}
message={notification.message}
header="Profile"
/>
</div>
);
};

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