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 NurandGitHub 70ee5300f7 add maintenance page (#66) 2025-11-27 23:46:52 +07:00
35c9539c08 feat(hackathon): Change "edit profile" into modal dialog (#65)
* feat: update landing page

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

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

* chore: remove react-helmet-async

* chore: update landing page

* chore: update UI for signup and login

- add back button for better UX

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

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

* feat(hackathon): update UI

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

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

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

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

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

* feat(hackathon): browse team page

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

* feat(hackathon): update team page

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

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

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

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

* feat(hackathon): dark mode for user page

---------

Co-authored-by: Maulana Sodiqin <sodiqincahyana1@gmail.com>
Co-authored-by: Muhammad Zakir Ramadhan <61570975+zakirkun@users.noreply.github.com>
2025-11-27 16:25:21 +07:00
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 RizkyantoandGitHub cbb880a053 Hackathon: better frontend (#61)
* feat: enhance team browsing with member count and view team option

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

* feat: implement user profile page and user layout component

* feat: improve browse team filtering layout

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 21:58:15 +07:00
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 NurandGitHub a2cf67d210 feat(hackathon): Update landing page UI and meta tag (#59)
* feat: update landing page

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

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

* chore: remove react-helmet-async
2025-11-25 15:47:55 +07:00
Maulana SodiqinandClaude d9c4217fd3 chore: trigger deployment for all apps
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 15:47:20 +07:00
Maulana SodiqinandClaude 719e36e46c feat(hackathon): add searchable city select component
- Created CitySelect component with real-time filtering
- Supports 516 Indonesian cities with array filtering
- Added search functionality for better UX
- Integrated with react-hook-form in user onboarding
- Includes clear button and auto-close on outside click

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

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

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

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

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

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

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

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

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

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

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

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

These changes fix TypeScript compilation errors during Docker builds.

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

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

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

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

This resolves the Docker build EOF errors during deployment.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 02:34:52 +07:00
Maulana Sodiqin 9e9bfc2793 feat: hackathon 2025-11-25 01:47:56 +07:00
Maulana Sodiqin 5a99f216cb chore: deploy hackathon 2025-11-24 13:09:12 +07:00
Maulana Sodiqin efdbc65be6 chore: remove vetrsion 2025-11-24 12:51:41 +07:00
Maulana Sodiqin f5905c0acc chore: remove storybook 2025-11-24 11:21:29 +07:00
Maulana Sodiqin 6348a4998b feat: upgrade nx 2025-11-24 10:48:09 +07:00
Hafid NurandGitHub e067450fcb fix(landing): Fix community links & meta tag for SEO (#57) 2025-10-06 08:57:13 +07:00
Asep Haryana SaputraandGitHub a281f279f8 full json (#56) 2025-08-27 19:28:40 +07:00
Asep Haryana SaputraandGitHub 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 RijalandGitHub 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 SaputraandGitHub 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 AzmiandGitHub 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
Muhamad RijalandGitHub f3a19fa460 Feat/dimentorin landing page (#49)
* feat(dimentorin): slicing landing page menu mentoring

* feat(dimentorin): slicing landing page detail mentor

* feat(dimentorin): slicing modal booking mentor

* feat(dimentorin): slicing landing - resources page

* feat(dimentorin): slicing landing - articles page

* feat(dimentorin): slicing landing - detail articles page
2025-07-28 23:26:22 +07:00
291 changed files with 54607 additions and 16258 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,52 @@
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"
import { PieLabelProps } from "recharts/types/polar/Pie"
type ChartProps = {
name: string
value: number
color: string
}
const chartData: ChartProps[] = [
{ name: "Active", value: 49, color: "#23A1EB" },
{ name: "Done", value: 24, color: "#81CBF8" },
{ name: "Canceled", value: 27, color: "#BCE1FB" },
]
const RADIAN = Math.PI / 180;
const renderCustomizedLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent }: PieLabelProps) => {
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const x = cx + radius * Math.cos(-(midAngle ?? 0) * RADIAN);
const y = cy + radius * Math.sin(-(midAngle ?? 0) * RADIAN);
return (
<text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central">
{`${((percent ?? 1) * 100).toFixed(0)}%`}
</text>
);
};
export const SessionStatusChart = () => {
return (
<ResponsiveContainer width="100%" height={320}>
<PieChart width={500} height={320}>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={40}
outerRadius={100}
labelLine={false}
label={renderCustomizedLabel}
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
)
}
@@ -0,0 +1,36 @@
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
type ChartProps = {
name: string
activeUser: number
activeSession: number
}
const chartData: ChartProps[] = [
{ name: "2014", activeUser: 0, activeSession: 0 },
{ name: "2015", activeUser: 15, activeSession: 25 },
{ name: "2016", activeUser: 30, activeSession: 40 },
{ name: "2017", activeUser: 45, activeSession: 55 },
{ name: "2018", activeUser: 60, activeSession: 70 },
{ name: "2019", activeUser: 75, activeSession: 85 },
{ name: "2020", activeUser: 90, activeSession: 95 },
{ name: "2021", activeUser: 85, activeSession: 80 },
{ name: "2022", activeUser: 95, activeSession: 90 },
{ name: "2023", activeUser: 100, activeSession: 100 },
]
export const UserGrowthChart = () => {
return (
<ResponsiveContainer width="100%" height={320}>
<LineChart data={chartData} width={500} height={320} margin={{ left: -32 }}>
<CartesianGrid />
<XAxis dataKey="name" />
<YAxis tickCount={10} />
<Tooltip />
<Legend />
<Line dataKey="activeUser" stroke="#23A1EB" />
<Line dataKey="activeSession" stroke="#0877C1" />
</LineChart>
</ResponsiveContainer>
)
}
@@ -0,0 +1,115 @@
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { BackofficeWrapper } from "@imphnen-frontend-service/ui/organisms";
import { For } from "@imphnen-frontend-service/utils";
import { ReactElement } from "react";
import { UserGrowthChart } from "./_components/chart/user-growth";
import { SessionStatusChart } from "./_components/chart/session-status";
const Overview = () => {
return (
<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 Users</p>
</div>
)
}
export default function Components(): ReactElement {
return (
<BackofficeWrapper title="Dimentorin.dev">
<h1 className="text-p1 font-semibold text-neutral-700 mb-5">Overview</h1>
<div className="space-y-14">
<div>
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
Overview
</Button>
<div className="grid grid-cols-5 gap-5">
<For data={Array.from({ length: 5 })}>
{(_, index) => <Overview key={index} />}
</For>
</div>
</div>
<div>
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
Trends & Analytics
</Button>
<div className="grid grid-cols-7 gap-x-5">
<div className="bg-white px-6 py-4 rounded-lg col-span-5">
<div className="flex items-center justify-between mb-7">
<h2 className="font-semibold text-p3 text-neutral-700">User Growth</h2>
<div></div>
</div>
<UserGrowthChart />
</div>
<div className="bg-white px-6 py-4 rounded-lg col-span-2">
<h2 className="font-semibold text-p3 text-neutral-700 mb-7">Session Status</h2>
<SessionStatusChart />
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-x-5">
<div className="bg-white px-7 py-4 rounded-lg">
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top 5 Mentors</h2>
<div>
<table className="w-full">
<thead>
<tr className="text-label1 bg-primary-50 text-left rounded-full">
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
<th className="font-medium py-4 px-5 w-3/5">Nama Lengkap</th>
<th className="font-medium py-4 px-5 rounded-r-lg">Avg Rating</th>
</tr>
</thead>
<tbody>
<For data={Array.from({ length: 5 })}>
{(_, index) => (
<tr key={index} className="shadow rounded-lg">
<td className="py-4 px-5">{index + 1}</td>
<td className="py-4 px-5">Mursid Al-Catraz</td>
<td className="py-4 px-5">4.9</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
<div className="bg-white px-6 py-4 rounded-lg">
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top Booked Mentoring Topics</h2>
<div>
<table className="w-full">
<thead>
<tr className="text-label1 bg-primary-50 text-left font-medium">
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
<th className="font-medium py-4 px-5 w-3/5">Nama Lengkap</th>
<th className="font-medium py-4 px-5 rounded-r-lg">Total Sesi</th>
</tr>
</thead>
<tbody>
<For data={Array.from({ length: 5 })}>
{(_, index) => (
<tr key={index} className="shadow rounded-lg">
<td className="py-4 px-5">{index + 1}</td>
<td className="py-4 px-5">Mursid Al-Catraz</td>
<td className="py-4 px-5">1000</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
</div>
</div>
</BackofficeWrapper>
)
}
@@ -0,0 +1,181 @@
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"
const TABS = {
MENTORING: 'Mentoring',
PLATFORM: 'Platform'
} as const
type Tabs = typeof TABS[keyof typeof TABS]
type FeedbackStatus = 'done' | 'todo';
interface FeedbackType {
id: number
name: string
email: string
rating: number
status: FeedbackStatus
}
const mockData: FeedbackType[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
name: i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana',
email: 'fullname23@gmail.com',
rating: 4.5,
status: i % 2 === 0 ? 'done' : 'todo',
}))
export default function Components(): ReactElement {
const [activeTab, setActiveTab] = useState<Tabs>(TABS.MENTORING)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const columns: ColumnDef<FeedbackType>[] = [
{
id: 'select',
meta: { cellClassName: cn("w-20") },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'name',
header: 'Name',
accessorKey: 'name',
},
{
id: 'email',
header: 'Email',
accessorKey: 'email',
},
{
id: 'rating',
header: 'Rating',
accessorKey: 'rating',
},
{
id: 'status',
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<FeedbackStatus, string> = {
done: 'bg-success-200 text-success-500',
todo: 'bg-primary-200 text-primary-500',
};
const statusText: Record<FeedbackStatus, string> = {
done: 'Done',
todo: 'To Do',
};
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
},
{
header: 'Action',
meta: { cellClassName: cn("w-72") },
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation()
}}
className="flex items-center gap-2 w-max"
>
<SearchOutlined className="text-[16px]" /> Lihat Feedback
</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="Dimentorin.dev">
<div className="mb-8 flex justify-between items-center">
<h1 className="text-p1 font-semibold text-neutral-700">Feedback</h1>
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
<For data={Object.values(TABS)}>
{(tab) => (
<Button
key={tab}
variant="text"
className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
onClick={() => setActiveTab(tab)}
>
{tab}
</Button>
)}
</For>
</div>
</div>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-5 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama mentor/mentee"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<Select>
<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 value="active">Active</option>
<option value="inactive">Inactive</option>
</Select>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</section>
</BackofficeWrapper>
);
}
@@ -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;
+41 -3
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 />
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
<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>
@@ -0,0 +1,85 @@
import { ArrowRightOutlined } from "@ant-design/icons";
import { Button, Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms";
import { Accordion, Modal, ModalProps } from "@imphnen-frontend-service/ui/molecules";
import { cn, For } from "@imphnen-frontend-service/utils";
import { FC } from "react";
const labelClass = cn('text-neutral-800 text-[10px] font-medium mb-1.5 inline-block md:text-xs md:mb-2 xl:text-p3')
export const ModalCreateRoadmap: FC<Omit<ModalProps, 'children'>> = ({ isOpen, onClose }) => {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="xl:max-w-[64rem] bg-white px-10 py-9"
closeButtonClassName="hidden"
>
<div className="overflow-y-auto max-h-[80vh]">
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
Create Roadmaps
</h1>
<div className="grid grid-cols-5 gap-6 mb-12">
<div className="col-span-3">
<label className={labelClass}>Prompt</label>
<Textarea
className="min-w-full w-full h-[calc(100%-2rem)]"
placeholder="Create a learning roadmap for (subject) at the (level) level. Include topics, estimated duration, and logical progression."
/>
</div>
<div className="col-span-2 space-y-8">
<div>
<label className={labelClass}>Roadmap Name</label>
<Input type="text" className="min-w-full w-full" placeholder="Nama Roadmap" />
</div>
<div>
<label className={labelClass}>Roadmap Name</label>
<Select className="w-full">
<option value="pemula">Pemula</option>
<option value="menengah">Menengah</option>
</Select>
</div>
<div>
<label className={labelClass}>Model</label>
<Select className="w-full">
<option value="gpt-3.5-turbo">GPT 3.5 Turbo</option>
<option value="gpt-4">GPT 4</option>
<option value="gpt-4-32k">GPT 4 32k</option>
</Select>
</div>
</div>
<div className="col-span-full">
<Button>
Generate Roadmap
</Button>
</div>
</div>
<div>
<h2 className="text-neutral-600 text-p2 font-semibold mb-8">
Roadmap Preview
</h2>
<div className="space-y-2.5">
<For data={Array.from({ length: 3 })}>
{(_, index) => (
<Accordion
key={index}
title={`Day ${index + 1}`}
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit. In convallis tincidunt nisl, id consequat mi malesuada vel. Nulla facilisi. Nam in turpis ligula."
/>
)}
</For>
</div>
</div>
<div className="flex justify-end mt-12">
<Button type="button" className="flex items-center gap-5">
Submit Roadmap
<ArrowRightOutlined />
</Button>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,174 @@
import { DeleteOutlined, EditOutlined, PlusOutlined, 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 } from "@imphnen-frontend-service/utils";
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
import { useState } from "react";
import { ModalCreateRoadmap } from "./_components/modal/create-roadmap";
type LearningStatus = 'active' | 'inactive'
type RoadmapType = {
id: number
name: string
learningLevel: string
status: LearningStatus
}
const mockData: RoadmapType[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
name: i === 0 ? 'Ahmad Wijuana' : 'Anna Wiguana',
learningLevel: ['Pemula', 'Menengah'][Math.floor(Math.random() * 2)],
status: i % 2 === 0 ? 'active' : 'inactive',
}))
export default function Components(): React.ReactElement {
const [openCreateModal, setOpenCreateModal] = useState(false)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const columns: ColumnDef<RoadmapType>[] = [
{
id: 'select',
meta: { cellClassName: cn("w-20") },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'id',
header: 'No',
accessorKey: 'id',
},
{
id: 'name',
header: 'Nama Roadmap',
accessorKey: 'name',
},
{
id: 'learningLevel',
header: 'Tingkat Belajar',
accessorKey: 'learningLevel',
},
{
id: 'status',
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<LearningStatus, string> = {
inactive: 'bg-danger-200 text-danger-700',
active: 'bg-success-200 text-success-500',
};
const statusText: Record<LearningStatus, string> = {
inactive: 'Inactive',
active: 'Active',
};
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
},
{
header: 'Action',
meta: { cellClassName: cn("w-72") },
cell: ({ row }) => (
<div className="flex gap-[8px]">
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-2"
>
<EditOutlined /> Action
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-2"
>
<DeleteOutlined /> Delete
</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="Dimentorin.dev">
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Content & Roadmap</h1>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex items-center justify-between mb-9">
<h2 className="text-p2 font-semibold text-neutral-600">AI Roadmaps</h2>
<Button type="button" variant="primary" className="flex items-center gap-2" onClick={() => setOpenCreateModal(true)}>
<PlusOutlined /> Buat Roadmap
</Button>
</div>
<div className="flex justify-between items-center gap-5 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama roadmap"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<Select>
<option selected disabled>Tingkat Belajar</option>
<option value="pemula">Pemula</option>
<option value="menengah">Menengah</option>
</Select>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</section>
<ModalCreateRoadmap isOpen={openCreateModal} onClose={() => setOpenCreateModal(false)} />
</BackofficeWrapper>
)
}
@@ -0,0 +1,114 @@
import { Icon } from "@iconify/react";
import { Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms";
import { Modal } from "@imphnen-frontend-service/ui/molecules";
import { cn, For } from "@imphnen-frontend-service/utils";
import { FC } from "react";
const TOPICS = [
{ id: 2, icon: '🏢', name: 'Industry Insight' },
{ id: 4, icon: '🖥️', name: 'Basic IT' },
]
const placeholder = `Hi [Nama Mentor], Saya [Nama Kamu] & saya berharap dapat memiliki sesi mentoring dengan Anda.
Saat ini, saya tertarik untuk mengejar __. Tujuan saya untuk sesi ini adalah __.
Saya ingin tahu secara khusus tentang ___.
1.Pertanyaan Anda
2. ...
3. ...`
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
type ModalProps = {
open: boolean
setOpen: (open: boolean) => void
}
export const ModalDetailSession: FC<ModalProps> = ({ open, setOpen }) => {
return (
<Modal
isOpen={open}
onClose={() => setOpen(false)}
className="xl:max-w-[64rem] bg-white px-10 py-9"
closeButtonClassName="hidden"
>
<div>
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
Detail Sesi
</h1>
<div className="grid grid-cols-9 px-9 py-7 border rounded-md gap-12 mb-10">
<div className="col-span-4 flex items-center gap-x-12">
<div>
<h2 className="text-p2 font-semibold text-primary-500 mb-4">Mentor</h2>
<div>
<p className="text-p3 font-semibold mb-2.5">Muhammad Firdaus Oi Oi Oi, S.H., M.H.</p>
<p className="text-neutral-600">UI Designer at Oray orayan Studios</p>
</div>
</div>
<Icon icon="ph:arrow-right" className="text-9xl text-primary-500" />
</div>
<div className="col-span-5 flex items-center gap-12">
<div>
<h2 className="text-p2 font-semibold text-primary-500 mb-4">Mentee</h2>
<div>
<p className="text-p3 font-semibold mb-2.5">Muhammad Firdaus Oi Oi Oi, S.H., M.H.</p>
<p className="text-neutral-600">UI Designer at Oray orayan Studios</p>
</div>
</div>
<div>
<h2 className="text-p2 font-semibold text-neutral-700 mb-4">Status</h2>
<div className="py-2 px-6 rounded-md text-center bg-success-200 text-success-500 font-semibold">
Finished
</div>
</div>
</div>
</div>
<div>
<h1 className="text-p3 font-medium mb-2.5">Topics</h1>
<div className="p-5 bg-primary-50 border border-primary-100 rounded-md flex flex-wrap gap-2.5 mb-8">
<For data={TOPICS}>
{(item, index) => (
<div
key={index}
className="px-2.5 py-2 text-neutral-800 bg-white border border-primary-100 rounded-md shadow font-medium"
>
<span>{item.icon} </span>
<span>{item.name}</span>
</div>
)}
</For>
</div>
<div className="grid gap-2.5 md:grid-cols-2 md:gap-5">
<div>
<label className={labelClass}>Tanggal</label>
<Input type="date" className="min-w-full w-full" />
</div>
<div>
<label className={labelClass}>Waktu</label>
<Input type="time" className="min-w-full w-full" />
</div>
<div className="relative md:col-span-full">
<label className={labelClass}>Lokasi</label>
<Select className="min-w-full w-full">
<option value="online">Online</option>
<option value="offline">Offline</option>
</Select>
</div>
<div className="md:col-span-full">
<label className={labelClass}>Pertanyaan Untuk Senpai</label>
<Textarea
className="min-w-full w-full h-40"
placeholder={placeholder}
/>
</div>
</div>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,168 @@
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 } from "@imphnen-frontend-service/utils";
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
import { ReactElement, useState } from "react";
import { ModalDetailSession } from "./_components/modal/detail";
type SessionStatus = 'ongoing' | 'finished';
interface SessionType {
id: string
mentorName: string
menteeName: string
datetime: number
status: SessionStatus
}
const mockData: SessionType[] = Array.from({ length: 90 }, (_, i) => ({
id: `DS-${i + 1}`,
mentorName: 'Ahmad Wijuana',
menteeName: 'Sofia Wijuana',
datetime: new Date().getTime(),
status: i % 2 === 0 ? 'ongoing' : 'finished',
}))
export default function Components(): ReactElement {
const [openDetail, setOpenDetail] = useState(false);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const columns: ColumnDef<SessionType>[] = [
{
id: 'select',
meta: { cellClassName: cn("w-20") },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'id',
header: 'ID Sesi',
accessorKey: 'id',
},
{
id: 'mentorName',
header: 'Nama Mentor',
accessorKey: 'name',
},
{
id: 'menteeName',
header: 'Nama Mentee',
accessorKey: 'name',
},
{
id: 'datetime',
header: 'Waktu',
accessorKey: 'datetime',
},
{
id: 'status',
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<SessionStatus, string> = {
ongoing: 'bg-warning-200 text-warning-700',
finished: 'bg-success-200 text-success-500',
};
const statusText: Record<SessionStatus, string> = {
ongoing: 'On Going',
finished: 'Finished',
};
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
},
{
header: 'Action',
meta: { cellClassName: cn("w-52") },
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setOpenDetail(true);
}}
className="flex items-center gap-2 w-max"
>
<SearchOutlined className="text-[16px]" /> Cek Detail
</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="Dimentorin.dev">
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Session Management</h1>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-5 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama lengkap"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<Select>
<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 value="finished">Finished</option>
<option value="ongoing">On Going</option>
</Select>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</section>
<ModalDetailSession open={openDetail} setOpen={setOpenDetail} />
</BackofficeWrapper>
)
}
@@ -0,0 +1,61 @@
import { Button, Input, Select, ToggleInput } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils"
import { FC } from "react"
const labelClass = cn('text-neutral-800 text-[10px] font-medium mb-1.5 inline-block md:text-xs md:mb-2 xl:text-p3')
export const GeneralSettings: FC = () => {
return (
<div>
<h1 className="text-p2 font-semibold text-neutral-700 mb-8">General Settings</h1>
<div>
<ToggleInput label="Mode Maintenance" />
<h2 className="text-p3 font-semibold text-neutral-700 mb-5">Platform Settings</h2>
<div className="grid grid-cols-2 gap-x-8 gap-y-5 mb-8">
<div>
<label className={labelClass}>Nama Platform</label>
<Input type="text" className="min-w-full w-full" />
</div>
<div>
<label className={labelClass}>Bahasa</label>
<Select defaultValue="id" className="w-full">
<option value="id">Indonesia</option>
<option value="en">English</option>
</Select>
</div>
<div>
<label className={labelClass}>Logo Platform</label>
<Input type="file" className="min-w-full w-full" />
</div>
<div>
<label className={labelClass}>Favicon</label>
<Input type="file" className="min-w-full w-full" />
</div>
</div>
<h2 className="text-p3 font-semibold text-neutral-700 mb-5">Legal Settings</h2>
<div className="grid gap-x-8 gap-y-5 mb-20">
<div>
<label className={labelClass}>URL Syarat & Ketentuan</label>
<Input type="text" className="min-w-full w-full" />
</div>
<div>
<label className={labelClass}>URL Kebijakan Privasi</label>
<Input type="text" className="min-w-full w-full" />
</div>
</div>
<div className="flex justify-end gap-5">
<Button type="button" variant="bordered">
Batal
</Button>
<Button type="button">
Simpan
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,34 @@
import { Button, Textarea, ToggleInput } from "@imphnen-frontend-service/ui/atoms"
import { FC } from "react"
export const NotificationSettings: FC = () => {
return (
<div>
<h1 className="text-p2 font-semibold text-neutral-700 mb-8">Notification Settings</h1>
<div>
<div className="flex items-center gap-8 flex-wrap">
<ToggleInput label="Nyalakan Notifikasi Email" />
<ToggleInput label="Beritahu mentor ketika ada request" />
<ToggleInput label="Beritahu mentee untuk update sesi " />
</div>
<div className="mb-20">
<label className="text-neutral-800 font-medium inline-block mb-2 text-p3">
API Integrasi Notifikasi (URL)
</label>
<Textarea placeholder="Masukkan url API notifikasi yang akan digunakan" className="w-full h-40" />
</div>
<div className="flex justify-end gap-5">
<Button type="button" variant="bordered">
Batal
</Button>
<Button type="button">
Simpan
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,50 @@
import { Button, Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils"
import { FC } from "react"
const labelClass = cn('text-neutral-800 text-[10px] font-medium mb-1.5 inline-block md:text-xs md:mb-2 xl:text-p3')
export const PaymentSettings: FC = () => {
return (
<div>
<h1 className="text-p2 font-semibold text-neutral-700 mb-8">Payment</h1>
<div className="grid grid-cols-2 gap-x-8 gap-y-5 mb-8">
<div>
<label className={labelClass}>Integrasi Payment Gateway</label>
<Textarea className="min-w-full w-full h-20" placeholder="Durasi dalam menit" />
</div>
<div>
<label className={labelClass}>Mata Uang</label>
<Select defaultValue="idr">
<option value="idr">Rupiah (IDR)</option>
<option value="usd">Dollar (USD)</option>
</Select>
</div>
<div>
<label className={labelClass}>Harga sesi mentoring <span className="text-neutral-600">(default)</span></label>
<Input type="text" className="min-w-full w-full" placeholder="Masukkan harga sesi mentoring" />
</div>
<div>
<label className={labelClass}>Tarif Komisi untuk Platform</label>
<Input type="text" className="min-w-full w-full" placeholder="Masukkan persentase komisi" />
</div>
</div>
<h2 className="text-p3 font-semibold text-neutral-700 mb-5">Invoice</h2>
<div className="mb-32">
<label className={labelClass}>Masukkan Format Invoice</label>
<Input type="file" className="min-w-full w-full" placeholder="InvoiceDimentorin.png" />
</div>
<div className="flex justify-end gap-5">
<Button type="button" variant="bordered">
Batal
</Button>
<Button type="button">
Simpan
</Button>
</div>
</div>
)
}
@@ -0,0 +1,35 @@
import { Button, Input } from "@imphnen-frontend-service/ui/atoms";
import { cn } from "@imphnen-frontend-service/utils";
import { FC } from "react";
const labelClass = cn('text-neutral-800 text-[10px] font-medium mb-1.5 inline-block md:text-xs md:mb-2 xl:text-p3')
export const SecuritySettings: FC = () => {
return (
<div>
<h1 className="text-p2 font-semibold text-neutral-700 mb-8">Security Settings</h1>
<div className="grid grid-cols-2 gap-8 mb-20">
<div>
<label className={labelClass}>Durasi Session Timeout</label>
<Input type="text" className="min-w-full w-full" placeholder="Durasi dalam menit" />
<p className="text-[10px] text-neutral-800">Auto logout setelah X menit tidak aktif</p>
</div>
<div>
<label className={labelClass}>Blokir Setelah Upaya Gagal</label>
<Input type="text" className="min-w-full w-full" placeholder="x kali perobaan login" />
<p className="text-[10px] text-neutral-800">Misal: 5 kali salah login = lock akun</p>
</div>
</div>
<div className="flex justify-end gap-5">
<Button type="button" variant="bordered">
Batal
</Button>
<Button type="button">
Simpan
</Button>
</div>
</div>
)
}
@@ -0,0 +1,118 @@
import { DeleteOutlined, UserSwitchOutlined } from "@ant-design/icons";
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { DataTable } from "@imphnen-frontend-service/ui/organisms";
import { cn } from "@imphnen-frontend-service/utils";
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
import { FC, useState } from "react";
type UserRolesPermissionType = {
id: number
role: string
totalUser: number
}
const mockData: UserRolesPermissionType[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
role: ['Admin', 'Super Admin', 'Mentee', 'Mentor'][Math.floor(Math.random() * 4)],
totalUser: 10
}))
export const UserRolesPermission: FC = () => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const columns: ColumnDef<UserRolesPermissionType>[] = [
{
id: 'select',
meta: { cellClassName: cn("w-20") },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'role',
header: 'Role',
accessorKey: 'role',
},
{
id: 'totalUser',
header: 'Total User',
accessorKey: 'totalUser',
},
{
header: 'Action',
meta: { cellClassName: cn("w-96") },
cell: ({ row }) => (
<div className="flex items-center gap-4">
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-2 w-max"
>
<UserSwitchOutlined className="text-[16px]" /> Manage Permissions
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-2 w-max"
>
<DeleteOutlined className="text-[16px]" /> Delete Role
</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 (
<div>
<div className="mb-8 flex items-center justify-between">
<h1 className="text-p2 font-semibold text-neutral-700">User Roles & Permissions</h1>
<Button type="button">
Add Rols
</Button>
</div>
<div className="bg-white shadow p-8 rounded-lg">
<DataTable data={mockData} columns={columns} table={table} />
</div>
</div>
)
}
@@ -0,0 +1,53 @@
import { BackofficeWrapper } from "@imphnen-frontend-service/ui/organisms"
import { cn, For } from "@imphnen-frontend-service/utils"
import { useState } from "react"
import { GeneralSettings } from "./_components/general"
import { UserRolesPermission } from "./_components/user-roles-permission"
import { NotificationSettings } from "./_components/notification"
import { SecuritySettings } from "./_components/security"
import { PaymentSettings } from "./_components/payment"
const TABS = {
general: "General Settings",
userRolePermissions: "User Roles & Permissions",
notification: "Notification Settings",
security: "Security",
payment: "Payment",
} as const
type Tabs = typeof TABS[keyof typeof TABS]
export default function Components(): React.ReactElement {
const [activeTab, setActiveTab] = useState<Tabs>(TABS.general)
return (
<BackofficeWrapper title="Dimentorin.dev">
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Settings</h1>
<div className="flex items-start gap-x-8">
<div className="w-64 bg-white p-2.5 shadow space-y-2 rounded-md">
<For data={Object.values(TABS)}>
{(tab) => (
<button
key={tab}
className={cn(
"px-4 py-3 w-full text-left font-medium rounded-md text-neutral-400 cursor-pointer select-none hover:bg-primary-100",
activeTab === tab && "bg-primary-500 text-white hover:bg-primary-600"
)}
onClick={() => setActiveTab(tab)}
>
{tab}
</button>
)}
</For>
</div>
<div className="bg-white px-8 py-6 shadow space-y-2 rounded-md flex-1">
{activeTab === TABS.general && <GeneralSettings />}
{activeTab === TABS.userRolePermissions && <UserRolesPermission />}
{activeTab === TABS.notification && <NotificationSettings />}
{activeTab === TABS.security && <SecuritySettings />}
{activeTab === TABS.payment && <PaymentSettings />}
</div>
</div>
</BackofficeWrapper>
)
}
@@ -0,0 +1,55 @@
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { Modal } from "@imphnen-frontend-service/ui/molecules";
import { FC } from "react";
type ModalDeleteProps = {
open: boolean
setOpen: (open: boolean) => void
hadnleDelete?: () => void
}
export const ModalDelete: FC<ModalDeleteProps> = ({ open, setOpen, hadnleDelete }) => {
return (
<Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
isOpen={open}
onClose={() => setOpen(false)}
closeButtonClassName="hidden"
>
<Modal.Header className="gap-8">
<img
src="/chibi-delete.webp"
alt="Delete item?"
width={148}
className="self-center"
/>
<div className="text-center">
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
Hapus Akun
</h2>
<p className="text-p3 text-neutral-400">
Apakah kamu yakin untuk menghapus akun ini?
</p>
</div>
</Modal.Header>
<Modal.Content className="flex gap-4">
<Button
variant="bordered"
size="lg"
className="w-full border-danger-500 text-danger-500 hover:bg-danger-50 hover:text-danger-600 hover:border-danger-600"
onClick={() => setOpen(false)}
>
Batal
</Button>
<Button
variant="danger"
size="lg"
className="w-full"
onClick={() => hadnleDelete?.()}
>
Hapus Akun
</Button>
</Modal.Content>
</Modal>
)
}
@@ -0,0 +1,86 @@
import { Button, Input } from "@imphnen-frontend-service/ui/atoms";
import { cn } from "@imphnen-frontend-service/utils";
import { FC, useState } from "react";
import { ModalDelete } from "../delete";
import { ModalProps } from "../type";
import { ModalSuspendOrBan } from "../suspend-or-ban";
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
export const AccountProfile: FC<ModalProps> = ({ setOpen }) => {
const [openDelete, setOpenDelete] = useState(false)
const [openSuspendOrBan, setOpenSuspendOrBan] = useState(false)
return (
<div>
<div className="flex items-center gap-x-8 mb-8">
<div className="size-[100px] rounded-full overflow-hidden">
<img src="/images/asd687hwq6nds4dfjj2983.webp" alt="Profile" className="w-full object-cover" />
</div>
<div>
<Button type="button" size="sm" variant="bordered" className="mb-4">
Upload Foto
</Button>
<p className="text-neutral-400 font-medium">
Setidaknya rekomendasi ukuran 240x240 px. <br />
.jpg, .jpeg, .png diperbolehkan
</p>
</div>
</div>
<div>
<div className="mb-8">
<h1 className="mb-7 text-p2 font-semibold">Informasi Pribadi</h1>
<div className="grid grid-cols-2 gap-8">
<div>
<label className={labelClass}>Nama Depan</label>
<Input type="text" className="min-w-full w-full" placeholder="Nama Depan" />
</div>
<div>
<label className={labelClass}>Nama Belakang</label>
<Input type="text" className="min-w-full w-full" placeholder="Nama Belakang" />
</div>
<div>
<label className={labelClass}>Email</label>
<Input type="email" className="min-w-full w-full" placeholder="Email" />
</div>
<div>
<label className={labelClass}>Nomor Telepon</label>
<Input type="text" className="min-w-full w-full" placeholder="+62 81234567890" />
</div>
</div>
</div>
<div>
<h1 className="mb-7 text-p2 font-semibold">Status Akun</h1>
<div className="flex items-center gap-5">
<Button
type="button"
size="sm"
variant="bordered"
className="border-danger-500 text-danger-500"
onClick={() => setOpenSuspendOrBan(true)}
>
Suspend/Ban
</Button>
<Button type="button" size="sm" variant="danger" onClick={() => setOpenDelete(true)}>
Delete Akun
</Button>
</div>
</div>
<div className="flex justify-end gap-x-5">
<Button type="button" variant="bordered" onClick={() => setOpen(false)}>
Batal
</Button>
<Button type="button" disabled>
Simpan
</Button>
</div>
</div>
<ModalDelete open={openDelete} setOpen={setOpenDelete} />
<ModalSuspendOrBan open={openSuspendOrBan} setOpen={setOpenSuspendOrBan} />
</div>
)
}
@@ -0,0 +1,110 @@
import { SearchOutlined } from "@ant-design/icons"
import { Input, Select } from "@imphnen-frontend-service/ui/atoms"
import { DataTable } from "@imphnen-frontend-service/ui/organisms"
import { cn } from "@imphnen-frontend-service/utils"
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table"
import { FC, useState } from "react"
import dayjs from "dayjs"
interface ActivityLogType {
id: number
date: string
menu: string
activity: string
}
const mockData: ActivityLogType[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
date: new Date().toISOString(),
menu: 'Mentoring',
activity: 'Mentoring Session',
}))
export const ActivityLog: FC = () => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
})
const columns: ColumnDef<ActivityLogType>[] = [
{
id: 'select',
meta: { cellClassName: cn("w-16") },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'date',
header: 'Waktu',
accessorKey: 'date',
cell: (info) => dayjs(info.row.original.date).format('DD MMMM YYYY, HH:mm WIB'),
},
{
id: 'menu',
header: 'Menu',
accessorKey: 'menu',
},
{
id: 'activity',
header: 'Activity',
accessorKey: 'activity',
}
]
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 (
<div className="p-8 shadow rounded-lg">
<div className="flex justify-between items-center gap-5 mb-9">
<div className="relative w-1/2">
<Input
placeholder="Cari aktivitas"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="w-1/4">
<Input type="date" className="min-w-full w-full" />
</div>
<Select>
<option selected disabled>Menu</option>
<option value="profle">Profile</option>
<option value="profle-2">Profile</option>
</Select>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</div>
)
}
@@ -0,0 +1,89 @@
import { Icon } from "@iconify/react"
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { For } from "@imphnen-frontend-service/utils"
import { FC } from "react"
const SOCIAL_LINKS = [
{ icon: <Icon icon="mdi:linkedin" className="text-2xl" />, url: "https://linkedin.com", label: "LinkedIn" },
{ icon: <Icon icon="mdi:github" className="text-2xl" />, url: "https://github.com", label: "Github" },
{ icon: <Icon icon="mingcute:meta-line" className="text-2xl" />, url: "https://facebook.com", label: "Facebook (Meta)" },
{ icon: <Icon icon="mdi:stack-overflow" className="text-2xl" />, url: "https://stackoverflow.com", label: "Stack Overflow" }
]
export const DetailProfile: FC = () => {
return (
<div>
<div className="shadow rounded-lg p-8 mb-7">
<div className="flex justify-between items-start mb-6">
<div className="flex items-center gap-x-6">
<div className="size-[54px] rounded-full overflow-hidden">
<img src="/images/asd687hwq6nds4dfjj2983.webp" alt="Profile" className="w-full object-cover" />
</div>
<div>
<h1 className="text-p2 font-semibold text-neutral-800">Muhammad Firdaus Oiwobo</h1>
<p className="text-p3 font-medium text-neutral-600">Mentor</p>
</div>
</div>
<Button type="button" variant="text" className="bg-primary-100">
Actively Seeking Job
</Button>
</div>
<div className="flex items-center gap-5">
<For data={SOCIAL_LINKS}>
{({ icon, url, label }) => (
<a key={url} href={url} target="_blank" rel="noreferrer">
<Button type="button" size="sm" className="flex items-center gap-x-2">
{icon}
{label}
</Button>
</a>
)}
</For>
</div>
</div>
<div className="grid grid-cols-2 gap-7">
<div className="text-pretty px-8 py-10 rounded-lg shadow h-max">
<h1 className="text-p2 font-semibold text-neutral-800 mb-6">Description</h1>
<p className="text-p3 font-medium text-neutral-600">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
</p>
</div>
<div className="px-8 py-10 rounded-lg shadow h-max">
<h1 className="text-p2 font-semibold text-neutral-800 mb-6">Personal Informations</h1>
<div className="grid gap-6">
<div className="flex items-center gap-x-5">
<div className="bg-primary-50 rounded-full p-2.5 flex items-center justify-center text-primary-500">
<Icon icon="ic:outline-mail" className="text-3xl" />
</div>
<div className="text-p3">
<p className="text-neutral-800 font-semibold">rzalaxib23@gmail.com</p>
<p className="text-neutral-600 font-medium">Email Address</p>
</div>
</div>
<div className="flex items-center gap-x-5">
<div className="bg-primary-50 rounded-full p-2.5 flex items-center justify-center text-primary-500">
<Icon icon="cil:phone" className="text-3xl" />
</div>
<div className="text-p3">
<p className="text-neutral-800 font-semibold">+62 888 8888 8888</p>
<p className="text-neutral-600 font-medium">Phone Number</p>
</div>
</div>
<div className="flex items-center gap-x-5">
<div className="bg-primary-50 rounded-full p-2.5 flex items-center justify-center text-primary-500">
<Icon icon="ion:location-outline" className="text-3xl" />
</div>
<div className="text-p3">
<p className="text-neutral-800 font-semibold">Jl. Mergosari, Kec. Suryakencana, Banjaran</p>
<p className="text-neutral-600 font-medium">Location</p>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,68 @@
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { Modal } from "@imphnen-frontend-service/ui/molecules"
import { cn, For, Show } from "@imphnen-frontend-service/utils"
import { FC, useEffect, useState } from "react"
import { AccountProfile } from "./account-profile"
import { DetailProfile } from "./detail-profile"
import { ActivityLog } from "./activity-log"
import { ModalDetailUserProps } from "./type"
const TABS = {
account: 'account profile',
detail: 'detail profile',
activity: 'activity logs',
} as const
type TabType = typeof TABS[keyof typeof TABS]
export const ModalDetailUser: FC<ModalDetailUserProps> = ({
open,
setOpen,
}) => {
const [activeTab, setActiveTab] = useState<TabType>(TABS.account)
useEffect(() => {
if (!open) setActiveTab(TABS.account)
}, [open])
return (
<Modal
isOpen={open}
onClose={() => setOpen(false)}
className="xl:max-w-[84rem] bg-white px-10 py-9"
closeButtonClassName="hidden"
>
<div>
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
Detail - Muhammad Firdaus Oiwobo
</h1>
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md w-max mb-8">
<For data={Object.values(TABS)}>
{(tab) => (
<Button
key={tab}
variant="text"
className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
onClick={() => setActiveTab(tab)}
>
{tab}
</Button>
)}
</For>
</div>
<div>
<Show condition={activeTab === TABS.account}>
<AccountProfile open={open} setOpen={setOpen} />
</Show>
<Show condition={activeTab === TABS.detail}>
<DetailProfile />
</Show>
<Show condition={activeTab === TABS.activity}>
<ActivityLog />
</Show>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,5 @@
import { ModalProps } from "../type";
export interface ModalDetailUserProps extends ModalProps {
userId?: number | null
}
@@ -0,0 +1,42 @@
import { FC } from "react";
import { ModalProps } from "../type";
import { Modal } from "@imphnen-frontend-service/ui/molecules";
import { cn } from "@imphnen-frontend-service/utils";
import { Button, Select, Textarea } from "@imphnen-frontend-service/ui/atoms";
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
export const ModalSuspendOrBan: FC<ModalProps> = ({ open, setOpen }) => {
return (
<Modal
isOpen={open}
onClose={() => setOpen(false)}
className="bg-white px-10 py-9 xl:max-w-[32rem]"
closeButtonClassName="hidden"
>
<div>
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
Suspend/Ban
</h1>
<div className="space-y-6">
<div>
<label className={labelClass}>Suspend/Ban</label>
<Select defaultValue="suspend" className="w-full">
<option value="suspend">Suspend</option>
<option value="banned">Banned</option>
</Select>
</div>
<div>
<label className={labelClass}>Alasan</label>
<Textarea placeholder="Masukkan alasan suspend/ban" className="w-full h-40" />
</div>
<Button type="button" className="w-full">
Selesai
</Button>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,4 @@
export interface ModalProps {
open: boolean
setOpen: (open: boolean) => void
}
@@ -0,0 +1,206 @@
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;
}
const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
name: i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana',
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 [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const columns: ColumnDef<UserType>[] = [
{
id: 'select',
meta: { cellClassName: cn('w-20') },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'name',
header: 'Name',
accessorKey: 'name',
},
{
id: 'email',
header: 'Email',
accessorKey: 'email',
},
{
id: 'rating',
header: 'Rating',
accessorKey: 'rating',
},
{
id: 'status',
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<UserStatus, string> = {
active: 'bg-success-200 text-success-500',
inactive: 'bg-danger-200 text-danger-500',
};
const statusText: Record<UserStatus, string> = {
active: 'Active',
inactive: 'Inactive',
};
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
},
{
header: 'Action',
meta: { cellClassName: cn('w-72') },
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setSelectedUserId(row.original.id);
setShowDetail(true);
}}
className="flex items-center gap-2 w-max"
>
<SearchOutlined className="text-[16px]" /> Lihat Detail & Action
</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="Dimentorin.dev">
<div className="mb-8 flex justify-between items-center">
<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'
)}
onClick={() => setActiveTab(tab)}
>
{tab}
</Button>
)}
</For>
</div>
</div>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-5 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama lengkap"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<Select>
<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 value="active">Active</option>
<option value="inactive">Inactive</option>
</Select>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</section>
<ModalDetailUser
open={showDetail}
setOpen={setShowDetail}
userId={selectedUserId}
/>
</BackofficeWrapper>
);
}
+2 -2
View File
@@ -130,7 +130,7 @@
html {
font-family: 'Bai Jamjuree', sans-serif;
font-weight: 400;
font-size: 12px;
font-size: 14px;
line-height: 1.2;
}
}
}
+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

@@ -2,10 +2,8 @@ import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import { FC, useRef } from 'react';
import { motion, useInView, Variants } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
export const CTASection: FC = () => {
const navigate = useNavigate();
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
@@ -139,7 +137,6 @@ export const CTASection: FC = () => {
<Button
type="button"
className="w-full h-auto py-2 text-[10px] font-medium md:w-max md:text-sm md:px-2.5 md:font-semibold lg:text-base"
onClick={() => navigate('/mentoring/ai-agent')}
>
Mulai Belajar Dengan AI
</Button>
@@ -147,7 +144,6 @@ export const CTASection: FC = () => {
type="button"
variant="secondary"
className="w-full h-auto py-2 text-[10px] font-medium md:w-max md:text-sm md:px-2.5 md:font-semibold lg:text-base"
onClick={() => navigate('/mentoring')}
>
Temukan Mentor
</Button>
@@ -5,11 +5,9 @@ import { motion, useInView, Variants } from 'framer-motion'
import { cn, For } from '@imphnen-frontend-service/utils'
const FAQ_DATA: AccordionOptions[] = [
{ title: 'Apakah saya bisa memilih mentor sendiri?', description: 'Tentu! Kamu bebas memilih mentor yang sesuai dengan kebutuhan, topik, dan level skill kamu. Setiap mentor punya profil, expertise, dan jadwal yang bisa kamu lihat sebelum booking.' },
{ title: 'Apakah mentoring ini cocok untuk pemula?', description: 'Yap! Baik newbie maupun seasoned dev bisa menemukan mentor yang pas. Tidak tahu harus mulai dari mana? Tanya AI Agent dulu untuk arahan belajar, lalu booking mentor untuk pendampingan yang lebih dalam.' },
{ title: 'Apakah ini seperti sekolah coding biasa?', description: 'Nope! Ini lebih fleksibel dari kursus pada umumnya. Rasa seperti punya mentor pribadi yang menyesuaikan pace belajarmu, plus AI Agent yang siap membantu kapan pun kamu macet.' },
{ title: 'Bagaimana sistem pembayarannya?', description: 'Kamu bayar per sesi mentoring yang sudah di-booking. Pembayaran diproses via Midtrans (Virtual Account / QRIS), dan sesi otomatis terkonfirmasi begitu pembayaran lunas.' },
{ title: 'Apakah ada materi yang bisa dipelajari gratis?', description: 'Ada! Halaman Materi berisi artikel dari para mentor yang bisa kamu baca kapan saja, dan AI Agent AI bisa menjawab pertanyaanmu berdasar materi tersebut tanpa biaya tambahan.' },
{ title: 'Apakah saya bisa memilih mentor sendiri?', description: 'Tentu! Kamu bisa memilih mentor yang sesuai dengan kebutuhan dan level skill kamu.' },
{ title: 'Apakah mentoring ini cocok untuk pemula?', description: 'Yap! Baik newbie maupun seasoned dev bisa menemukan mentor yang pas untuk mempercepat progres belajarnya.' },
{ title: 'Apakah ini seperti sekolah coding biasa?', description: 'Nope! Ini lebih dari sekadar kursus-kursus AI guide dan mentor berpengalaman, kamu mendapatkan pengalaman belajar yang jauh lebih personal dan efektif.' },
]
export const FAQSection: FC = () => {
@@ -74,7 +72,7 @@ export const FAQSection: FC = () => {
variant="bordered"
className="h-auto px-3 py-2 font-semibold bg-primary-100 md:text-base md:px-5 md:py-2.5 lg:text-2xl lg:px-6 lg:py-3"
>
Frequently Asked Questions
Frequently Ask Question
</Button>
</motion.div>
@@ -1,9 +1,7 @@
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { motion } from 'framer-motion'
import { useNavigate } from 'react-router-dom';
export function HeroSection() {
const navigate = useNavigate();
return (
<section className="relative w-full z-0 py-20 md:py-28 lg:py-32">
{/* Background gradients and shapes */}
@@ -65,7 +63,6 @@ export function HeroSection() {
type="button"
size="sm"
className="text-[10px] h-auto px-2.5 py-2 md:text-sm"
onClick={() => navigate('/mentoring/ai-agent')}
>
Belajar Dengan AI Sekarang
</Button>
@@ -8,33 +8,6 @@ export const TestimonialSection: FC = () => {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
const testimonials = [
{
name: 'Riko, Junior Web Developer',
quote:
'Rasanya seperti punya AI waifu yang ngajarin ngoding! Mentoringnya juga super insightful. Thanks, Dimentorin!',
rating: '4.8',
},
{
name: 'Salsa, Data Analyst',
quote:
'AI Agent-nya ngebantu banget pas stuck di konsep SQL yang rumit. Jawabannya selalu lengkap sama sumber materinya. Recommended!',
rating: '5.0',
},
{
name: 'Dimas, Mahasiswa Sistem Informasi',
quote:
'Awalnya takut tanya dosen, sekarang tinggal chat AI agent atau booking mentor. Progress belajarku naik drastis dalam 2 bulan!',
rating: '4.9',
},
{
name: 'Nadia, UI/UX Designer',
quote:
'Sensei-nya sabar banget jelasin dari dasar. Materi Rust & Tokio di sini juga disusun rapi, jadi gampang diikuti step by step.',
rating: '4.7',
},
]
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
@@ -84,7 +57,7 @@ export const TestimonialSection: FC = () => {
viewport={{ once: true }}
className="my-6 text-lg font-semibold mx-auto text-center text-primary-500 md:text-2xl lg:my-10 lg:text-4xl"
>
Words from our fellow devs
Words from our dellow devs
</motion.h1>
<div className="scrollbar-hide w-full mx-auto max-w-7xl px-8 overflow-auto md:px-[60px] xl:px-0">
@@ -94,8 +67,8 @@ export const TestimonialSection: FC = () => {
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
>
<For data={testimonials}>
{(testimonial, index) => (
<For data={Array.from({ length: 2 })}>
{(_, index) => (
<motion.div
key={index}
variants={childVariants}
@@ -110,10 +83,10 @@ export const TestimonialSection: FC = () => {
</div>
<div className="flex-1">
<h3 className="mb-3 font-semibold text-primary-500 md:text-lg lg:text-2xl">
{testimonial.name}
Riko, Junior Web Developer
</h3>
<p className="mb-6 text-[10px] text-neutral-500 font-medium md:text-sm lg:text-base">
{testimonial.quote}
Rasanya seperti punya AI waifu yang ngajarin ngoding! Mentoringnya juga super insightful. Thanks, Dimentorin!
</p>
<motion.div className="w-max" whileHover={{ scale: 1.1, rotate: -3 }}>
<Button
@@ -124,7 +97,7 @@ export const TestimonialSection: FC = () => {
>
<StarFilled className="md:text-lg" />
<span>
<span className="text-sm font-medium md:text-lg md:font-semibold lg:text-xl">{testimonial.rating}</span>
<span className="text-sm font-medium md:text-lg md:font-semibold lg:text-xl">4.8</span>
<span className="text-[10px] text-primary-300 md:text-sm md:font-medium lg:text-base">/5.0</span>
</span>
</Button>
@@ -72,8 +72,8 @@ export const WhatWeOfferSection: FC = () => {
<div
className="absolute bottom-0 inset-x-0 p-4 h-1/2 text-primary-50 bg-gradient-to-t from-primary-500 via-primary-500/80 flex flex-col items-center justify-end gap-y-1 md:items-start lg:px-6 lg:pb-8"
>
<p className="font-semibold lg:text-[23px] lg:font-bold">{offer.title}</p>
<p className="text-xs lg:text-[19px] lg:font-medium">{offer.description}</p>
<p className="font-semibold lg:text-[26px] lg:font-bold">{offer.title}</p>
<p className="text-xs lg:text-[22px] lg:font-medium">{offer.description}</p>
</div>
</motion.div>
)}
-52
View File
@@ -1,52 +0,0 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Link } from 'react-router-dom';
export default function NotFound() {
return (
<main className="relative min-h-[70vh] flex items-center justify-center overflow-hidden bg-primary-50 px-6">
{/* Decorative circles */}
<div className="pointer-events-none absolute -top-24 -right-24 size-72 rounded-full bg-primary-200 md:size-96" />
<div className="pointer-events-none absolute -bottom-28 -left-24 size-72 rounded-full bg-primary-100 md:size-96" />
<div className="relative z-10 mx-auto max-w-2xl text-center">
<p className="text-[46px] font-bold leading-none text-primary-500 md:text-[96px]">
404
</p>
<h1 className="mt-4 text-[23px] font-semibold text-neutral-800 md:text-[29px]">
Quest Tidak Ditemukan!
</h1>
<p className="mx-auto mt-3 max-w-md text-[15px] text-neutral-500 md:text-[19px]">
Sepertinya halaman ini pergi ke isekai tanpa kamu... atau alamatnya
salah ketik. Jangan khawatir, pahlawan sejati selalu tahu jalan
pulang! 🗡
</p>
<div className="mt-8 flex flex-col items-center justify-center gap-4 sm:flex-row">
<Link to="/">
<Button
type="button"
size="lg"
className="w-full sm:w-auto px-8"
>
Kembali ke Home
</Button>
</Link>
<Link to="/mentoring">
<Button
type="button"
size="lg"
variant="bordered"
className="w-full sm:w-auto px-8"
>
Cari Mentor
</Button>
</Link>
</div>
<p className="mt-8 text-[12px] text-neutral-400">
Level up &gt; 404 Not Found &gt; Berhasil kembali ke jalur quest
</p>
</div>
</main>
);
}
@@ -8,10 +8,6 @@ import { Link, NavLink, useLocation } from "react-router-dom";
const MENUS: { label: string; href: string }[] = [
{ label: "Home", href: "/" },
{ label: 'Mentoring', href: '/mentoring' },
{ label: 'Materi', href: '/mentoring/materi' },
{ label: 'AI Agent', href: '/mentoring/ai-agent' },
{ label: 'Dashboard', href: '/mentoring/dashboard' },
{ label: 'Sesi Saya', href: '/mentoring/my-sessions' },
{ label: 'Resources', href: '/resources' },
{ label: 'Articles', href: '/articles' },
]
@@ -1,19 +1,9 @@
import { Icon } from "@iconify/react";
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { cn, For, Show } from "@imphnen-frontend-service/utils";
import { cn } from "@imphnen-frontend-service/utils";
import { motion } from "framer-motion";
import { useParams } from "react-router-dom";
import { useGetArticleBySlug } from "@imphnen-frontend-service/service";
export default function DetailArticle() {
const { slug } = useParams()
const { data: article, isLoading, isError } = useGetArticleBySlug(slug ?? "")
const paragraphs = article?.content.split("\n").filter((p) => p.trim()) ?? []
const date = article?.created_at
? new Date(article.created_at).toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" })
: ""
return (
<main>
<motion.section
@@ -22,83 +12,76 @@ export default function DetailArticle() {
animate={{ opacity: 1 }}
transition={{ duration: 0.5, ease: "easeInOut" }}
>
<Show
condition={!isLoading && !isError && !!article}
fallback={
<div className="w-full max-w-[900px] mx-auto text-center text-neutral-500 py-20">
{isError ? "Artikel tidak ditemukan." : "Memuat artikel..."}
</div>
}
>
<div className="w-full max-w-[900px] mx-auto mb-8 md:mb-[60px]">
<div className="mb-10 flex items-center justify-between">
<Button
type="button"
variant="text"
className="flex gap-x-2 text-[10px] px-2.5 py-2 md:text-[15px]"
onClick={() => window.history.back()}
>
<Icon icon="material-symbols:arrow-back-rounded" />
<span>Back</span>
</Button>
<Button
type="button"
variant="text"
className="flex gap-x-2 text-[10px] px-2.5 py-2 md:text-[15px]"
>
<span>Read Next</span>
<Icon icon="material-symbols:arrow-forward-rounded" />
</Button>
</div>
<div className="text-center leading-tight">
<h1
className="text-[23px] text-neutral-800 font-semibold mb-6 md:text-[37px] md:mb-10 xl:text-[46px]"
>
{article?.title}
</h1>
<p
className="text-[15px] font-semibold text-neutral-800 mb-2 md:text-[19px] md:mb-1 xl:text-[23px] xl:mb-2"
>
{article?.author_name || "IMPHNEN Editorial"}
</p>
<p className="text-xs text-neutral-600 mb-6 md:text-[15px] xl:text-[19px]">
{date}
</p>
<div
className="text-[10px] text-primary-600 font-medium bg-primary-200 px-3 py-1 rounded-3xl w-max mx-auto md:text-xs xl:text-[15px]"
>
{article?.category}
</div>
</div>
</div>
<div className="w-full max-w-[900px] mx-auto">
<div className="mb-8 w-full h-auto aspect-16/10 overflow-hidden md:aspect-31/16 md:mb-[60px] xl:aspect-45/26">
<img
src={article?.cover_url ?? "/image/59de8a682dfbe42455613ea07477ff3e30532c1f.webp"}
alt="Cover Article"
className="size-full object-cover"
/>
</div>
<div
className={cn(
"text-balance text-[15px] font-medium text-neutral-600 space-y-8",
"md:space-y-12 md:text-[19px] xl:text-[23px] xl:max-w-[780px] xl:mx-auto",
)}
<div className="w-full max-w-[900px] mx-auto mb-8 md:mb-[60px]">
<div className="mb-10 flex items-center justify-between">
<Button
type="button"
variant="text"
className="flex gap-x-2 text-[10px] px-2.5 py-2 md:text-[15px]"
>
<p className="leading-tight">{article?.excerpt}</p>
<For data={paragraphs}>
{(paragraph) => (
<p key={paragraph.slice(0, 40)} className="leading-tight">
{paragraph}
</p>
)}
</For>
<Icon icon="material-symbols:arrow-back-rounded" />
<span>Back</span>
</Button>
<Button
type="button"
variant="text"
className="flex gap-x-2 text-[10px] px-2.5 py-2 md:text-[15px]"
>
<span>Read Next</span>
<Icon icon="material-symbols:arrow-forward-rounded" />
</Button>
</div>
<div className="text-center leading-tight">
<h1
className="text-[23px] text-neutral-800 font-semibold mb-6 md:text-[37px] md:mb-10 xl:text-[46px]"
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</h1>
<p
className="text-[15px] font-semibold text-neutral-800 mb-2 md:text-[19px] md:mb-1 xl:text-[23px] xl:mb-2"
>
Writer&rsquo;s Name
</p>
<p className="text-xs text-neutral-600 mb-6 md:text-[15px] xl:text-[19px]">
14 March 2025
</p>
<div
className="text-[10px] text-primary-600 font-medium bg-primary-200 px-3 py-1 rounded-4xl w-max mx-auto md:text-xs xl:text-[15px]"
>
5 min read
</div>
</div>
</Show>
</div>
<div className="w-full max-w-[900px] mx-auto">
<div className="mb-8 w-full h-auto aspect-16/10 overflow-hidden md:aspect-31/16 md:mb-[60px] xl:aspect-45/26">
<img src="/image/59de8a682dfbe42455613ea07477ff3e30532c1f.webp" alt="Cover Article" className="size-full object-cover" />
</div>
<div
className={cn(
"text-balance text-[15px] font-medium text-neutral-600 space-y-8",
"md:space-y-12 md:text-[19px] xl:text-[23px] xl:max-w-[780px] xl:mx-auto",
)}
>
<h2 className="text-[19px] text-neutral-800 font-semibold leading-tight md:text-[23px] xl:text-[29px]">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus.
</h2>
<p className="leading-tight">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
</p>
<img src="/image/code-article.webp" alt="Code" className="w-full h-auto mb-12" />
<p className="leading-tight">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
</p>
<p className="leading-tight">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
</p>
</div>
</div>
</motion.section>
</main>
)
@@ -2,26 +2,12 @@ import { Button } from "@imphnen-frontend-service/ui/atoms"
import { cn, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react"
import { Link } from "react-router-dom"
import { TArticleListItem } from "@imphnen-frontend-service/service"
export type ArticleCardProps = {
variant?: "default" | "recent" | "featured"
article?: TArticleListItem
}
function formatDate(iso?: string) {
if (!iso) return ""
return new Date(iso).toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" })
}
export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default', article }) => {
const title = article?.title ?? "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
const excerpt = article?.excerpt ?? "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
const author = article?.author_name ?? "IMPHNEN Editorial"
const date = formatDate(article?.created_at)
const slug = article?.slug ?? ""
const cover = article?.cover_url
export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default' }) => {
return (
<div
className={cn(
@@ -36,25 +22,25 @@ export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default', article
)}
>
<img
src={cover ?? "/image/59de8a682dfbe42455613ea07477ff3e30532c1f.webp"}
alt={title}
src="/image/59de8a682dfbe42455613ea07477ff3e30532c1f.webp"
alt="Cover's Article"
className={cn("w-full object-cover", variant === "recent" && "h-full")}
/>
</div>
<div className={cn("leading-tight", variant === "recent" && "xl:flex-1")}>
<div className="w-max bg-primary-200 text-primary-600 px-3 py-1 text-xs font-medium rounded-3xl mb-4 xl:mb-[15px]">
{article?.category ?? "Artikel"}
<div className="w-max bg-primary-200 text-primary-600 px-3 py-1 text-xs font-medium rounded-4xl mb-4 xl:mb-[15px]">
5 min read
</div>
<Link
to={slug ? `/articles/${slug}` : "/articles"}
to="/articles/detail"
className={cn(
"text-[15px] text-neutral-800 font-semibold mb-4 line-clamp-2 md:text-[19px] xl:mb-[23px]",
variant === "featured" && "text-[19px] md:text-[23px]",
variant === "recent" && "mb-7 xl:text-[29px]"
variant === "recent" && "mb-7 xl:text-[27px]"
)}
>
{title}
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</Link>
<Show condition={variant !== "recent"}>
<p
@@ -63,7 +49,7 @@ export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default', article
variant === "featured" && "text-[15px] md:text-[19px] md:mb-6",
)}
>
{excerpt}
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris.
</p>
</Show>
<div
@@ -73,16 +59,14 @@ export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default', article
variant === "featured" && "text-[15px] md:text-[19px] xl:text-base",
)}
>
<p>{author}</p>
<p>{date}</p>
<p>Writers Name</p>
<p>14 March 2025</p>
</div>
<Show condition={variant === "default"}>
<Link to={slug ? `/articles/${slug}` : "/articles"}>
<Button type="button" size="sm" className="px-2.5 py-2">
Baca Artikel
</Button>
</Link>
<Button type="button" size="sm" className="px-2.5 py-2">
Baca Artikel
</Button>
</Show>
</div>
</div>
@@ -1,22 +1,14 @@
import { ReactElement, useRef, useState } from "react";
import { ArticleCard } from "./_components/card/article";
import { cn, For, Show } from "@imphnen-frontend-service/utils";
import { cn, For } from "@imphnen-frontend-service/utils";
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { motion, useInView, Variants } from "framer-motion";
import { useGetArticleCategories, useGetArticles } from "@imphnen-frontend-service/service";
const CATEGORIES = ['UI/UX Design', 'Software/Web Dev', 'Data & AI', 'Cloud & DevOps', 'Cybersecurity', 'IT & Network', 'Project Management', 'QA & Testing'] as const
type Category = typeof CATEGORIES[number]
export default function Components(): ReactElement {
const [activeTab, setActiveTab] = useState<string>("Semua")
const { data: articles = [], isLoading } = useGetArticles({ per_page: 20 })
const { data: categories = [] } = useGetArticleCategories()
const tabs = ["Semua", ...categories]
const featured = articles[0]
const recent = articles.slice(1, 4)
const filtered = activeTab === "Semua"
? articles
: articles.filter((a) => a.category === activeTab)
const [activeTab, setActiveTab] = useState<Category>('UI/UX Design')
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
@@ -60,34 +52,29 @@ export default function Components(): ReactElement {
Featuring Articles
</motion.h1>
<Show
condition={!isLoading}
fallback={<p className="text-neutral-500">Memuat artikel...</p>}
>
<div className="grid gap-12 xl:grid-cols-2">
<motion.div variants={childVariants}>
<ArticleCard variant="featured" article={featured} />
</motion.div>
<div className="grid gap-12 xl:grid-cols-2">
<motion.div variants={childVariants}>
<ArticleCard variant="featured" />
</motion.div>
<div>
<motion.h1
className="text-[19px] text-neutral-800 font-semibold mb-10 md:text-[29px] xl:text-[37px] xl:mb-12"
variants={childVariants}
>
Recent Articles
</motion.h1>
<div className="grid gap-10">
<For data={recent}>
{(article, index) => (
<motion.div key={article.id ?? index} variants={childVariants}>
<ArticleCard variant="recent" article={article} />
</motion.div>
)}
</For>
</div>
<div>
<motion.h1
className="text-[19px] text-neutral-800 font-semibold mb-10 md:text-[29px] xl:text-[37px] xl:mb-12"
variants={childVariants}
>
Recent Articles
</motion.h1>
<div className="grid gap-10">
<For data={Array.from({ length: 3 })}>
{(_, index) => (
<motion.div key={index} variants={childVariants}>
<ArticleCard variant="recent" />
</motion.div>
)}
</For>
</div>
</div>
</Show>
</div>
</div>
<div className="max-w-7xl mx-auto">
@@ -99,7 +86,7 @@ export default function Components(): ReactElement {
</motion.h1>
<div className="scrollbar-hide mb-8 w-full overflow-auto mx-auto">
<motion.div className="min-w-max w-auto flex gap-x-3" variants={childVariants}>
<For data={tabs}>
<For data={CATEGORIES}>
{(category) => (
<Button
key={category}
@@ -107,7 +94,7 @@ export default function Components(): ReactElement {
variant="text"
onClick={() => setActiveTab(category)}
className={cn(
"relative text-[10px] text-neutral-400 font-medium px-3 py-2 rounded-3xl md:text-xs md:font-semibold xl:text-[15px]",
"relative text-[10px] text-neutral-400 font-medium px-3 py-2 rounded-4xl md:text-xs md:font-semibold xl:text-[15px]",
"before:w-0 before:absolute before:h-0.5 before:mx-auto before:inset-x-0 before:bg-primary-500 hover:before:w-full before:bottom-0 before:left-0 before:transition-all before:duration-300",
activeTab === category && "text-primary-500 before:w-full"
)}
@@ -119,20 +106,15 @@ export default function Components(): ReactElement {
</motion.div>
</div>
<Show
condition={filtered.length > 0}
fallback={<p className="text-neutral-500 text-center py-10">Belum ada artikel di kategori ini.</p>}
>
<div className="grid gap-8 xl:grid-cols-3">
<For data={filtered}>
{(article, index) => (
<motion.div key={article.id ?? index} variants={childVariants}>
<ArticleCard article={article} />
</motion.div>
)}
</For>
</div>
</Show>
<div className="grid gap-8 xl:grid-cols-3">
<For data={Array.from({ length: 3 })}>
{(_, index) => (
<motion.div key={index} variants={childVariants}>
<ArticleCard />
</motion.div>
)}
</For>
</div>
</div>
</motion.section>
</main>
@@ -1,220 +0,0 @@
import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { motion } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import { ArrowRightOutlined, FireOutlined, TrophyOutlined, ThunderboltOutlined } from '@ant-design/icons';
const STATS = [
{ value: '1000+', label: 'Kohai Menunggu' },
{ value: '500+', label: 'Sensei Aktif' },
];
const GUILD_CARDS = [
{
icon: <ThunderboltOutlined />,
title: 'Power-Up Para Kohai',
description:
'Setiap tips dan trik dari sensei bakal langsung boost skill mereka! Kayak main RPG tapi real life—level up yang beneran meaningful!',
},
{
icon: <FireOutlined />,
title: 'Skill Jadi Gold Mine',
description:
'Knowledge yang udah kamu grind bertahun-tahun sekarang bisa jadi sumber cuan! Farming EXP sambil farming income, double win!',
},
{
icon: <TrophyOutlined />,
title: 'Legendary Sensei Status',
description:
'Jadi mentor yang namanya diingat sampai kohai-mu jadi senior! Your legacy bakal hidup terus di setiap success story mereka.',
},
];
export const Components: FC = (): ReactElement => {
const navigate = useNavigate();
return (
<main>
{/* HERO */}
<section className="relative overflow-hidden bg-primary-50/60">
<div className="max-w-7xl mx-auto px-8 py-16 md:px-[60px] md:py-20 lg:px-20 lg:py-24 flex flex-col items-center text-center">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-3xl"
>
<h1 className="text-[29px] font-bold text-primary-500 leading-tight md:text-[37px] lg:text-[46px]">
Jadi Sensei di Dunia IT?
</h1>
<p className="mt-4 text-[15px] font-medium text-neutral-600 md:text-[19px]">
Waktunya naik level jadi mentor legendaris!
</p>
<div className="mt-10 flex justify-center gap-6 md:gap-12">
{STATS.map((s) => (
<div key={s.label} className="text-center">
<p className="text-[23px] font-bold text-primary-500 md:text-[29px]">
{s.value}
</p>
<p className="text-xs font-medium text-neutral-500 md:text-[15px]">
{s.label}
</p>
</div>
))}
</div>
<div className="mt-10">
<Button
type="button"
size="lg"
onClick={() => navigate('/auth/register-mentor')}
>
Mulai Jadi Sensei <ArrowRightOutlined />
</Button>
</div>
</motion.div>
</div>
</section>
{/* VALUE PROPOSITION */}
<section className="max-w-7xl mx-auto px-8 py-14 md:px-[60px] md:py-16 lg:px-20">
<div className="text-center mb-10">
<h2 className="text-[23px] font-bold text-neutral-800 md:text-[29px]">
Kenapa Harus Join Guild Sensei Kami? 🔥
</h2>
</div>
<div className="grid gap-6 md:grid-cols-3">
{GUILD_CARDS.map((card, i) => (
<motion.div
key={card.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.1 }}
className="bg-white border border-neutral-100 rounded-xl shadow-sm p-8 text-center"
>
<div className="mx-auto mb-5 flex size-16 items-center justify-center rounded-full bg-primary-100 text-3xl text-primary-500">
{card.icon}
</div>
<h3 className="text-[19px] font-semibold text-neutral-800 mb-3">
{card.title}
</h3>
<p className="text-sm text-neutral-500 leading-relaxed">
{card.description}
</p>
</motion.div>
))}
</div>
</section>
{/* TESTIMONIAL */}
<section className="bg-white py-14 md:py-16">
<div className="max-w-7xl mx-auto px-8 md:px-[60px] lg:px-20">
<div className="text-center mb-10">
<p className="text-xs font-semibold uppercase tracking-wider text-primary-500 mb-2">
Testimonial
</p>
<h2 className="text-[23px] font-bold text-neutral-800 md:text-[29px]">
What the Legendary Sensei Says
</h2>
</div>
<div className="grid gap-6 md:grid-cols-3">
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-primary-50/60 border border-primary-100 rounded-xl p-8"
>
<div className="flex items-center gap-4 mb-4">
<div className="size-12 rounded-full bg-primary-500 text-white flex items-center justify-center font-bold">
R
</div>
<div>
<p className="font-semibold text-neutral-800 text-[15px]">
Riko, Junior Web Developer
</p>
<p className="text-primary-500 text-xs">4.8/5.0 </p>
</div>
</div>
<p className="text-sm text-neutral-600 leading-relaxed">
Rasanya seperti punya AI waifu yang ngajarin ngoding!
Mentoringnya juga super insightful. Thanks, Dimentorin!
</p>
</div>
))}
</div>
</div>
</section>
{/* FAQ */}
<section className="bg-primary-50/60 py-14 md:py-16">
<div className="max-w-7xl mx-auto px-8 md:px-[60px] lg:px-20">
<div className="text-center mb-10">
<h2 className="text-[23px] font-bold text-neutral-800 md:text-[29px]">
Frequently Asked Questions
</h2>
<p className="mt-2 text-[15px] font-medium text-neutral-500">
Kata Para Sensei Legendaris
</p>
</div>
<div className="max-w-3xl mx-auto space-y-4">
{[
{
q: 'Berapa income yang bisa didapat?',
a: 'Kamu akan mendapatkan income 100rb/sesi/45 menit.',
},
{
q: 'Bagaimana sistem pembayarannya?',
a: 'Tentu! Kamu bisa memilih mentor yang sesuai dengan kebutuhan dan level skill kamu.',
},
{
q: 'Apakah bisa flexible dalam mengambil sesi?',
a: 'Tentu! Kamu bisa memilih mentor yang sesuai dengan kebutuhan dan level skill kamu.',
},
].map((faq, i) => (
<details
key={i}
className="group bg-white border border-neutral-100 rounded-lg p-5 open:shadow-sm"
>
<summary className="flex items-center justify-between cursor-pointer list-none font-semibold text-neutral-800 text-[15px]">
{faq.q}
<span className="text-primary-500 group-open:rotate-45 transition-transform">
+
</span>
</summary>
<p className="mt-3 text-sm text-neutral-500 leading-relaxed">
{faq.a}
</p>
</details>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="bg-primary-600 relative overflow-hidden py-16 md:py-20">
<div className="max-w-4xl mx-auto px-8 text-center relative z-10">
<h2 className="text-[23px] font-bold text-white md:text-[29px]">
Ready to become the Legendary Sensei?
</h2>
<p className="mt-4 text-[15px] text-primary-100">
Join ribuan expert lainnya yang sudah membuktikan impact mereka.
Your expertise is needed!
</p>
<div className="mt-8">
<Button
type="button"
size="lg"
variant="secondary"
onClick={() => navigate('/auth/register-mentor')}
>
Daftar Sekarang 🚀
</Button>
</div>
</div>
</section>
</main>
);
};
export default Components;
@@ -10,12 +10,6 @@ import { QrisPaymentStep } from "./steps/qris-payement"
import { VAPaymentStep } from "./steps/va-payment"
import { SuccessStep } from "./steps/success"
import { PaymentStep } from "./steps/payment"
import {
TBookSessionRequest,
TPayment,
usePostBookSession,
usePostCreatePayment,
} from "@imphnen-frontend-service/service"
const STEPS = ['topic', 'schedule', 'profile', 'payment', 'qr-payment', 'va-payment', 'success'] as const
type Step = typeof STEPS[number]
@@ -23,56 +17,15 @@ type Step = typeof STEPS[number]
type Props = {
open: boolean
setOpen: (open: boolean) => void
mentorId?: string
mentor?: {
fullname?: string | null
current_role?: string
current_company?: string
mentoring_rate?: number
}
}
export const AppointmentModal: FC<Props> = ({ open, setOpen, mentorId, mentor }) => {
export const AppointmentModal: FC<Props> = ({ open, setOpen }) => {
const [step, setStep] = useState<Step>('topic')
const [selectedTopics, setSelectedTopics] = useState<number[]>([])
const [booking, setBooking] = useState<TBookSessionRequest | null>(null)
const [payment, setPayment] = useState<TPayment | null>(null)
const [paymentMethod, setPaymentMethod] = useState<'va' | 'qris'>('va')
const [error, setError] = useState("")
const [sessionId, setSessionId] = useState("")
const bookMutation = usePostBookSession(mentorId ?? "")
const paymentMutation = usePostCreatePayment(sessionId)
const handleStep = async (action: 'next' | 'prev') => {
setError("")
if (step === 'payment' && action === 'next') {
if (!mentorId || !booking) {
setError("Data booking tidak lengkap.")
return
}
try {
// 1. Book the session -> get session id
const booked = await bookMutation.mutateAsync({ ...booking, topic: booking.topic || `Mentoring #${selectedTopics[0] ?? 1}` })
const sid = (booked as unknown as { data?: { id?: string } })?.data?.id ?? ""
if (!sid) {
setError("Gagal membuat sesi mentoring. Coba lagi.")
return
}
setSessionId(sid)
// 2. Create payment for the session with the chosen method
const pm = await paymentMutation.mutateAsync({ method: paymentMethod })
setPayment(pm)
// 3. Route to the method-specific payment screen
setStep(paymentMethod === 'qris' ? 'qr-payment' : 'va-payment')
} catch {
setError("Gagal membuat sesi mentoring. Coba lagi.")
}
return
}
const handleStep = (action: 'next' | 'prev') => {
if (action === 'next' && step === 'success') {
setOpen(false)
} else if (action === 'next' && (step === 'qr-payment' || step === 'va-payment')) {
setStep('success')
} else if (action === 'next') {
setStep(STEPS[STEPS.indexOf(step) + 1])
} else if (action === 'prev' && step !== 'topic') {
@@ -159,18 +112,14 @@ export const AppointmentModal: FC<Props> = ({ open, setOpen, mentorId, mentor })
)}
<AnimatePresence>
{step === 'topic' && <TopicStep selectedTopics={selectedTopics} setSelectedTopics={setSelectedTopics} mentor={mentor} />}
{step === 'schedule' && <ScheduleStep onChange={(data) => setBooking(data)} />}
{step === 'topic' && <TopicStep selectedTopics={selectedTopics} setSelectedTopics={setSelectedTopics} />}
{step === 'schedule' && <ScheduleStep />}
{step === 'profile' && <ProfileStep />}
{step === 'payment' && <PaymentStep selectedTopics={selectedTopics} mentor={mentor} booking={booking} onMethodChange={setPaymentMethod} />}
{step === 'qr-payment' && <QrisPaymentStep payment={payment} />}
{step === 'va-payment' && <VAPaymentStep payment={payment} />}
{step === 'payment' && <PaymentStep selectedTopics={selectedTopics} />}
{step === 'qr-payment' && <QrisPaymentStep />}
{step === 'va-payment' && <VAPaymentStep />}
{step === 'success' && <SuccessStep />}
</AnimatePresence>
{error && (
<p className="text-danger-600 text-xs mt-4 font-medium">{error}</p>
)}
</div>
<Show condition={step !== 'success'}>
@@ -199,10 +148,8 @@ export const AppointmentModal: FC<Props> = ({ open, setOpen, mentorId, mentor })
condition={step !== 'success'}
fallback="Halman Booking"
>
<Show condition={step !== 'payment'} fallback={bookMutation.isPending ? "Memproses..." : "Bayar Sekarang"}>
<Show condition={step !== 'profile'} fallback="Pembayaran">
Selanjutnya
</Show>
<Show condition={step !== 'payment'} fallback="Bayar Sekarang">
Selanjutnya
</Show>
</Show>
</Button>
@@ -1,34 +1,13 @@
import { For, Show } from "@imphnen-frontend-service/utils"
import { FC, useState } from "react"
import { FC } from "react"
import { motion } from "framer-motion"
import { TOPICS } from "../../../sections/topics"
import { TBookSessionRequest } from "@imphnen-frontend-service/service"
import { MentorAvatar } from "../../../../../_components/mentor-avatar"
type Props = {
selectedTopics: number[]
mentor?: {
fullname?: string | null;
current_role?: string;
current_company?: string;
mentoring_rate?: number;
} | null
booking?: TBookSessionRequest | null
onMethodChange: (method: 'va' | 'qris') => void
}
const BANK_OPTIONS = ['BCA', 'BRI', 'BNI', 'MANDIRI']
export const PaymentStep: FC<Props> = ({ selectedTopics, mentor, booking, onMethodChange }) => {
const rate = mentor?.mentoring_rate ?? 50000
const serviceFee = 2000
const [method, setMethod] = useState<'va' | 'qris'>('va')
const handleMethod = (next: 'va' | 'qris') => {
setMethod(next)
onMethodChange(next)
}
export const PaymentStep: FC<Props> = ({ selectedTopics }) => {
return (
<motion.div
className="bg-white px-6 py-5 rounded-md grid gap-6 md:grid-cols-2 md:gap-8 xl:grid-cols-9"
@@ -43,25 +22,15 @@ export const PaymentStep: FC<Props> = ({ selectedTopics, mentor, booking, onMeth
</p>
<div>
<div className="aspect-square mb-2 size-10 mx-auto rounded-full overflow-hidden md:size-20 md:ms-0">
<MentorAvatar
name={mentor?.fullname || "Mentor"}
size="sm"
className="w-full h-full rounded-full"
textClassName="text-sm md:text-xl"
/>
<img src="/image/testimonial.webp" alt="Mentor" className="w-full object-cover" />
</div>
<div className="w-44 mx-auto text-center md:ms-0 md:text-left md:mb-2 md:text-[15px] md:w-auto">
<h3 className="text-xs font-semibold mb-1 xl:text-[15px]">
{mentor?.fullname || "Mentor"}
Muhammad Firdaus Oi Oi Oi, S.H., M.H.
</h3>
<p className="text-[10px] text-neutral-600 md:text-xs">
{mentor?.current_role || "Mentor"} at {mentor?.current_company || "IMPHNEN"}
UI Designer at Oray orayan Studios
</p>
{booking?.scheduled_at && (
<p className="text-[10px] text-neutral-500 md:text-xs mt-1">
{new Date(booking.scheduled_at).toLocaleString("id-ID", { dateStyle: "full", timeStyle: "short" })}
</p>
)}
</div>
</div>
</div>
@@ -96,16 +65,15 @@ export const PaymentStep: FC<Props> = ({ selectedTopics, mentor, booking, onMeth
<p className="text-[8px] text-neutral-400 mb-2 md:text-[10px]">
Virtual Account
</p>
<div className="grid grid-cols-4 gap-2">
<For data={BANK_OPTIONS}>
<div className="grid grid-cols-3 gap-2 md:grid-cols-4">
<For data={['BCA', 'BRI', 'BNI', 'MANDIRI', 'BCA-Virtual', 'BNI-Virtual', 'MANDIRI-Virtual']}>
{(bank, index) => (
<div
key={index}
className="p-1 bg-white rounded-xs border border-primary-50 flex items-center gap-x-1 cursor-pointer"
onClick={() => handleMethod('va')}
className="p-1 bg-white rounded-xs border border-primary-50 flex items-center gap-x-1"
>
<input type="radio" name="payment" id={`va-${bank}`} checked={method === 'va'} readOnly className="size-2" />
<label htmlFor={`va-${bank}`} className="block">
<input type="radio" name="payment" id={bank} className="size-2" />
<label htmlFor={bank} className="block">
<img src="/image/payment/bca.webp" alt={bank} className="object-scale-down" />
</label>
</div>
@@ -117,12 +85,11 @@ export const PaymentStep: FC<Props> = ({ selectedTopics, mentor, booking, onMeth
<p className="text-[8px] text-neutral-400 mb-2 md:text-[10px]">
QRIS
</p>
<div className="grid grid-cols-4 gap-2">
<div className="grid grid-cols-3 gap-2 md:grid-cols-4">
<div
className="p-1 bg-white rounded-xs border border-primary-50 flex items-center gap-x-1 cursor-pointer"
onClick={() => handleMethod('qris')}
className="p-1 bg-white rounded-xs border border-primary-50 flex items-center gap-x-1"
>
<input type="radio" name="payment" id="QRIS" checked={method === 'qris'} readOnly className="size-2" />
<input type="radio" name="payment" id="QRIS" className="size-2" />
<label htmlFor="QRIS" className="block">
<img src="/image/payment/qris.webp" alt="QRIS" className="object-scale-down" />
</label>
@@ -137,14 +104,14 @@ export const PaymentStep: FC<Props> = ({ selectedTopics, mentor, booking, onMeth
</p>
<div className="grid grid-cols-2 space-y-2.5 text-[10px] text-neutral-400 font-medium md:text-xs md:space-y-3">
<p className="font-normal">Subtotal :</p>
<p className="text-right">Rp. {rate.toLocaleString("id-ID")}</p>
<p className="text-right">Rp. 50.000</p>
<p className="font-normal">Service Fee :</p>
<p className="text-right">Rp. {serviceFee.toLocaleString("id-ID")}</p>
<p className="text-right">Rp. 2.000</p>
<hr className="border-neutral-200 col-span-full mt-1" />
<p className="text-neutral-700">Total :</p>
<p className="text-right text-neutral-700">Rp. {(rate + serviceFee).toLocaleString("id-ID")}</p>
<p className="text-right text-neutral-700">Rp. 52.000</p>
</div>
</div>
</div>
@@ -1,7 +1,5 @@
import { For, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react"
import { For } from "@imphnen-frontend-service/utils"
import { motion } from "framer-motion"
import { TPayment } from "@imphnen-frontend-service/service"
const PAYMENT_STEP = [
'Buka aplikasi e-wallet atau m-banking kamu',
@@ -10,11 +8,7 @@ const PAYMENT_STEP = [
'Konfimasi pembayaran, dan proses selesai.'
]
type Props = {
payment?: TPayment | null
}
export const QrisPaymentStep: FC<Props> = ({ payment }) => {
export const QrisPaymentStep = () => {
return (
<motion.div
className="bg-white px-6 py-5 rounded-md md:flex md:justify-between md:gap-8"
@@ -44,14 +38,7 @@ export const QrisPaymentStep: FC<Props> = ({ payment }) => {
<p className="text-[10px] text-neutral-400 mb-1.5 font-medium md:text-xs">
Biaya yang harus dibayarkan
</p>
<p className="text-xs font-semibold text-neutral-700 md:text-[15px]">
Rp. {(payment?.total ?? 52000).toLocaleString("id-ID")}
</p>
<Show condition={!!payment?.externalRef && payment.provider !== 'midtrans'}>
<p className="text-[10px] text-neutral-400 mt-1 font-medium">
Ref: {payment?.externalRef}
</p>
</Show>
<p className="text-xs font-semibold text-neutral-700 md:text-[15px]">Rp. 52.000</p>
</div>
</div>
@@ -60,21 +47,9 @@ export const QrisPaymentStep: FC<Props> = ({ payment }) => {
QR Code Pembayaran
</p>
<div className="bg-primary-50 p-2.5 rounded-lg size-[120px] mx-auto md:size-[142px] md:me-0">
<Show
condition={!!payment?.externalRef && payment.provider === 'midtrans'}
fallback={<img src="/image/sample-qrcode.webp" alt="QR Code" className="w-full" />}
>
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=${encodeURIComponent(payment!.externalRef!)}`}
alt="QR Code Midtrans"
className="w-full"
/>
</Show>
<img src="/image/sample-qrcode.webp" alt="QR Code" className="w-full" />
</div>
<p className="text-[8px] text-neutral-500 text-center mt-2 md:text-[10px]">
Berlaku hingga {payment ? new Date(payment.expiresAt).toLocaleString("id-ID") : "-"}
</p>
</div>
</motion.div>
)
}
}
@@ -1,11 +1,9 @@
import { Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils"
import { motion } from "framer-motion"
import { useEffect, useState } from "react"
import { TBookSessionRequest } from "@imphnen-frontend-service/service"
const placeholder = `Hi [Nama Mentor], Saya [Nama Kamu] & saya berharap dapat memiliki sesi mentoring dengan Anda.
Saat ini, saya tertarik untuk mengejar __. Tujuan saya untuk sesi ini adalah __.
Saya ingin tahu secara khusus tentang ___.
@@ -15,27 +13,7 @@ Saya ingin tahu secara khusus tentang ___.
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
type Props = {
onChange: (data: TBookSessionRequest) => void
}
export const ScheduleStep = ({ onChange }: Props) => {
const [date, setDate] = useState("")
const [time, setTime] = useState("")
const [sessionType, setSessionType] = useState("online")
const [description, setDescription] = useState("")
useEffect(() => {
if (!date || !time) return
onChange({
topic: "Mentoring Session",
scheduled_at: `${date}T${time}:00`,
description,
duration_minutes: 60,
session_type: sessionType,
})
}, [date, time, sessionType, description])
export const ScheduleStep = () => {
return (
<motion.div
className="bg-white px-6 py-5 rounded-md md:px-6"
@@ -53,15 +31,15 @@ export const ScheduleStep = ({ onChange }: Props) => {
<div className="grid gap-2.5 md:grid-cols-2 md:gap-5">
<div>
<label className={labelClass}>Tanggal</label>
<Input type="date" className="min-w-full w-full" value={date} onChange={(e) => setDate(e.target.value)} />
<Input type="date" className="min-w-full w-full" />
</div>
<div>
<label className={labelClass}>Waktu</label>
<Input type="time" className="min-w-full w-full" value={time} onChange={(e) => setTime(e.target.value)} />
<Input type="time" className="min-w-full w-full" />
</div>
<div className="relative md:col-span-full">
<label className={labelClass}>Lokasi</label>
<Select className="min-w-full w-full" value={sessionType} onChange={(e) => setSessionType(e.target.value)}>
<Select className="min-w-full w-full">
<option value="online">Online</option>
<option value="offline">Offline</option>
</Select>
@@ -70,9 +48,7 @@ export const ScheduleStep = ({ onChange }: Props) => {
<label className={labelClass}>Pertanyaan Untuk Senpai</label>
<Textarea
className="min-w-full w-full h-40"
placeholder={placeholder}
value={description}
onChange={(e) => setDescription(e.target.value)} />
placeholder={placeholder} />
</div>
</div>
</motion.div>
@@ -16,11 +16,11 @@ export const SuccessStep = () => {
<div
className={cn(
"mb-3 flex justify-center items-center mx-auto size-[60px] bg-success-200 rounded-full",
"mb-3 flex justify-center items-center mx-auto size-[60px] bg-green-200 rounded-full",
"md:size-[100px] md:mb-7 xl:size-[120px] xl:mb-8"
)}
>
<div className="bg-success-500 rounded-full size-10 flex justify-center items-center text-white md:size-16 xl:size-20">
<div className="bg-green-500 rounded-full size-10 flex justify-center items-center text-white md:size-16 xl:size-20">
<CheckOutlined className="text-xl md:text-3xl xl:text-5xl" />
</div>
</div>
@@ -3,19 +3,13 @@ import { cn, For, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react"
import { motion } from "framer-motion"
import { TOPICS } from "../../../sections/topics"
import { MentorAvatar } from "../../../../../_components/mentor-avatar"
type Props = {
selectedTopics: number[]
setSelectedTopics: (topics: number[]) => void
mentor?: {
fullname?: string | null
current_role?: string
current_company?: string
}
}
export const TopicStep: FC<Props> = ({ selectedTopics, setSelectedTopics, mentor }) => {
export const TopicStep: FC<Props> = ({ selectedTopics, setSelectedTopics }) => {
const handleSelectTopic = (topicId: number) => {
if (selectedTopics.includes(topicId)) {
setSelectedTopics(selectedTopics.filter((id) => id !== topicId))
@@ -33,19 +27,14 @@ export const TopicStep: FC<Props> = ({ selectedTopics, setSelectedTopics, mentor
exit={{ opacity: 0, y: 20 }}
>
<div className="w-16 aspect-4/5 overflow-hidden md:w-[134px] xl:w-[140px]">
<MentorAvatar
name={mentor?.fullname || "Mentor"}
size="md"
className="w-full h-full rounded-none"
textClassName="text-lg md:text-3xl"
/>
<img src="/image/testimonial.webp" alt="Mentor" className="w-full object-cover" />
</div>
<div className="flex-1 pe-4">
<h3 className="text-xs font-semibold mb-1 line-clamp-2 md:text-[19px] md:mb-2 xl:text-[23px]">
{mentor?.fullname || "Mentor"}
Muhammad Firdaus Oi Oi Oi, S.H., M.H.
</h3>
<p className="text-[10px] line-clamp-2 text-neutral-600 md:text-[15px] xl:text-[19px]">
{mentor?.current_role || "Mentor"} at {mentor?.current_company || "IMPHNEN"}
UI Designer at Oray orayan Studios
</p>
</div>
</motion.div>
@@ -1,7 +1,5 @@
import { For, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react"
import { For } from "@imphnen-frontend-service/utils"
import { motion } from "framer-motion"
import { TPayment } from "@imphnen-frontend-service/service"
const PAYMENT_STEP = [
'Buka aplikasi e-wallet atau m-banking kamu',
@@ -10,11 +8,7 @@ const PAYMENT_STEP = [
'Konfimasi pembayaran, dan proses selesai.'
]
type Props = {
payment?: TPayment | null
}
export const VAPaymentStep: FC<Props> = ({ payment }) => {
export const VAPaymentStep = () => {
return (
<motion.div
className="bg-white px-6 py-5 rounded-md md:flex md:items-center md:gap-11"
@@ -30,7 +24,7 @@ export const VAPaymentStep: FC<Props> = ({ payment }) => {
<p className="text-[10px] text-neutral-400 mb-1.5">
Cara melakukan pembayaran
</p>
<ul className="list-decimal pl-2.5 mb-4 md:mb-7">
<ul className="list-decimal pl-2.5">
<For data={PAYMENT_STEP}>
{(step, index) => (
<li key={index} className="text-[10px] text-neutral-400 leading-tight font-medium">
@@ -39,15 +33,6 @@ export const VAPaymentStep: FC<Props> = ({ payment }) => {
)}
</For>
</ul>
<div className="text-center md:text-start">
<p className="text-[10px] text-neutral-400 mb-1.5 font-medium md:text-xs">
Biaya yang harus dibayarkan
</p>
<p className="text-xs font-semibold text-neutral-700 md:text-[15px]">
Rp. {(payment?.total ?? 52000).toLocaleString("id-ID")}
</p>
</div>
</div>
<div className="text-center md:text-start">
@@ -59,17 +44,12 @@ export const VAPaymentStep: FC<Props> = ({ payment }) => {
Kode Virtual Account
</p>
<p className="text-xs font-semibold text-neutral-700 mb-1.5 md:text-[15px]">
{payment?.externalRef || "8091239861969812"}
8091239861969812
</p>
<p className="text-[8px] text-neutral-400 md:text-[10px]">
A/N IMPHNEN
A/N Unknown
</p>
<Show condition={!!payment?.expiresAt}>
<p className="text-[8px] text-neutral-500 mt-2 md:text-[10px]">
Berlaku hingga {new Date(payment!.expiresAt).toLocaleString("id-ID")}
</p>
</Show>
</div>
</motion.div>
)
}
}
@@ -1,21 +1,12 @@
import { StarFilled } from "@ant-design/icons"
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { cn, For } from "@imphnen-frontend-service/utils"
import { TMentorDetail } from "@imphnen-frontend-service/service"
import { MentorAvatar } from "../../../_components/mentor-avatar"
type Props = {
onBook: () => void
mentor?: TMentorDetail
}
export const ProfileSection: React.FC<Props> = ({ onBook, mentor }) => {
const name = mentor?.fullname || mentor?.legal_name || "Mentor"
const role = mentor?.current_role || "Mentor"
const company = mentor?.current_company || "IMPHNEN"
const expertise = mentor?.expertise ?? []
const softSkills = mentor?.languages ?? []
export const ProfileSection: React.FC<Props> = ({ onBook }) => {
return (
<div className="bg-white px-4 py-5 space-y-6 md:px-8 md:pt-6 md:pb-0 xl:space-y-0 xl:py-[30px] xl:flex xl:gap-x-9 xl:justify-between">
<div
@@ -32,12 +23,7 @@ export const ProfileSection: React.FC<Props> = ({ onBook, mentor }) => {
"xl:aspect-5/6 md:h-60 md:rounded-lg"
)}
>
<MentorAvatar
name={name}
size="lg"
className="w-full h-full rounded-none"
textClassName="text-xl md:text-3xl lg:text-5xl"
/>
<img src="/image/testimonial.webp" alt="Mentor" className="w-full object-cover" />
</div>
<div className="md:max-w-[242px] xl:max-w-[306px]">
@@ -47,10 +33,10 @@ export const ProfileSection: React.FC<Props> = ({ onBook, mentor }) => {
"md:text-[19px] md:mb-2 md:text-start xl:text-[23px]",
)}
>
{name}
Muhammad Firdaus Oi Oi Oi, S.H., M.H.
</h1>
<p className="text-xs mb-4 text-neutral-600 md:mb-5 md:text-[15px] xl:text-[19px] xl:mb-5">
{role} at {company}
UI Designer at Oray orayan Studios
</p>
<div className="flex items-center gap-x-2.5 w-full max-w-max border border-primary-50 p-1.5 rounded-md mx-auto md:ms-0">
<div
@@ -70,7 +56,7 @@ export const ProfileSection: React.FC<Props> = ({ onBook, mentor }) => {
<div>
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Expertise</h2>
<div className="p-4 bg-primary-50 border border-primary-100 rounded-md flex flex-wrap gap-3">
<For data={expertise}>
<For data={['UI Design', 'UX Reseacrh']}>
{(item) => (
<div key={item} className="bg-primary-300 text-primary-600 px-3 py-2 rounded-md text-[10px] md:font-medium xl:text-xs xl:font-semibold">
{item}
@@ -82,7 +68,7 @@ export const ProfileSection: React.FC<Props> = ({ onBook, mentor }) => {
<div>
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Soft Skills</h2>
<div className="p-4 bg-primary-50 border border-primary-100 rounded-md flex flex-wrap gap-3">
<For data={softSkills}>
<For data={['Design Thinking', 'Communication', 'Problem Solving', '19:00 WIB']}>
{(item) => (
<div key={item} className="bg-primary-300 text-primary-600 px-3 py-2 rounded-md text-[10px] md:font-medium xl:text-xs xl:font-semibold">
{item}
@@ -95,12 +81,8 @@ export const ProfileSection: React.FC<Props> = ({ onBook, mentor }) => {
<div className="hidden md:flex xl:hidden justify-between">
<div>
<p className="text-primary-500 text-[15px] font-semibold">
{new Date().toLocaleDateString('id-ID', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' })}
</p>
<p className="text-neutral-500 text-xs font-medium">
{new Date().toLocaleDateString('id-ID', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
</p>
<p className="text-primary-500 text-[15px] font-semibold">Jum, 4 April 2025</p>
<p className="text-neutral-500 text-xs font-medium">Jum, 4 April 2025</p>
</div>
<Button type="button" size="sm" onClick={onBook}>
Book Your Senpai!
@@ -1,77 +1,68 @@
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { For, Show } from "@imphnen-frontend-service/utils"
import { useGetMentorAvailability } from "@imphnen-frontend-service/service"
import { cn, For } from "@imphnen-frontend-service/utils"
import { FC } from "react"
const SCHEDULES = [
{
date: "Kamis, 20 Maret 2025",
availableCount: 10,
schedule: [
{ time: "19:00 WIB", available: true },
{ time: "20:00 WIB", available: true },
{ time: "21:00 WIB", available: true },
{ time: "22:00 WIB", available: true },
]
},
{
date: "Jumat, 21 Maret 2025",
availableCount: 1,
schedule: [
{ time: "19:00 WIB", available: true },
{ time: "20:00 WIB", available: true },
{ time: "21:00 WIB", available: false },
{ time: "22:00 WIB", available: true },
]
}
]
type Props = {
onBook: () => void
mentorId?: string
}
const DAY_NAMES = ["Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"]
function formatDate(dateStr: string) {
const d = new Date(dateStr + "T00:00:00")
return `${DAY_NAMES[d.getDay()]}, ${d.getDate()} ${d.toLocaleString("id-ID", { month: "long" })} ${d.getFullYear()}`
}
function formatTime(timeStr: string) {
const [h] = timeStr.split(":").map(Number)
return `${String(h).padStart(2, "0")}:00 WIB`
}
export const SenpaiScheduleSection: FC<Props> = ({ onBook, mentorId }) => {
const { data: availability, isLoading } = useGetMentorAvailability(mentorId ?? "")
const slots = availability?.slots ?? []
const byDate = new Map<string, typeof slots>()
for (const s of slots) {
if (!byDate.has(s.date)) byDate.set(s.date, [])
byDate.get(s.date)!.push(s)
}
const days = [...byDate.entries()]
export const SenpaiScheduleSection: FC<Props> = ({ onBook }) => {
return (
<div>
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Senpai Schedule</h2>
<div className="p-4 bg-primary-50 border border-primary-100 rounded-md space-y-5">
<Show
condition={!isLoading && days.length > 0}
fallback={
<p className="text-xs text-neutral-500">
{isLoading ? "Memuat jadwal senpai..." : "Belum ada jadwal tersedia."}
</p>
}
>
<For data={days}>
{([date, daySlots]) => (
<div key={date}>
<div className="flex justify-between items-center mb-4">
<p className="text-[15px] font-medium">{formatDate(date)}</p>
<p className="rounded-3xl py-1 px-2.5 text-xs font-medium bg-primary-200 text-primary-500">
{daySlots.length} slot tersisa
</p>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-2">
<For data={daySlots}>
{(schedule) => (
<div
key={`${schedule.date}-${schedule.time}`}
className={`px-3 py-2 text-xs rounded-md font-semibold ${
schedule.available
? "bg-primary-300 text-primary-600"
: "bg-neutral-200 text-neutral-400 line-through"
}`}
>
{formatTime(schedule.time)}
</div>
)}
</For>
</div>
<For data={SCHEDULES}>
{(item) => (
<div key={item.date}>
<div className="flex justify-between items-center mb-4">
<p className="text-[15px] font-medium">{item.date}</p>
<p
className={cn(
"rounded-4xl py-1 px-2.5 text-xs font-medium",
item.availableCount > 5 ? "bg-primary-200 text-primary-500" : "bg-danger-100 text-danger-600"
)}
>
{item.availableCount} slot tersisa
</p>
</div>
)}
</For>
</Show>
<div className="flex gap-x-3">
<For data={item.schedule}>
{(schedule, index) => (
<div
key={index}
className="bg-primary-300 text-primary-600 px-3 py-2 text-xs rounded-md font-semibold"
>
{schedule.time}
</div>
)}
</For>
</div>
</div>
)}
</For>
<hr className="border-primary-200" />
@@ -81,4 +72,4 @@ export const SenpaiScheduleSection: FC<Props> = ({ onBook, mentorId }) => {
</div>
</div>
)
}
}
@@ -1,23 +1,14 @@
import { StarFilled } from "@ant-design/icons";
import { For } from "@imphnen-frontend-service/utils";
import { useGetMentorStats } from "@imphnen-frontend-service/service";
import { FC } from "react";
type Props = {
mentorId?: string;
};
const formatCount = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 100000 ? 0 : 1)}k` : `${n}`);
export const StatisticsSection: FC<Props> = ({ mentorId }) => {
const { data: stats } = useGetMentorStats(mentorId ?? "")
const SENPAI_STATISTIC: { name: string; count: string }[] = [
{ name: 'Total Sessions', count: formatCount(stats?.total_sessions ?? 0) },
{ name: 'Mentee Impact', count: formatCount(stats?.unique_mentees ?? 0) },
{ name: 'Avg Rating', count: stats && stats.avg_rating > 0 ? `${stats.avg_rating}/5` : '-' },
]
const SENPAI_STATISTIC = [
{ name: 'Total Sessions', count: 8 },
{ name: 'Mentee Impact', count: 1000 },
{ name: 'Response Time', count: '30 Minute' }
]
export const StatisticsSection: FC = () => {
return (
<div className="md:mb-10">
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Senpai Statistics</h2>
@@ -1,5 +1,4 @@
import { FC, useState } from 'react'
import { useParams } from 'react-router-dom'
import { ProfileSection } from './_components/sections/profile'
import { StatisticsSection } from './_components/sections/senpai-statistics'
import { TopicsSection } from './_components/sections/topics'
@@ -8,93 +7,44 @@ import { EducationSection } from './_components/sections/education'
import { SenpaiScheduleSection } from './_components/sections/senpai-schedule'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { AppointmentModal } from './_components/modals/appointment'
import { useGetMentorDetail } from '@imphnen-frontend-service/service'
import { Show } from '@imphnen-frontend-service/utils'
export const Components: FC = () => {
const [open, setOpen] = useState(false)
const { id } = useParams()
const { data: mentor, isLoading, isError } = useGetMentorDetail(id ?? '')
const buildResume = (): string => {
if (mentor?.bio) return mentor.bio
if (!mentor) return ''
const parts: string[] = []
const role = mentor.current_role
const company = mentor.current_company
const years = mentor.years_of_experience
const expertise = mentor.expertise
const industries = mentor.industries
const languages = mentor.languages
if (role || company) {
parts.push(`${role || 'Mentor'}${company && role ? ' at ' : company ? ' di ' : ''}${company || ''}.`)
}
if (years > 0) {
parts.push(`Memiliki pengalaman ${years} tahun di industri IT.`)
}
if (expertise.length > 0) {
parts.push(`Keahlian utama: ${expertise.join(', ')}.`)
}
if (industries.length > 0) {
parts.push(`Bidang yang digeluti: ${industries.join(', ')}.`)
}
if (languages.length > 0) {
parts.push(`Bahasa yang dikuasai: ${languages.join(', ')}.`)
}
if (parts.length === 0) {
return 'Profil senpai ini sedang dilengkapi. Segera cek kembali!'
}
return parts.join(' ')
}
const resume = buildResume()
return (
<main>
<Show
condition={!isLoading && !isError}
fallback={
<section className="w-full p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20">
<div className="max-w-7xl mx-auto text-center text-neutral-500 py-10">
{isError ? 'Mentor tidak ditemukan' : 'Memuat profil mentor...'}
<section className="w-full p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20">
<div className="max-w-7xl mx-auto space-y-8 md:bg-white xl:bg-transparent">
<ProfileSection onBook={() => setOpen(true)} />
<Button type="button" size="sm" className="w-full md:hidden" onClick={() => setOpen(true)}>
Book Your Senpai!
</Button>
<div className="bg-white px-4 py-5 md:px-8 md:pb-6 md:pt-0 xl:py-7 xl:flex xl:gap-x-10">
<div className="space-y-10 md:space-y-7 xl:flex-1">
<StatisticsSection />
<TopicsSection />
<div className="px-6 py-8 rounded-md shadow-md">
<h2 className="text-xs text-neutral-800 font-semibold mb-5 md:text-[15px] xl:text-[19px]">Senpai Resume</h2>
<p className="text-[10px] font-medium text-neutral-600 text-pretty md:text-[15px]">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
</p>
</div>
<ExperienceSection />
<EducationSection />
</div>
</section>
}
>
<section className="w-full p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20">
<div className="max-w-7xl mx-auto space-y-8 md:bg-white xl:bg-transparent">
<ProfileSection onBook={() => setOpen(true)} mentor={mentor} />
<Button type="button" size="sm" className="w-full md:hidden" onClick={() => setOpen(true)}>
Book Your Senpai!
</Button>
<div className="bg-white px-4 py-5 md:px-8 md:pb-6 md:pt-0 xl:py-7 xl:flex xl:gap-x-10">
<div className="space-y-10 md:space-y-7 xl:flex-1">
<StatisticsSection mentorId={mentor?.id} />
<TopicsSection />
<div className="px-6 py-8 rounded-md shadow-md">
<h2 className="text-xs text-neutral-800 font-semibold mb-5 md:text-[15px] xl:text-[19px]">Senpai Resume</h2>
<p className="text-[10px] font-medium text-neutral-600 text-pretty md:text-[15px]">
{resume || 'Profil senpai ini sedang dilengkapi. Segera cek kembali!'}
</p>
</div>
<ExperienceSection />
<EducationSection />
</div>
<div className="hidden xl:block xl:w-[400px]">
<SenpaiScheduleSection onBook={() => setOpen(true)} mentorId={mentor?.id} />
</div>
<div className="hidden xl:block xl:w-[400px]">
<SenpaiScheduleSection onBook={() => setOpen(true)} />
</div>
</div>
</section>
</div>
</section>
<AppointmentModal open={open} setOpen={setOpen} mentorId={mentor?.id} mentor={mentor} />
</Show>
<AppointmentModal open={open} setOpen={setOpen} />
</main>
)
}
@@ -1,162 +0,0 @@
import { FC, ReactElement } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { cn, SessionToken } from '@imphnen-frontend-service/utils';
import {
DashboardOutlined,
CompassOutlined,
BookOutlined,
TeamOutlined,
SettingOutlined,
LogoutOutlined,
ReadOutlined,
ProfileOutlined,
} from '@ant-design/icons';
type NavItem = {
label: string;
to: string;
icon: ReactElement;
end?: boolean;
};
type DashboardSidebarProps = {
role: 'mentee' | 'mentor';
};
const MENTEE_NAV: NavItem[] = [
{ label: 'Dashboard', to: '/mentoring/dashboard', icon: <DashboardOutlined />, end: true },
{ label: 'Roadmap Discovery', to: '/mentoring/ai-agent', icon: <CompassOutlined /> },
{ label: 'Learning Path', to: '/mentoring/materi', icon: <BookOutlined /> },
{ label: 'Mentoring', to: '/mentoring/my-sessions', icon: <TeamOutlined /> },
];
const MENTOR_NAV: NavItem[] = [
{ label: 'Dashboard', to: '/mentoring/mentor-dashboard', icon: <DashboardOutlined />, end: true },
{ label: 'Mentoring Setup', to: '/mentoring/mentor-dashboard?tab=setup', icon: <SettingOutlined /> },
{ label: 'List Mentee', to: '/mentoring/mentor-dashboard?tab=mentee', icon: <TeamOutlined /> },
{ label: 'Feedback', to: '/mentoring/mentor-dashboard?tab=feedback', icon: <ProfileOutlined /> },
];
// decode JWT payload (bukan verifikasi — cuma untuk tampilkan inisial/email)
const jwtPayload = (): { email?: string; name?: string } | null => {
const raw = SessionToken.get()?.token?.access_token;
if (!raw) return null;
try {
const part = raw.split('.')[1];
if (!part) return null;
const json = atob(part.replace(/-/g, '+').replace(/_/g, '/'));
const pad = json.length % 4 === 0 ? json : json + '='.repeat(4 - (json.length % 4));
return JSON.parse(pad);
} catch {
return null;
}
};
export const DashboardSidebar: FC<DashboardSidebarProps> = ({ role }): ReactElement => {
const location = useLocation();
const navigate = useNavigate();
const nav = role === 'mentee' ? MENTEE_NAV : MENTOR_NAV;
const isActive = (item: NavItem) => {
if (item.end) return location.pathname === item.to;
if (item.to.includes('?')) {
const [base, q] = item.to.split('?');
const [key, val] = q.split('=');
return location.pathname === base && new URLSearchParams(location.search).get(key) === val;
}
return location.pathname.startsWith(item.to);
};
const handleLogout = () => {
SessionToken.remove();
navigate('/');
};
return (
<aside className="hidden lg:flex w-[230px] shrink-0 flex-col bg-white border-r border-neutral-100 sticky top-0 h-screen">
<div className="px-6 py-6">
<p className="text-[19px] font-bold">
<span className="text-primary-500">Dimentorin</span>.dev
</p>
</div>
<nav className="flex-1 px-3 space-y-1">
{nav.map((item) => (
<Link
key={item.label}
to={item.to}
className={cn(
'flex items-center gap-3 rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
isActive(item)
? 'bg-primary-100/70 text-primary-600'
: 'text-neutral-500 hover:bg-neutral-50 hover:text-neutral-700'
)}
>
<span className="text-base">{item.icon}</span>
{item.label}
</Link>
))}
</nav>
<div className="p-3 border-t border-neutral-100">
<button
type="button"
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-lg px-4 py-2.5 text-sm font-medium text-neutral-500 hover:bg-danger-50 hover:text-danger-500 transition-colors"
>
<LogoutOutlined /> Log Out
</button>
</div>
</aside>
);
};
export const DashboardHeader: FC<{ title?: string; role?: 'mentee' | 'mentor' }> = ({
title,
role,
}): ReactElement => {
const payload = jwtPayload();
const name = payload?.name || payload?.email?.split('@')[0] || 'Pengguna';
const initial = name[0]?.toUpperCase() ?? 'U';
const roleLabel = role === 'mentor' ? 'Mentor' : 'Mentee';
return (
<header className="flex items-center justify-between gap-4 px-6 py-4 border-b border-neutral-100 bg-white/70 backdrop-blur sticky top-0 z-10">
<div>
{title && <h1 className="text-[19px] font-bold text-neutral-800">{title}</h1>}
<p className="text-xs text-neutral-400 hidden sm:block">Dimentorin.dev</p>
</div>
<div className="flex items-center gap-3">
<button type="button" aria-label="Notifications" className="size-9 rounded-full bg-neutral-50 hover:bg-neutral-100 flex items-center justify-center text-neutral-500">
<ReadOutlined />
</button>
<button type="button" aria-label="Settings" className="size-9 rounded-full bg-neutral-50 hover:bg-neutral-100 flex items-center justify-center text-neutral-500">
<SettingOutlined />
</button>
<div className="flex items-center gap-2">
<div className="size-9 rounded-full bg-primary-500 text-white flex items-center justify-center text-sm font-semibold">
{initial}
</div>
<div className="hidden sm:block">
<p className="text-sm font-semibold text-neutral-800 leading-tight">{name}</p>
<p className="text-xs text-neutral-400 capitalize">{roleLabel}</p>
</div>
</div>
</div>
</header>
);
};
export const DashboardLayout: FC<{
role: 'mentee' | 'mentor';
title?: string;
children: ReactElement | ReactElement[];
}> = ({ role, title, children }): ReactElement => (
<div className="flex min-h-screen bg-primary-50/40">
<DashboardSidebar role={role} />
<div className="flex-1 min-w-0 flex flex-col">
<DashboardHeader title={title} role={role} />
<main className="flex-1 px-6 py-6 lg:px-8">{children}</main>
</div>
</div>
);
@@ -1,256 +0,0 @@
import { FC, ReactElement, useEffect, useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { cn, Show } from '@imphnen-frontend-service/utils';
import { usePostSessionFeedback } from '@imphnen-frontend-service/service';
import { StarFilled, StarOutlined } from '@ant-design/icons';
import { toast } from 'sonner';
const RATING_LABELS: Record<number, string> = {
1: 'Sangat Tidak Puas',
2: 'Tidak Puas',
3: 'Cukup Puas',
4: 'Puas',
5: 'Sangat Puas',
};
type FeedbackModalProps = {
open: boolean;
sessionId: string;
mentorName?: string | null;
onClose: () => void;
onSuccess: () => void;
};
const RATING_QUESTIONS: { question: string; options: string[] }[] = [
{
question: 'Seberapa membantu jawaban mentor untuk kebutuhanmu?',
options: [
'Tidak Membantu',
'Kurang Membantu',
'Cukup Membantu',
'Membantu',
'Sangat Membantu',
],
},
{
question: 'Apakah kamu akan merekomendasikan platform ini ke rekanmu?',
options: ['Ya', 'Tidak', 'Mungkin'],
},
];
export const FeedbackModal: FC<FeedbackModalProps> = ({
open,
sessionId,
mentorName,
onClose,
onSuccess,
}): ReactElement => {
const [step, setStep] = useState<'rating' | 'detail'>('rating');
const [rating, setRating] = useState(0);
const [hoverRating, setHoverRating] = useState(0);
const [helpfulness, setHelpfulness] = useState('');
const [recommend, setRecommend] = useState('');
const [liked, setLiked] = useState('');
const [improve, setImprove] = useState('');
const [testimonial, setTestimonial] = useState('');
const mutation = usePostSessionFeedback(sessionId);
useEffect(() => {
if (open) {
setStep('rating');
setRating(0);
setHelpfulness('');
setRecommend('');
setLiked('');
setImprove('');
setTestimonial('');
}
}, [open]);
const handleSubmit = async () => {
const parts: string[] = [];
if (liked.trim()) parts.push(`Suka: ${liked.trim()}`);
if (improve.trim()) parts.push(`Tingkatkan: ${improve.trim()}`);
if (testimonial.trim()) parts.push(`Testimoni: ${testimonial.trim()}`);
if (helpfulness) parts.push(`Kebermanfaatan: ${helpfulness}`);
if (recommend) parts.push(`Rekomendasi: ${recommend}`);
const feedbackText = parts.join('. ') || 'Tidak ada komentar tambahan.';
try {
await mutation.mutateAsync({ rating, feedback: feedbackText });
toast.success('Feedback berhasil dikirim ✅ Terima kasih atas waktumu!');
onSuccess();
onClose();
} catch {
toast.error('Gagal mengirim feedback. Coba lagi ya, Senpai!');
}
};
const canNext =
rating > 0 && helpfulness !== '' && recommend !== '';
const canSubmit =
canNext || liked.trim() !== '' || improve.trim() !== '' || testimonial.trim() !== '';
return (
<Modal isOpen={open} onClose={onClose}>
<div className="p-6 w-full max-w-lg">
<div className="flex items-center justify-between mb-1">
<h2 className="text-[19px] font-bold text-neutral-800">
Feedback Mentor
</h2>
<span className="text-xs font-medium text-neutral-400">
{step === 'rating' ? '1 dari 2' : '2 dari 2'}
</span>
</div>
<p className="text-xs text-neutral-400 mb-4">
Sesi dengan {mentorName ?? 'mentormu'}
</p>
<Show
condition={step === 'rating'}
fallback={
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Apa yang kamu suka dari sesi ini?
</label>
<textarea
value={liked}
onChange={(e) => setLiked(e.target.value)}
rows={2}
className="w-full rounded-lg border border-neutral-200 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
placeholder="Ceritakan hal yang paling berkesan dari sesi ini..."
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Apa yang bisa ditingkatkan oleh mentor?
</label>
<textarea
value={improve}
onChange={(e) => setImprove(e.target.value)}
rows={2}
className="w-full rounded-lg border border-neutral-200 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
placeholder="Saran membangun untuk mentor..."
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Testimoni atau ucapan terima kasih untuk mentor
</label>
<textarea
value={testimonial}
onChange={(e) => setTestimonial(e.target.value)}
rows={2}
className="w-full rounded-lg border border-neutral-200 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
placeholder="Tulis testimoni singkat..."
/>
</div>
</div>
}
>
<div className="space-y-5">
<div className="text-center">
<p className="text-sm font-medium text-neutral-700 mb-3">
Seberapa puas kamu dengan sesi mentoring ini?
</p>
<div className="flex justify-center gap-1.5">
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
type="button"
aria-label={`Rating ${value}`}
onMouseEnter={() => setHoverRating(value)}
onMouseLeave={() => setHoverRating(0)}
onClick={() => setRating(value)}
className={cn(
'text-2xl transition-colors',
(hoverRating || rating) >= value
? 'text-warning-400'
: 'text-neutral-200'
)}
>
{(hoverRating || rating) >= value ? <StarFilled /> : <StarOutlined />}
</button>
))}
</div>
<p className="mt-2 text-xs text-neutral-500">
{rating > 0 ? RATING_LABELS[rating] : '1 = Sangat Tidak Puas, 5 = Sangat Puas'}
</p>
</div>
{RATING_QUESTIONS.map((q) => (
<div key={q.question}>
<p className="text-sm font-medium text-neutral-700 mb-2">{q.question}</p>
<div className="flex flex-wrap gap-2">
{q.options.map((opt) => {
const isSelected =
(q.question === RATING_QUESTIONS[0].question
? helpfulness
: recommend) === opt;
const setter =
q.question === RATING_QUESTIONS[0].question
? setHelpfulness
: setRecommend;
return (
<button
key={opt}
type="button"
onClick={() => setter(opt)}
className={cn(
'rounded-full border px-4 py-1.5 text-xs font-medium transition-colors',
isSelected
? 'border-primary-500 bg-primary-100/60 text-primary-600'
: 'border-neutral-200 text-neutral-500 hover:border-primary-300'
)}
>
{opt}
</button>
);
})}
</div>
</div>
))}
</div>
</Show>
<div className="mt-6 flex justify-between gap-3">
<Button
type="button"
size="sm"
variant="bordered"
onClick={step === 'rating' ? onClose : () => setStep('rating')}
>
{step === 'rating' ? 'Batal' : 'Kembali'}
</Button>
<Show
condition={step === 'rating'}
fallback={
<Button
type="button"
size="sm"
disabled={mutation.isPending || !canSubmit}
onClick={handleSubmit}
>
{mutation.isPending ? 'Mengirim...' : 'Kirim'}
</Button>
}
>
<Button
type="button"
size="sm"
disabled={!canNext}
onClick={() => setStep('detail')}
>
Lanjut
</Button>
</Show>
</div>
</div>
</Modal>
);
};
export default FeedbackModal;
@@ -1,69 +0,0 @@
import { FC } from 'react';
import { cn } from '@imphnen-frontend-service/utils';
type TMentorAvatarProps = {
name: string;
size?: 'sm' | 'md' | 'lg';
className?: string;
textClassName?: string;
};
// Warna gradient unik per nama — hash nama → pasangan warna dari palet brand
const GRADIENTS: [string, string][] = [
['#23a1eb', '#0b5b8c'],
['#5b8def', '#3a3f8f'],
['#14b8a6', '#0f766e'],
['#8b5cf6', '#5b21b6'],
['#f59e0b', '#b45309'],
['#ef4444', '#991b1b'],
['#ec4899', '#9d174d'],
['#06b6d4', '#0e7490'],
];
const SIZES: Record<'sm' | 'md' | 'lg', string> = {
sm: 'size-[60px] text-lg md:size-10 lg:size-12',
md: 'size-16 text-xl md:size-20',
lg: 'size-24 text-3xl md:size-32 lg:size-40 md:text-4xl lg:text-5xl',
};
const getInitials = (name: string): string => {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '?';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
};
const getGradient = (name: string): string => {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
}
const [from, to] = GRADIENTS[hash % GRADIENTS.length];
return `linear-gradient(135deg, ${from} 0%, ${to} 100%)`;
};
export const MentorAvatar: FC<TMentorAvatarProps> = ({
name,
size = 'md',
className,
textClassName,
}) => {
const displayName = name || 'Mentor';
return (
<div
className={cn(
'flex items-center justify-center rounded-md overflow-hidden shrink-0 select-none',
SIZES[size],
className
)}
style={{ background: getGradient(displayName) }}
aria-label={`Avatar ${displayName}`}
>
<span
className={cn('font-semibold text-white drop-shadow-sm', textClassName)}
>
{getInitials(displayName)}
</span>
</div>
);
};
@@ -1,73 +1,40 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { For } from '@imphnen-frontend-service/utils';
import { TMentorDetail } from '@imphnen-frontend-service/service';
import { FC } from 'react';
import { Link } from 'react-router-dom';
import { MentorAvatar } from './mentor-avatar';
type TMentorCardProps = {
mentor: TMentorDetail;
};
export const MentorCard: FC<TMentorCardProps> = ({ mentor }) => {
const experience =
mentor.years_of_experience > 0
? `${mentor.years_of_experience} Years Experience`
: 'New Mentor';
const role = mentor.current_role || 'Mentor';
const company = mentor.current_company || 'IMPHNEN';
const softSkills = mentor.expertise || [];
const shownSkills = softSkills.slice(0, 2);
const extraCount = Math.max(0, softSkills.length - shownSkills.length);
export const MentorCard: FC = () => {
return (
<div className="p-2.5 rounded-md bg-white shadow flex gap-x-4 items-start md:p-4 md:flex-col md:rounded-lg md:gap-y-4">
<MentorAvatar
name={mentor.fullname ?? mentor.legal_name ?? 'Mentor'}
size="sm"
className="md:w-full md:h-auto md:aspect-square"
/>
<div className="size-[60px] rounded-md overflow-hidden md:w-full md:h-auto md:aspect-square">
<img src="/image/testimonial.webp" alt="Mentor" className="w-full object-cover" />
</div>
<div>
<Button variant="text" size="sm" className="bg-primary-100 h-auto px-1.5 py-1 text-[8px] font-normal mb-2 hover:bg-primary-100 md:text-[10px] md:font-medium md:mb-4">
{experience}
3-5 Years Experience
</Button>
<h2 className="mb-1">
<Link to={`/mentoring/${mentor.id}`} className="text-xs font-semibold text-primary-500 md:text-[15px] md:font-semibold lg:text-[19px]">
{mentor.fullname || mentor.legal_name || 'Mentor'}
<Link to="/mentoring/detail" className="text-xs font-semibold text-primary-500 md:text-[15px] md:font-semibold lg:text-[19px]">
Fullname
</Link>
</h2>
<p className="text-[8px] text-neutral-500 mb-3 md:text-[10px] md:font-medium md:mb-4 lg:text-xs">
{role} at {company}
Full Stack Enjoyer at name company
</p>
{softSkills.length > 0 && (
<div>
<p className="text-[8px] text-neutral-500 mb-1 md:mb-2 lg:text-[10px]">Soft Skills :</p>
<div className="space-x-2">
<For data={shownSkills}>
{(skill) => (
<Button
key={skill}
variant="text"
size="sm"
className="bg-primary-200 h-auto px-1.5 py-1 text-[8px] font-normal hover:bg-primary-200 lg:text-[10px]"
>
{skill}
</Button>
)}
</For>
{extraCount > 0 && (
<Button
variant="text"
size="sm"
className="h-auto px-1.5 py-1 text-[8px] font-normal text-neutral-500 hover:bg-transparent lg:text-[10px]"
>
+{extraCount}
</Button>
)}
</div>
<div>
<p className="text-[8px] text-neutral-500 mb-1 md:mb-2 lg:text-[10px]">Soft Skills :</p>
<div className="space-x-2">
<Button variant="text" size="sm" className="bg-primary-200 h-auto px-1.5 py-1 text-[8px] font-normal hover:bg-primary-200 lg:text-[10px]">
Communication
</Button>
<Button variant="text" size="sm" className="bg-primary-200 h-auto px-1.5 py-1 text-[8px] font-normal hidden hover:bg-primary-200 md:inline-block lg:text-[10px]">
Communication
</Button>
<Button variant="text" size="sm" className="h-auto px-1.5 py-1 text-[8px] font-normal text-neutral-500 hover:bg-transparent lg:text-[10px]">
+2
</Button>
</div>
)}
</div>
</div>
</div>
)
@@ -1,154 +0,0 @@
import { FC, ReactElement, useRef, useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { SendOutlined } from '@ant-design/icons';
import { usePostChat } from '@imphnen-frontend-service/service';
import type { TChatResponse } from '@imphnen-frontend-service/service';
type Message = {
role: 'user' | 'assistant';
content: string;
sources?: TChatResponse['sources'];
};
export const Components: FC = (): ReactElement => {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const chatMutation = usePostChat();
const inputRef = useRef<HTMLInputElement>(null);
const handleSend = async () => {
const question = input.trim();
if (!question || loading) return;
setInput('');
setMessages((m) => [...m, { role: 'user', content: question }]);
setLoading(true);
try {
const res = await chatMutation.mutateAsync({ question });
setMessages((m) => [
...m,
{ role: 'assistant', content: res.answer, sources: res.sources },
]);
} catch {
setMessages((m) => [
...m,
{
role: 'assistant',
content: 'Maaf, terjadi kesalahan saat menjawab. Coba lagi ya.',
},
]);
} finally {
setLoading(false);
inputRef.current?.focus();
}
};
return (
<main className="px-4 md:px-20 py-10">
<div className="mb-6">
<h1 className="text-3xl font-semibold text-primary-500 mb-2">
AI Agent Mentoring
</h1>
<p className="text-neutral-500">
Tanya apa saja tentang materi mentoring jawaban diambil langsung
dari konten materi yang tersedia (RAG).
</p>
</div>
<div className="bg-white rounded-2xl border border-neutral-100 shadow-sm max-w-3xl mx-auto">
{/* Chat body */}
<div className="h-[420px] overflow-y-auto p-5 space-y-4">
{messages.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center">
<div className="size-14 rounded-full bg-primary-50 flex items-center justify-center mb-3">
<SendOutlined className="text-primary-500 text-xl" />
</div>
<p className="text-sm text-neutral-400 max-w-sm">
Coba tanya: <i>&ldquo;Apa itu ownership di Rust?&rdquo;</i>{' '}
atau <i>&ldquo;Gimana cara buat route di Axum?&rdquo;</i>
</p>
</div>
) : (
messages.map((msg, idx) => (
<div
key={idx}
className={
msg.role === 'user'
? 'flex justify-end'
: 'flex justify-start'
}
>
<div
className={
msg.role === 'user'
? 'bg-primary-500 text-white rounded-2xl rounded-br-sm px-4 py-2.5 max-w-[80%] text-sm whitespace-pre-wrap'
: 'bg-neutral-50 border border-neutral-100 rounded-2xl rounded-bl-sm px-4 py-2.5 max-w-[85%] text-sm text-neutral-700 whitespace-pre-wrap'
}
>
{msg.content}
{msg.sources && msg.sources.length > 0 && (
<div className="mt-3 pt-2 border-t border-neutral-200">
<p className="text-[10px] uppercase tracking-wide text-neutral-400 mb-1">
Sumber materi
</p>
{msg.sources.map((s, i) => (
<div
key={i}
className="flex items-center justify-between gap-2 mb-1"
>
<span className="text-[12px] text-neutral-600 truncate">
📄 {s.title}
</span>
<span className="text-[10px] text-neutral-400 shrink-0">
{Math.round(s.score * 100)}%
</span>
</div>
))}
</div>
)}
</div>
</div>
))
)}
{loading && (
<div className="flex justify-start">
<div className="bg-neutral-50 border border-neutral-100 rounded-2xl rounded-bl-sm px-4 py-2.5 text-sm text-neutral-400">
<span className="inline-flex gap-1">
<span className="animate-bounce"></span>
<span className="animate-bounce [animation-delay:0.1s]">
</span>
<span className="animate-bounce [animation-delay:0.2s]">
</span>
</span>
</div>
</div>
)}
</div>
{/* Input */}
<div className="border-t border-neutral-100 p-4 flex gap-2">
<input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="Tanya tentang materi mentoring..."
className="flex-1 px-4 py-2.5 rounded-xl border border-neutral-200 focus:border-primary-400 focus:outline-none text-sm"
/>
<Button
variant="primary"
className="shrink-0"
onClick={handleSend}
disabled={loading || !input.trim()}
>
<SendOutlined />
</Button>
</div>
</div>
</main>
);
};
export default Components;
@@ -1,128 +0,0 @@
import { FC, ReactElement } from 'react';
import { Link } from 'react-router-dom';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Show, cn, SessionToken } from '@imphnen-frontend-service/utils';
import {
useGetMySessions,
useGetMaterialsList,
useGetArticles,
} from '@imphnen-frontend-service/service';
import { DashboardLayout } from '../_components/dashboard-layout';
import {
VideoCameraOutlined,
FileDoneOutlined,
CheckCircleOutlined,
ArrowRightOutlined,
} from '@ant-design/icons';
const StatCard: FC<{ icon: ReactElement; label: string; value: number | string }> = ({
icon,
label,
value,
}): ReactElement => (
<div className="bg-white rounded-xl p-5 border border-neutral-100 shadow-sm">
<div className="flex items-center gap-3">
<div className="size-10 rounded-lg bg-primary-100 text-primary-500 flex items-center justify-center text-lg">
{icon}
</div>
<div>
<p className="text-[23px] font-bold text-neutral-800 leading-none">{value}</p>
<p className="text-xs text-neutral-500 mt-1">{label}</p>
</div>
</div>
</div>
);
const DashboardPage: FC = (): ReactElement => {
const { data: sessions } = useGetMySessions();
const { data: materials } = useGetMaterialsList();
const { data: articles } = useGetArticles({});
const sessionCount = sessions?.sessions?.length ?? 0;
const completed = sessions?.sessions?.filter((s) => s.status === 'completed').length ?? 0;
const mats = materials?.data ?? [];
const totalSteps = mats.length || 1;
const doneSteps = Math.ceil(totalSteps * 0.35);
const progress = Math.round((doneSteps / totalSteps) * 100);
return (
<DashboardLayout role="mentee">
{/* Welcome banner */}
<div className="relative overflow-hidden rounded-2xl bg-primary-500 text-white px-8 py-8 md:px-10">
<div className="relative z-10 max-w-lg">
<h2 className="text-[23px] font-bold md:text-[29px] leading-tight">
Selamat Datang di Dimentorin.dev
</h2>
<p className="mt-3 text-sm text-primary-100 leading-relaxed">
Temukan roadmap belajar impianmu lewat Skill Discovery, lalu biarkan
AI dan mentor profesional membimbingmu step by step sampai jadi
Full Stack Developer 🚀
</p>
<Link to="/mentoring/ai-agent" className="mt-6 inline-block">
<Button variant="secondary" size="lg" className="gap-2">
Temukan Roadmapnya <ArrowRightOutlined />
</Button>
</Link>
</div>
<div className="absolute -right-8 -bottom-8 size-48 rounded-full bg-primary-400/40 blur-2xl" />
<div className="absolute -right-4 -top-10 size-32 rounded-full bg-white/10 blur-xl" />
<div className="absolute right-16 bottom-4 hidden md:flex size-20 items-center justify-center rounded-2xl bg-white/15 border border-white/20 text-3xl">
🎓
</div>
</div>
{/* Overview */}
<section className="mt-6">
<h3 className="text-[19px] font-semibold text-neutral-800 mb-4">Overview</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<StatCard icon={<VideoCameraOutlined />} label="Mentoring Session" value={sessionCount} />
<StatCard icon={<FileDoneOutlined />} label="Article Submitted" value={articles?.length ?? 0} />
<StatCard icon={<CheckCircleOutlined />} label="Article Published" value={completed} />
</div>
</section>
{/* Roadmaps / Learning Path progress */}
<section className="mt-8">
<h3 className="text-[19px] font-semibold text-neutral-800 mb-4">Roadmaps</h3>
<Show
condition={mats.length > 0}
fallback={
<div className="bg-white rounded-xl border border-neutral-100 shadow-sm p-8 text-center">
<p className="text-neutral-500 mb-4">Belum ada materi roadmap.</p>
<Link to="/mentoring/ai-agent">
<Button variant="primary" className="gap-2">
Mulai Skill Discovery <ArrowRightOutlined />
</Button>
</Link>
</div>
}
>
<div className="bg-white rounded-xl p-6 border border-neutral-100 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-4 mb-4">
<div>
<p className="font-semibold text-neutral-800">Front End Basic</p>
<p className="text-sm text-neutral-500 mt-0.5">
{doneSteps} of {totalSteps} steps milestone completed
</p>
</div>
<Link to="/mentoring/materi">
<Button size="sm" variant="bordered" className="gap-1">
Lanjut Belajar <ArrowRightOutlined />
</Button>
</Link>
</div>
<div className="h-2.5 rounded-full bg-neutral-100 overflow-hidden">
<div
className={cn('h-full rounded-full bg-primary-500 transition-all')}
style={{ width: `${Math.min(progress, 100)}%` }}
/>
</div>
<div className="mt-2 text-right text-xs text-neutral-400">{progress}%</div>
</div>
</Show>
</section>
</DashboardLayout>
);
};
export default DashboardPage;
@@ -1,53 +0,0 @@
import { FC, ReactElement } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { useGetMaterialBySlug } from '@imphnen-frontend-service/service';
export const Components: FC = (): ReactElement => {
const { slug } = useParams<{ slug: string }>();
const { data: material, isLoading } = useGetMaterialBySlug(slug ?? '');
return (
<main className="px-4 md:px-20 py-10">
<Link to="/mentoring/materi" className="inline-block mb-6">
<Button size="sm" variant="text">
<ArrowLeftOutlined /> Kembali ke Materi
</Button>
</Link>
{isLoading || !material ? (
<div className="animate-pulse bg-neutral-100 h-72 rounded-2xl" />
) : (
<article className="max-w-3xl">
<span className="inline-block px-2 py-0.5 rounded-full text-[12px] font-medium mb-3 bg-neutral-100 text-neutral-600">
{material.category}
</span>
<h1 className="text-3xl font-semibold text-neutral-800 mb-3">
{material.title}
</h1>
<p className="text-neutral-500 mb-6">{material.description}</p>
<div className="bg-white rounded-2xl border border-neutral-100 p-8 shadow-sm">
{/* SAFE: all '<' escaped to &lt; below, no raw HTML passes through */}
<div
className="prose prose-sm max-w-none prose-neutral"
dangerouslySetInnerHTML={{
__html: material.content
.split('\n')
.map((p) => p.trim())
.filter(Boolean)
.map(
(p) =>
`<p class="mb-3 text-neutral-700 leading-relaxed">${p.replace(/</g, '&lt;')}</p>`
)
.join(''),
}}
/>
</div>
</article>
)}
</main>
);
};
export default Components;
@@ -1,92 +0,0 @@
import { FC, ReactElement } from 'react';
import { Link } from 'react-router-dom';
import { Show } from '@imphnen-frontend-service/utils';
import {
useGetMaterialCategories,
useGetMaterialsList,
} from '@imphnen-frontend-service/service';
const CATEGORY_COLORS: Record<string, string> = {
Backend: 'bg-primary-100 text-primary-700',
Frontend: 'bg-info-100 text-info-800',
Data: 'bg-success-100 text-success-700',
Mobile: 'bg-warning-100 text-warning-800',
Cybersecurity: 'bg-danger-100 text-danger-700',
Default: 'bg-neutral-100 text-neutral-600',
};
export const Components: FC = (): ReactElement => {
const { data: materials, isLoading } = useGetMaterialsList();
const { data: categories } = useGetMaterialCategories();
return (
<main className="px-4 md:px-20 py-10">
<div className="mb-8">
<h1 className="text-3xl font-semibold text-primary-500 mb-2">
Materi Mentoring
</h1>
<p className="text-neutral-500">
Kumpulan materi dari para mentor IMPHNEN untuk memperdalam
keterampilan kamu.
</p>
</div>
{/* Category filter chips */}
<Show condition={!!categories?.length}>
<div className="flex flex-wrap gap-2 mb-6">
{categories?.map((cat) => (
<span
key={cat}
className={`px-3 py-1 rounded-full text-xs font-medium ${
CATEGORY_COLORS[cat] ?? CATEGORY_COLORS.Default
}`}
>
{cat}
</span>
))}
</div>
</Show>
{isLoading ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[0, 1, 2].map((i) => (
<div
key={i}
className="animate-pulse bg-neutral-100 h-44 rounded-2xl"
/>
))}
</div>
) : !materials?.data?.length ? (
<p className="text-neutral-400">Belum ada materi tersedia.</p>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{materials.data.map((m) => {
const color =
CATEGORY_COLORS[m.category] ?? CATEGORY_COLORS.Default;
return (
<Link
key={m.id}
to={`/mentoring/materi/${m.slug}`}
className="group bg-white rounded-2xl border border-neutral-100 p-6 shadow-sm hover:shadow-md transition-shadow"
>
<span
className={`inline-block px-2 py-0.5 rounded-full text-[12px] font-medium mb-3 ${color}`}
>
{m.category}
</span>
<h3 className="font-semibold text-neutral-800 group-hover:text-primary-500 transition-colors leading-snug mb-2">
{m.title}
</h3>
<p className="text-xs text-neutral-500 leading-relaxed line-clamp-2">
{m.description}
</p>
</Link>
);
})}
</div>
)}
</main>
);
};
export default Components;
@@ -1,435 +0,0 @@
import { FC, ReactElement, useMemo } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { For, Show, cn, SessionToken } from '@imphnen-frontend-service/utils';
import {
useGetMentorMe,
useGetMentorStats,
useGetMentorSessions,
useGetSessionPayments,
usePostConfirmPayment,
usePostRefreshPayment,
} from '@imphnen-frontend-service/service';
import {
ReloadOutlined,
CheckOutlined,
StarOutlined,
CheckCircleOutlined,
TeamOutlined,
MessageOutlined,
CalendarOutlined,
ClockCircleOutlined,
DollarOutlined,
} from '@ant-design/icons';
import { toast } from 'sonner';
import { DashboardLayout } from '../_components/dashboard-layout';
const STATUS_STYLE: Record<string, string> = {
pending: 'bg-warning-100 text-warning-800',
confirmed: 'bg-success-100 text-success-700',
completed: 'bg-primary-100 text-primary-700',
cancelled: 'bg-danger-100 text-danger-600',
};
const PAYMENT_STYLE: Record<string, string> = {
pending: 'bg-warning-100 text-warning-800',
paid: 'bg-success-100 text-success-700',
expired: 'bg-neutral-200 text-neutral-600',
};
const formatDate = (iso: string) => {
try {
return new Date(iso).toLocaleString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return iso;
}
};
const formatRupiah = (n: number) =>
new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(n);
const StatCard: FC<{ icon: ReactElement; label: string; value: number | string }> = ({
icon,
label,
value,
}): ReactElement => (
<div className="bg-white rounded-xl p-5 border border-neutral-100 shadow-sm">
<div className="flex items-center gap-3">
<div className="size-10 rounded-lg bg-primary-100 text-primary-500 flex items-center justify-center text-lg">
{icon}
</div>
<div>
<p className="text-[23px] font-bold text-neutral-800 leading-none">{value}</p>
<p className="text-xs text-neutral-500 mt-1">{label}</p>
</div>
</div>
</div>
);
const TABS = [
{ key: 'sessions', label: 'Sesi Masuk' },
{ key: 'setup', label: 'Mentoring Setup' },
{ key: 'mentee', label: 'List Mentee' },
{ key: 'feedback', label: 'Feedback' },
];
const SessionCard: FC<{ sessionId: string }> = ({ sessionId }) => {
const { data: payments } = useGetSessionPayments(sessionId);
const refreshPayment = usePostRefreshPayment();
const confirmPayment = usePostConfirmPayment();
const payment = (payments ?? [])[0];
return (
<Show condition={!!payment && payment.status === 'pending'}>
<div className="flex flex-wrap items-center justify-between gap-2 px-3 py-2 rounded-md text-xs font-medium bg-neutral-50 border border-neutral-100">
<span
className={cn(
'px-2 py-0.5 rounded-full capitalize',
PAYMENT_STYLE[payment?.status ?? ''] ?? ''
)}
>
Pembayaran: {payment?.status}
</span>
<span>{payment ? formatRupiah(payment.total) : ''}</span>
<div className="flex gap-2">
<Show condition={!!payment && payment.provider === 'midtrans'}>
<Button
size="sm"
variant="secondary"
className="gap-2"
disabled={refreshPayment.isPending}
onClick={async () => {
try {
if (payment) await refreshPayment.mutateAsync(payment.id);
toast.success('Status pembayaran diperbarui');
} catch {
toast.error('Gagal memperbarui status');
}
}}
>
<ReloadOutlined /> Refresh
</Button>
</Show>
<Button
size="sm"
variant="success"
className="gap-2"
disabled={confirmPayment.isPending}
onClick={async () => {
try {
if (payment) await confirmPayment.mutateAsync(payment.id);
toast.success('Pembayaran dikonfirmasi ✅');
} catch {
toast.error('Gagal mengonfirmasi pembayaran');
}
}}
>
<CheckOutlined /> Konfirmasi
</Button>
</div>
</div>
</Show>
);
};
export const Components: FC = (): ReactElement => {
const { data: mentor, isLoading: loadingMentor } = useGetMentorMe();
const mentorId = mentor?.id ?? '';
const { data: stats } = useGetMentorStats(mentorId);
const { data: sessions, isLoading } = useGetMentorSessions(mentorId);
const [params, setParams] = useSearchParams();
const tab = params.get('tab') ?? 'sessions';
const all = sessions?.sessions ?? [];
const sessionCount = all.length;
const completedCount = all.filter((s) => s.status === 'completed').length;
const confirmedCount = all.filter((s) => s.status === 'confirmed').length;
const pendingCount = all.filter((s) => s.status === 'pending').length;
const menteeCount = new Set(
all.map((s) => s.mentee_email ?? s.mentee_fullname ?? '')
).size;
const isMentor = !loadingMentor && !!mentor;
const setTab = (key: string) => {
if (key === 'sessions') params.delete('tab');
else params.set('tab', key);
setParams(params, { replace: true });
};
const content = useMemo(() => {
switch (tab) {
case 'setup':
return (
<div className="bg-white rounded-xl p-8 border border-neutral-100 shadow-sm">
<div className="flex items-center gap-3 mb-4">
<div className="size-11 rounded-xl bg-primary-100 text-primary-500 flex items-center justify-center text-xl">
<CalendarOutlined />
</div>
<div>
<h3 className="font-semibold text-neutral-800">Mentoring Setup</h3>
<p className="text-sm text-neutral-500">
Atur jadwal sesi & topik mentoring kamu.
</p>
</div>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<div className="rounded-lg border border-neutral-100 p-5">
<p className="text-sm font-medium text-neutral-700 mb-3 flex items-center gap-2">
<ClockCircleOutlined className="text-primary-500" /> Waktu Sesi
</p>
<p className="text-xs text-neutral-500">
Sesi mentoring kamu berjalan {confirmedCount > 0 ? `${confirmedCount} terkunci` : 'belum ada yang terkunci'}. Jadwal sesi tampil otomatis setelah mentee booking & pembayaran lunas.
</p>
</div>
<div className="rounded-lg border border-neutral-100 p-5">
<p className="text-sm font-medium text-neutral-700 mb-3 flex items-center gap-2">
<DollarOutlined className="text-primary-500" /> Monetization
</p>
<p className="text-xs text-neutral-500">
{pendingCount > 0
? `${pendingCount} sesi menunggu konfirmasi pembayaran.`
: 'Tidak ada sesi menunggu pembayaran saat ini.'}
</p>
</div>
</div>
</div>
);
case 'mentee':
return (
<div className="bg-white rounded-xl p-8 border border-neutral-100 shadow-sm">
<div className="flex items-center gap-3 mb-4">
<div className="size-11 rounded-xl bg-primary-100 text-primary-500 flex items-center justify-center text-xl">
<TeamOutlined />
</div>
<div>
<h3 className="font-semibold text-neutral-800">List Mentee</h3>
<p className="text-sm text-neutral-500">
{menteeCount} mentee unik pernah mengikuti sesimu.
</p>
</div>
</div>
<div className="space-y-3">
<For data={all.slice(0, 8)}>
{(session) => (
<div
key={session.id}
className="flex items-center justify-between gap-3 rounded-lg border border-neutral-100 px-4 py-3"
>
<div className="flex items-center gap-3 min-w-0">
<div className="size-9 rounded-full bg-primary-100 text-primary-600 flex items-center justify-center text-sm font-semibold shrink-0">
{(session.mentee_fullname ?? session.mentee_email ?? 'M')
.charAt(0)
.toUpperCase()}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-neutral-800 truncate">
{session.mentee_fullname ?? session.mentee_email ?? 'Mentee'}
</p>
<p className="text-xs text-neutral-400 truncate">{session.topic}</p>
</div>
</div>
<span
className={cn(
'shrink-0 px-2.5 py-1 rounded-full text-xs capitalize font-medium',
STATUS_STYLE[session.status] ?? 'bg-neutral-100 text-neutral-600'
)}
>
{session.status}
</span>
</div>
)}
</For>
{all.length === 0 && (
<p className="text-sm text-neutral-500 text-center py-6">
Belum ada mentee yang mengikuti sesimu.
</p>
)}
</div>
</div>
);
case 'feedback':
return (
<div className="bg-white rounded-xl p-8 border border-neutral-100 shadow-sm">
<div className="flex items-center gap-3 mb-4">
<div className="size-11 rounded-xl bg-primary-100 text-primary-500 flex items-center justify-center text-xl">
<MessageOutlined />
</div>
<div>
<h3 className="font-semibold text-neutral-800">Feedback</h3>
<p className="text-sm text-neutral-500">
Rating & feedback dari mentee setelah sesi selesai.
</p>
</div>
</div>
<div className="rounded-lg border border-neutral-100 p-6 text-center">
<p className="text-3xl mb-2"></p>
<p className="text-sm text-neutral-500">
Feedback mentee akan muncul di sini setelah sesi selesai
({completedCount} sesi selesai).
</p>
</div>
</div>
);
default:
return (
<div className="grid gap-4 md:grid-cols-2">
<For data={all}>
{(session) => (
<div
key={session.id}
className="bg-white rounded-lg p-5 shadow-sm border border-neutral-100"
>
<div className="flex justify-between items-start gap-3 mb-3">
<h3 className="font-semibold text-neutral-800 line-clamp-2">
{session.topic}
</h3>
<span
className={cn(
'shrink-0 px-3 py-1 rounded-full text-[12px] font-medium capitalize',
STATUS_STYLE[session.status] ?? 'bg-neutral-100 text-neutral-600'
)}
>
{session.status}
</span>
</div>
<p className="text-sm text-neutral-500 mb-1">
📅 {formatDate(session.scheduled_at)}
</p>
<p className="text-sm text-neutral-500 mb-3">
🧑🎓{' '}
{session.mentee_fullname ?? session.mentee_email ?? 'Mentee'}
{' · '}
{session.session_type === 'video_call' ? 'Video Call' : 'Chat'}
</p>
<SessionCard sessionId={session.id} />
<div className="mt-3">
<Link to={`/mentoring/${session.mentor_id}`}>
<Button size="sm" variant="bordered">
Lihat Detail
</Button>
</Link>
</div>
</div>
)}
</For>
</div>
);
}
}, [tab, all, confirmedCount, pendingCount, completedCount, menteeCount]);
return (
<DashboardLayout role="mentor" title="Mentor Dashboard">
<div className="max-w-7xl mx-auto">
<Show
condition={SessionToken.get() != null}
fallback={
<div className="bg-white rounded-lg p-12 text-center shadow-sm border border-neutral-100">
<p className="text-[46px] mb-2">🔐</p>
<h2 className="text-xl font-semibold text-neutral-800 mb-2">
Login dulu, Senpai!
</h2>
<p className="text-neutral-500 mb-6 max-w-md mx-auto">
Dashboard mentor hanya untuk yang sudah terdaftar. Masuk untuk
melihat permintaan sesi dan konfirmasi pembayaran.
</p>
<Link to="/auth/login">
<Button variant="primary" size="lg">
Login Sekarang
</Button>
</Link>
</div>
}
>
<Show
condition={isMentor}
fallback={
<div className="bg-white rounded-lg p-12 text-center shadow-sm border border-neutral-100">
<p className="text-neutral-500 mb-4">
Kamu belum terdaftar sebagai mentor. Daftar dulu untuk menerima
permintaan sesi.
</p>
<Link to="/auth/register-mentor">
<Button variant="primary">Daftar Jadi Mentor</Button>
</Link>
</div>
}
>
{/* Overview stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatCard icon={<StarOutlined />} label="Your Rating" value={stats?.avg_rating?.toFixed(1) ?? '-'} />
<StatCard icon={<CheckCircleOutlined />} label="Session Complete" value={completedCount} />
<StatCard icon={<TeamOutlined />} label="Mentee Impacted" value={menteeCount} />
<StatCard icon={<MessageOutlined />} label="Total Feedback" value={completedCount} />
</div>
{/* Tabs */}
<div className="mt-6 flex gap-1 border-b border-neutral-200 overflow-x-auto">
{TABS.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={cn(
'shrink-0 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
tab === t.key
? 'border-primary-500 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
)}
>
{t.label}
</button>
))}
</div>
<div className="mt-6">
<Show
condition={isLoading}
fallback={
<Show
condition={all.length > 0 || tab !== 'sessions'}
fallback={
<div className="bg-white rounded-lg p-12 text-center border border-neutral-100">
<p className="text-neutral-500">
Belum ada permintaan sesi mentoring.
</p>
</div>
}
>
{content}
</Show>
}
>
<div className="grid gap-4 md:grid-cols-2">
{[0, 1].map((i) => (
<div key={i} className="bg-white rounded-lg p-5 animate-pulse">
<div className="h-4 bg-neutral-200 rounded w-2/3 mb-3" />
<div className="h-3 bg-neutral-200 rounded w-1/2 mb-2" />
<div className="h-3 bg-neutral-200 rounded w-1/3" />
</div>
))}
</div>
</Show>
</div>
</Show>
</Show>
</div>
</DashboardLayout>
);
};
export default Components;
@@ -1,247 +0,0 @@
import { FC, ReactElement, useState } from 'react';
import { Link } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { For, Show, cn, SessionToken } from '@imphnen-frontend-service/utils';
import {
useGetMySessions,
useGetMyPayments,
usePostRefreshPayment,
} from '@imphnen-frontend-service/service';
import { ReloadOutlined, StarOutlined } from '@ant-design/icons';
import { toast } from 'sonner';
import { DashboardLayout } from '../_components/dashboard-layout';
import { FeedbackModal } from '../_components/feedback-modal';
const STATUS_STYLE: Record<string, string> = {
pending: 'bg-warning-100 text-warning-800',
confirmed: 'bg-success-100 text-success-700',
completed: 'bg-primary-100 text-primary-700',
cancelled: 'bg-danger-100 text-danger-600',
};
const PAYMENT_STYLE: Record<string, string> = {
pending: 'bg-warning-100 text-warning-800',
paid: 'bg-success-100 text-success-700',
expired: 'bg-neutral-200 text-neutral-600',
};
const formatDate = (iso: string) => {
try {
return new Date(iso).toLocaleString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return iso;
}
};
const formatRupiah = (n: number) =>
new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(n);
export const Components: FC = (): ReactElement => {
const queryClient = useQueryClient();
const { data: sessions, isLoading } = useGetMySessions();
const { data: payments } = useGetMyPayments();
const refreshPayment = usePostRefreshPayment();
const [feedbackTarget, setFeedbackTarget] = useState<{
sessionId: string;
mentorName: string | null;
} | null>(null);
const paymentBySession = new Map(
(payments ?? []).map((p) => [p.sessionId, p])
);
const handleRefresh = async (paymentId: string) => {
try {
await refreshPayment.mutateAsync(paymentId);
const updated = paymentBySession.get(
(payments ?? []).find((p) => p.id === paymentId)?.sessionId ?? ''
);
toast.success(
updated?.status === 'paid'
? 'Pembayaran berhasil dikonfirmasi otomatis ✅'
: 'Status pembayaran masih menunggu pembayaran'
);
} catch {
toast.error('Gagal memperbarui status pembayaran');
}
};
return (
<DashboardLayout role="mentee" title="Mentoring">
<div className="max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-[23px] font-bold text-neutral-800 md:text-[29px]">
Sesi Mentoring Saya
</h1>
<p className="text-sm text-neutral-500 mt-1">
Pantau jadwal, status pembayaran, dan konfirmasi sesi kamu.
</p>
</div>
<Show
condition={SessionToken.get() != null}
fallback={<AnonymousState />}
>
<Show
condition={isLoading}
fallback={
<Show
condition={(sessions?.sessions?.length ?? 0) > 0}
fallback={
<div className="bg-white rounded-lg p-12 text-center">
<p className="text-neutral-500 mb-4">
Kamu belum memiliki sesi mentoring.
</p>
<Link to="/mentoring">
<Button variant="primary">Cari Mentor</Button>
</Link>
</div>
}
>
<div className="grid gap-4 md:grid-cols-2">
{(sessions?.sessions ?? []).map((session) => {
if (!session) return null;
const payment = paymentBySession.get(session.id);
return (
<div
key={session.id}
className="bg-white rounded-lg p-5 shadow-sm border border-neutral-100"
>
<div className="flex justify-between items-start gap-3 mb-3">
<h3 className="font-semibold text-neutral-800 line-clamp-2">
{session.topic}
</h3>
<span
className={cn(
'shrink-0 px-3 py-1 rounded-full text-[12px] font-medium capitalize',
STATUS_STYLE[session.status] ?? 'bg-neutral-100 text-neutral-600'
)}
>
{session.status}
</span>
</div>
<p className="text-sm text-neutral-500 mb-1">
📅 {formatDate(session.scheduled_at)}
</p>
<p className="text-sm text-neutral-500 mb-4">
{session.duration_minutes} menit ·{' '}
{session.session_type === 'video_call'
? 'Video Call'
: 'Chat'}
</p>
<Show condition={!!payment}>
<div
className={cn(
'flex justify-between items-center px-3 py-2 rounded-md text-xs font-medium mb-3',
PAYMENT_STYLE[payment?.status ?? ''] ?? ''
)}
>
<span className="capitalize">
{payment?.status === 'paid' ? 'Lunas' : 'Menunggu pembayaran'}
</span>
<span>{payment ? formatRupiah(payment.total) : ''}</span>
</div>
</Show>
<div className="flex gap-2">
<Show
condition={!!payment && payment.status === 'pending' && payment.provider === 'midtrans'}
>
<Button
size="sm"
variant="secondary"
className="gap-2"
disabled={refreshPayment.isPending}
onClick={() => payment && handleRefresh(payment.id)}
>
<ReloadOutlined /> Cek Status
</Button>
</Show>
<Show condition={session.status === 'confirmed'}>
<Link to={`/mentoring/${session.mentor_id}`}>
<Button size="sm" variant="bordered">
Detail Mentor
</Button>
</Link>
</Show>
<Show condition={session.status === 'completed'}>
<Button
size="sm"
variant="bordered"
className="gap-1.5"
onClick={() =>
setFeedbackTarget({
sessionId: session.id,
mentorName: null,
})
}
>
<StarOutlined /> Beri Feedback
</Button>
</Show>
</div>
</div>
);
})}
</div>
</Show>
}
>
<div className="grid gap-4 md:grid-cols-2">
{[0, 1].map((i) => (
<div key={i} className="bg-white rounded-lg p-5 animate-pulse">
<div className="h-4 bg-neutral-200 rounded w-2/3 mb-3" />
<div className="h-3 bg-neutral-200 rounded w-1/2 mb-2" />
<div className="h-3 bg-neutral-200 rounded w-1/3" />
</div>
))}
</div>
</Show>
</Show>
</div>
<FeedbackModal
open={feedbackTarget != null}
sessionId={feedbackTarget?.sessionId ?? ''}
mentorName={feedbackTarget?.mentorName}
onClose={() => setFeedbackTarget(null)}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['get-my-sessions'] });
queryClient.invalidateQueries({ queryKey: ['get-mentor-stats'] });
}}
/>
</DashboardLayout>
);
};
const AnonymousState: FC = (): ReactElement => (
<div className="bg-white rounded-lg p-12 text-center shadow-sm border border-neutral-100">
<p className="text-[46px] mb-2">🔐</p>
<h2 className="text-xl font-semibold text-neutral-800 mb-2">
Login dulu, Senpai!
</h2>
<p className="text-neutral-500 mb-6 max-w-md mx-auto">
Sesi mentoring kamu aman tersimpan di akun. Masuk untuk melihat jadwal,
status pembayaran, dan konfirmasi sesi.
</p>
<Link to="/auth/login">
<Button variant="primary" size="lg">
Login Sekarang
</Button>
</Link>
</div>
);
export default Components;
@@ -3,24 +3,27 @@ import { BannerSection } from './_components/banner-section';
import { Topics } from './_components/topics';
import { Input } from '@imphnen-frontend-service/ui/atoms';
import { SearchOutlined } from '@ant-design/icons';
import { For, Show } from '@imphnen-frontend-service/utils';
import { For } from '@imphnen-frontend-service/utils';
import { MentorCard } from './_components/mentor-card';
import { Pagination } from '@imphnen-frontend-service/ui/molecules';
import { useGetMentors } from '@imphnen-frontend-service/service';
import { getCoreRowModel, getPaginationRowModel, PaginationState, useReactTable } from '@tanstack/react-table';
import { motion, useInView, Variants } from 'framer-motion';
const TEMP_DATA = [
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'John Doe' },
{ id: 3, name: 'John Doe' },
{ id: 4, name: 'John Doe' },
]
export const Components: FC = (): ReactElement => {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 8,
pageSize: 3,
});
const [search, setSearch] = useState('');
const { data: mentors, isLoading } = useGetMentors({ search });
const table = useReactTable({
data: mentors ?? [],
data: TEMP_DATA,
columns: [],
state: { pagination },
getCoreRowModel: getCoreRowModel(),
@@ -28,8 +31,6 @@ export const Components: FC = (): ReactElement => {
onPaginationChange: setPagination,
});
const pageMentors = table.getRowModel().rows.map((row) => row.original);
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
@@ -62,7 +63,7 @@ export const Components: FC = (): ReactElement => {
<section className="w-full px-8 md:px-[60px] md:pb-[60px] lg:px-20">
<motion.div
className="max-w-7xl mx-auto bg-white rounded-lg py-6 md:rounded-3xl"
className="max-w-7xl mx-auto bg-white rounded-lg py-6 md:rounded-4xl"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
@@ -90,8 +91,6 @@ export const Components: FC = (): ReactElement => {
<Input
placeholder="Cari berdasarkan nama, posisi/peran"
className="relative min-w-full w-full"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<SearchOutlined className="absolute right-2.5 top-1/2 -translate-y-1/2 text-primary-500 size-2.5 cursor-text md:me-12 lg:me-0" />
</motion.div>
@@ -102,31 +101,13 @@ export const Components: FC = (): ReactElement => {
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
>
<Show
condition={!isLoading}
fallback={
<motion.p variants={childVariants} className="text-center text-neutral-500 col-span-full py-10">
Memuat data mentor...
</motion.p>
}
>
<Show
condition={pageMentors.length > 0}
fallback={
<motion.p variants={childVariants} className="text-center text-neutral-500 col-span-full py-10">
Tidak ada mentor ditemukan
</motion.p>
}
>
<For data={pageMentors}>
{(mentor, index) => (
<motion.div key={mentor.id ?? index} variants={childVariants}>
<MentorCard mentor={mentor} />
</motion.div>
)}
</For>
</Show>
</Show>
<For data={Array.from({ length: 8 })}>
{(_, index) => (
<motion.div key={index} variants={childVariants}>
<MentorCard />
</motion.div>
)}
</For>
</motion.div>
<Pagination table={table} />
@@ -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>
);
};

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