Compare commits

..
313 Commits
Author SHA1 Message Date
Ardeman 7b4ea17a16 fix: handle nullable ads data and ensure safe filtering in advertisements 2025-04-23 13:29:03 +08:00
Ardeman eedb155880 refactor: remove ProfileIcon component and update image handling for author profiles 2025-03-25 19:25:49 +08:00
Ardeman 84451f1fd4 feat: add API endpoints for fetching staff profiles and staff list 2025-03-25 19:07:29 +08:00
Ardeman dce745f53d feat: translate 'Action' to 'Tindakan' and update validation messages 2025-03-25 09:37:03 +08:00
Ardeman 57e23adf3c feat: update UI text to Indonesian language for dashboard and form components 2025-03-25 01:25:51 +08:00
Ardeman 35c02d1643 feat: update UI text to Indonesian language across various components and pages 2025-03-25 01:20:20 +08:00
Ardeman 1f2f15d204 fix: correct spelling of 'Dashboard' to 'Dasbor' in admin menu and dashboard page 2025-03-25 01:03:52 +08:00
Ardeman 39b7720186 fix: correct spelling of 'Staff' to 'Staf' and 'Tags' to 'Tag' in various components 2025-03-25 01:00:57 +08:00
Ardeman de1802c597 feat: implement staff management features including staff creation and dashboard layout 2025-03-25 00:56:14 +08:00
Ardeman 724a14d741 feat: update getNews API query handling and enhance social share button styles 2025-03-25 00:21:09 +08:00
Ardeman fa5f7fbe92 feat: modify query parameter formatting in getNews API to replace spaces with plus signs 2025-03-24 19:55:47 +08:00
Ardeman 4ff1e23d25 feat: implement search functionality for news articles with query parameter 2025-03-24 19:42:27 +08:00
Ardeman 9ab67c615a feat: move views field into data response schema for news API 2025-03-24 17:22:57 +08:00
Ardeman 65f7bbe0aa feat: add views field to news response schema and update dashboard pages 2025-03-24 15:45:43 +08:00
Ardeman 1c33eba834 feat: enhance table styling in ContentsPage with additional class names for better readability 2025-03-24 13:52:31 +08:00
Ardeman 1885eab4c3 feat: add API endpoints for fetching news by ID and slug, and update routing accordingly 2025-03-24 13:42:36 +08:00
Ardeman f2f49de86b feat: update Autoplay settings in Banner component to stop on mouse enter 2025-03-24 13:25:15 +08:00
Ardeman e81dad4ec5 feat: update Autoplay settings in Banner component to prevent stopping on interaction 2025-03-24 13:24:24 +08:00
Ardeman 63bbf70bd6 feat: improve loading state in CarouselHero and CategorySection with ImageSkeletonIcon 2025-03-20 18:22:07 +08:00
Ardeman 33096ab7c1 feat: enhance loading state in CarouselHero with ImageSkeletonIcon 2025-03-20 18:16:15 +08:00
Ardeman ab5b545625 feat: add ImageSkeletonIcon for loading state in carousel section 2025-03-20 17:44:04 +08:00
Ardeman 06672714b7 feat: implement custom error handling in Await components for carousel and category sections 2025-03-20 16:25:34 +08:00
Ardeman 2f8b5dbe0f feat: add error handling to Await component in carousel and category sections 2025-03-20 16:01:13 +08:00
Ardeman 09f8669553 feat: update TNewsResponse type to TNewsResponseData for improved data structure 2025-03-20 12:40:20 +08:00
Ardeman 731b36ef70 feat: replace jose with @edgefirst-dev/jwt for token decoding in cookie generation 2025-03-20 12:36:28 +08:00
Ardeman 305c2cf140 feat: integrate getClientIPAddress utility for improved IP address retrieval 2025-03-20 12:31:51 +08:00
Ardeman 6edca07fa6 feat: enhance news fetching with pagination and active status filters 2025-03-20 12:10:38 +08:00
Ardeman d65aed6828 feat: add userAgent and ipAddress to HttpServer for enhanced request tracking 2025-03-20 11:49:36 +08:00
Ardeman ab3f748195 feat: implement Suspense and Await for improved data loading in carousel components 2025-03-20 11:43:15 +08:00
Ardeman 3847fd1896 refactor: remove unused HydrateFallback component to streamline code 2025-03-19 18:53:44 +08:00
Ardeman 2dc84c582d refactor: simplify loader implementation by removing clientLoader and streamlining data fetching 2025-03-19 18:53:30 +08:00
Ardeman b8378d37ad fix: update loading icon color for better visibility 2025-03-19 18:48:08 +08:00
Ardeman bb3e1bb139 Merge commit '9c9d127dfb1a8e3633f803f527c27bbfc6346b4d'
* commit '9c9d127dfb1a8e3633f803f527c27bbfc6346b4d':
  feat: add delete functionality for news articles with confirmation dialog
2025-03-19 18:21:19 +08:00
Ardeman 66de3dcf04 feat: enhance loading and error boundary components with improved styling and messaging 2025-03-19 18:18:00 +08:00
fredy.siswanto 9c9d127dfb Merge remote-tracking branch 'origin/master' into feature/slicing 2025-03-19 17:16:56 +07:00
fredy.siswanto 9745fff853 feat: add delete functionality for news articles with confirmation dialog 2025-03-19 17:15:46 +07:00
Ardeman deeefda8bc refactor: update loading and error boundary components to use Card for consistency 2025-03-19 18:00:29 +08:00
Ardeman abf7e829ba feat: enhance loading state with spinner and card component in HydrateFallback 2025-03-19 17:53:27 +08:00
Ardeman ba4e380023 feat: implement clientLoader for news data fetching and update serverLoader structure 2025-03-19 17:46:26 +08:00
Ardeman 4ca2d8cf84 fix: correct spelling of 'Rquest Topic' to 'Request Topic' in footer menu 2025-03-19 17:21:17 +08:00
Ardeman 1585830184 refactor: update ad table column title from 'Diklik' to 'Jumlah Klik' for clarity 2025-03-18 17:26:38 +08:00
Ardeman 22918b8bdb refactor: add 'clicked' field to ad response schema and update dashboard display 2025-03-18 17:11:30 +08:00
Ardeman d32eb2e7ed feat: implement logging for ad interactions with new API and update banner component 2025-03-18 16:42:18 +08:00
Ardeman 87616ef6bd refactor: add created_at field to user response schema and update dashboard display 2025-03-18 13:08:45 +08:00
Ardeman 0f332fde79 refactor: simplify layout in CarouselSection for improved readability 2025-03-17 19:21:34 +08:00
Ardeman c633397d7b refactor: update user table headers and data fields for clarity 2025-03-15 22:30:09 +08:00
Ardeman 680a0a88a2 refactor: remove unused icon components and update dashboard data to use new icons 2025-03-15 22:25:06 +08:00
Ardeman d91dffb013 style: update button text size and font weight in navbar for consistency 2025-03-15 19:47:13 +08:00
Ardeman 00bb1a46dc refactor: remove counter property from CardReport and dashboard data for simplification 2025-03-15 19:45:14 +08:00
Ardeman fe38a63f1c refactor: remove unused category column from users dashboard and streamline staff data fetching 2025-03-15 19:43:21 +08:00
Ardeman 978d74d226 feat: add profile update functionality with form validation and API integration 2025-03-15 19:23:16 +08:00
Ardeman d767055bdb fix: streamline error handling in dialogs by removing unnecessary return statements 2025-03-15 17:33:21 +08:00
Ardeman 7b840ce5cd fix: update effect dependencies to use fetcher.data for improved error handling in dialogs 2025-03-15 17:27:49 +08:00
Ardeman e7ef7177ca feat: replace error state handling with toast in login, register, and subscribe dialogs 2025-03-15 17:25:08 +08:00
Ardeman 4c3a143338 fix: correct subscription plan access check in login dialog 2025-03-15 17:03:06 +08:00
Ardeman cc5331284b feat: enhance news detail page with subscription prompt and content restriction for basic users 2025-03-15 16:58:02 +08:00
Ardeman c7195b7428 feat: refactor user authentication and subscription dialogs for improved structure and consistency 2025-03-15 15:24:01 +08:00
Ardeman 6d99a37f20 feat: improve error handling and success messages in various forms and dialogs 2025-03-15 09:16:57 +08:00
Ardeman 405e57b92d feat: update icon size classes for consistency across components 2025-03-15 08:58:34 +08:00
fredy.siswanto cbfb8e72cc feat: prevent basic subscribers from accessing premium news details 2025-03-15 01:08:38 +07:00
fredy.siswanto 3ddc657cfb Merge remote-tracking branch 'origin/master' into feature/slicing 2025-03-15 01:02:32 +07:00
fredy.siswanto 52085ea25e feat: implement subscription update API and enhance news detail loader with user subscription checks 2025-03-15 01:00:32 +07:00
Ardeman 84d10ac983 feat: remove ChevronIcon in navbar; add icon prop to Button component 2025-03-14 23:57:09 +08:00
Ardeman 19a5e6ab88 feat: update heroicons to version 24 and refactor button variants for consistency 2025-03-14 23:50:27 +08:00
Ardeman d538c56d26 feat: improve page title handling by limiting path segments for meta title lookup 2025-03-14 17:48:09 +08:00
Ardeman deb2004039 feat: update editor menubar icons and add clear format functionality 2025-03-14 17:43:54 +08:00
Ardeman 5c20cc48ab feat: add PlusIcon to category creation button and set readOnly for specific fields in form 2025-03-14 13:28:15 +08:00
Ardeman bfa46cdca4 feat: refactor date handling in ads and news creation/update 2025-03-14 13:08:48 +08:00
Ardeman fc23f45854 feat: filter news data to only include items with a live_at date in the past 2025-03-14 12:57:59 +08:00
Ardeman 29baa39a94 fix: update start_date and end_date fields to be non-nullable and filter ads based on date range 2025-03-14 12:54:15 +08:00
Ardeman f04ad1a8f8 feat: add start_date and end_date fields to advertisements with transformation and display in forms 2025-03-14 12:47:43 +08:00
Ardeman 273add293e feat: add nullable start_date and end_date fields to adResponseSchema 2025-03-14 12:08:47 +08:00
fredy.siswanto 01f9cf06ca fix: change user status type from string to number in userResponseSchema 2025-03-14 04:58:49 +07:00
fredy.siswanto ff51941647 feat: add table search filter component and integrate with tags page 2025-03-13 14:05:56 +07:00
fredy.siswanto 8a9cacf7b4 feat: conditionally render delete button based on category code 2025-03-13 11:11:33 +07:00
fredy.siswanto c89731e124 feat: implement category deletion functionality with confirmation dialog 2025-03-13 11:01:28 +07:00
Ardeman 798896e4ee refactor: update DialogDelete component to use selectedId and close callback 2025-03-13 09:36:40 +08:00
Ardeman b00adf89ec feat: implement unified delete dialog component and update related pages 2025-03-13 09:26:33 +08:00
Ardeman 2c703de8e5 feat: implement delete tag functionality with confirmation dialog 2025-03-13 09:05:29 +08:00
Ardeman eadfccfc0e refactor: update author type to TAuthorResponse in news author component 2025-03-13 07:29:40 +08:00
Ardeman d77ea01c59 refactor: update button styles and improve delete dialog properties in dashboard pages 2025-03-13 07:29:03 +08:00
Ardeman 6878da0db2 refactor: transform payload status to number in subscribe plan requests and update type definitions 2025-03-13 07:01:53 +08:00
Ardeman f40f2dadde refactor: rename subscription references to subscribe plan and update related imports 2025-03-13 06:13:43 +08:00
Ardeman cd76ded632 fix: update import path for SubscribePlanPage to correct module location 2025-03-13 05:59:00 +08:00
Ardeman 0f64b4600b fix: correct import paths for subscribe plan modules and update data table schema 2025-03-13 05:57:37 +08:00
Ardeman 0680fb4dc8 feat: add bar, donut, and pie charts to dashboard for enhanced data visualization 2025-03-13 05:45:03 +08:00
Ardeman 474c6dc750 style: update component styling for improved layout and consistency 2025-03-13 05:36:51 +08:00
Ardeman 86ee6abef7 style: update header title to uppercase for improved readability 2025-03-12 21:45:45 +08:00
Ardeman 86e87998bd refactor: update header title styling for improved visibility 2025-03-12 21:44:32 +08:00
Ardeman d63884dde1 feat: add toast notifications for delete actions and update button titles in advertisements page 2025-03-12 21:38:44 +08:00
Ardeman 850f42e99d feat: add delete confirmation dialog for advertisements 2025-03-12 21:30:30 +08:00
Ardeman df3b14a212 fix: prevent closing dialog when fetcher is in progress 2025-03-12 21:22:31 +08:00
Ardeman 7ab83a4f66 feat: implement toast notifications for delete actions in dashboard pages 2025-03-12 21:19:41 +08:00
Ardeman 36b22d3f4a refactor: replace anchor tags with Link components for navigation in TagsPage 2025-03-12 20:50:47 +08:00
Ardeman 338f4a72c4 refactor: replace anchor tags with Link components for navigation in dashboard pages 2025-03-12 20:50:39 +08:00
Ardeman ac5c095ecc refactor: simplify parameter destructuring in loader functions across routes 2025-03-12 20:48:43 +08:00
Ardeman 1ce5a2130b refactor: standardize access token variable naming across routes 2025-03-12 20:46:54 +08:00
Ardeman f423f3e1c0 feat: integrate toast notifications for success and error handling 2025-03-12 20:24:54 +08:00
Ardeman c0318fac4c feat: update success and error handling in form pages with toast notifications 2025-03-12 20:20:30 +08:00
Ardeman 0545190497 feat: remove unused FlashMessage component and banner data 2025-03-12 19:27:04 +08:00
Ardeman 634073342b feat: improve error handling 2025-03-12 19:25:36 +08:00
Ardeman eb7cc04256 feat: enhance error handling in content forms with state management and toast notifications 2025-03-12 19:23:46 +08:00
Ardeman f026277a88 feat: add react-hot-toast in admin and news layouts, and update ads form handling 2025-03-12 19:20:59 +08:00
Ardeman 4a21b7d331 feat: integrate ads data into news banner and update loader to fetch ads 2025-03-12 11:03:53 +08:00
Ardeman 930d4b8459 feat: update API endpoint for ad creation and enhance InputFile component with category prop 2025-03-12 10:57:48 +08:00
Ardeman 56c31d7a20 refactor: update route type imports for admin actions and remove unused types 2025-03-12 10:41:21 +08:00
Ardeman 70cbb134d8 feat: restructure ads mgmt by creating a new layout for ads creation and updating related schemas 2025-03-12 09:47:21 +08:00
Ardeman 90e2042a91 refactor: remove unused icon components and update menu to use new icons 2025-03-12 08:52:23 +08:00
Ardeman c5b611a300 feat: update AdvertisementsPage to use new icon and localized title, remove unused banner upload 2025-03-12 08:48:03 +08:00
fredy.siswanto b9fb1112ae feat: add NewsPaymentPage component and error boundary for payment route 2025-03-12 00:06:43 +07:00
fredy.siswanto 56d9081b4f fix: change user status type from string to number and update badge rendering logic 2025-03-11 23:36:19 +07:00
fredy.siswanto 2c90d32a10 feat: add TColorBadge type and getStatusBadge function for status color management 2025-03-11 23:21:02 +07:00
fredy.siswanto 8a19529230 feat: add FlashMessage component for displaying temporary messages 2025-03-11 22:22:45 +07:00
Ardeman d1b828bba1 feat: implement error boundaries for improved error handling across routes 2025-03-11 23:04:29 +08:00
Ardeman 2b253633a5 feat: add error boundary to handle route errors with detailed messages 2025-03-11 22:56:30 +08:00
fredy.siswanto ea6462f3ea Merge remote-tracking branch 'origin/master' into feature/slicing 2025-03-11 17:10:33 +07:00
Ardeman 6f0a21dde6 refactor: replace FormUpload with DialogUpload component for file uploads 2025-03-11 10:42:33 +08:00
Ardeman 07cced218e fix: update icon sizes in text editor menu bar for consistency 2025-03-11 10:28:48 +08:00
Ardeman 9a2d64eee4 fix: update ID display to show author's ID instead of content ID 2025-03-11 10:15:56 +08:00
Ardeman dfd1a46694 fix: update sidebar button styles for improved active state indication 2025-03-11 06:30:47 +08:00
Ardeman 4a188afc8f fix: replace standard button with Headless UI Button component in text editor 2025-03-11 06:27:35 +08:00
Ardeman 6ec8e5f2a2 fix: replace disabled state with loading state for buttons in upload and category forms 2025-03-11 06:13:03 +08:00
Ardeman 66bc731b07 fix: update button components to include loading state and improve styling in navbar and login page 2025-03-11 06:08:20 +08:00
Ardeman dbeec8acf3 Merge commit 'd3f0dac69f14c56ddc705844e3712819f096c70d' 2025-03-11 06:01:52 +08:00
Ardeman 8cddc82031 fix: add loading state to buttons in forms and update button styles 2025-03-11 06:00:49 +08:00
Ardeman a45a6fb87e fix: update button styles and variants for consistency across forms 2025-03-11 05:51:16 +08:00
fredy.siswanto 438d1d40db Merge remote-tracking branch 'origin/master' into feature/slicing 2025-03-11 00:22:40 +07:00
fredy.siswanto 9a5bddef6a feat: add delete subscription plan and delete tags APIs with response validation 2025-03-11 00:21:09 +07:00
fredy.siswanto f97c79aaa9 feat: add length, price, and status fields to subscription plan schema and forms 2025-03-10 23:00:32 +07:00
fredy.siswanto d3f0dac69f feat: add create and update subscription plan APIs and corresponding forms 2025-03-10 19:34:51 +07:00
Ardeman 7f7526783f fix: update dependency in EditorMenuBar to include imageUrl in useEffect 2025-03-10 17:28:46 +08:00
Ardeman 0f5b26c2b8 fix: remove max-height restriction from TextEditor component styling 2025-03-10 17:14:27 +08:00
Ardeman 60d11fbfa2 fix: replace standard input with Headless UI Input component in FormUpload 2025-03-10 16:51:59 +08:00
Ardeman 54a8a97c09 fix: encode URI components in API calls and links to ensure proper formatting 2025-03-10 14:49:09 +08:00
Ardeman 169f0166b8 fix: update upload handling to conditionally reset upload state based on category 2025-03-10 14:32:32 +08:00
Ardeman e93c0a8464 fix: increase maximum file size limit from 1MB to 10MB in FormUpload component 2025-03-10 13:39:03 +08:00
Ardeman ba83a6a025 fix: update InputFile component to conditionally reset uploaded file state based on upload status 2025-03-10 13:37:50 +08:00
Ardeman c70aa86be6 fix: update admin menu to replace icon for Subscribe Plan with DocumentCurrencyDollarIcon 2025-03-10 12:34:43 +08:00
Ardeman 2e13e11673 fix: add setUploadedFile to InputFile component for resetting uploaded file state 2025-03-10 12:31:29 +08:00
Ardeman aae10cf3bc fix: update InputFile component to use dynamic field name for setting uploaded file 2025-03-10 12:26:56 +08:00
Ardeman 9a0c6c1f0b feat: implement file upload functionality and enhance admin dashboard layout 2025-03-10 12:21:08 +08:00
fredy.siswanto 8c2298ff61 Merge remote-tracking branch 'origin/master' into feature/slicing 2025-03-09 23:25:01 +07:00
fredy.siswanto 061f95d4e5 refactor: enhance UiTable styling and add header alignment for improved readability 2025-03-09 23:22:38 +07:00
fredy.siswanto de92703fbe refactor: implement user management API and update dashboard users page with new data table 2025-03-09 23:06:07 +07:00
fredy.siswanto a9a350fdb2 refactor: update subscription data loading and clean up unused imports in dashboard components 2025-03-09 20:23:26 +07:00
Ardeman 4bc185434b fix: adjust padding in InputFile and Input components for better layout 2025-03-09 21:20:29 +08:00
Ardeman 4bb7a23795 feat: implement upload dialog in admin dashboard with context management 2025-03-09 20:52:50 +08:00
Ardeman f291290f55 refactor: handle 401 errors in staff and user data loading with logout headers 2025-03-09 20:46:10 +08:00
Ardeman e84c05d0a0 Merge commit '5b83c3f8dd8139c24ae4b6c288ab80ac08b43063' 2025-03-09 20:23:04 +08:00
Ardeman c7583413bb feat: add AdminContext for managing upload state in admin dashboard 2025-03-09 20:22:48 +08:00
fredy.siswanto 5b83c3f8dd refactor: improve type definitions in UiTable, handle potential undefined data in SubscribePlanPage 2025-03-09 19:12:23 +07:00
Ardeman 0f414f6963 refactor: update disabled button styles in EditorButton component for improved accessibility 2025-03-09 15:46:10 +08:00
fredy.siswanto 2940057cb1 Merge remote-tracking branch 'origin/master' into feature/slicing 2025-03-09 14:42:52 +07:00
fredy.siswanto 9dca781d6d refactor: add Subscribe Plan menu item and implement subscription management pages 2025-03-09 14:40:55 +07:00
Ardeman f17d64eb9b refactor: rename parameters in CategoriesPage for clarity 2025-03-09 15:34:00 +08:00
Ardeman 64bb369c59 refactor: update table component types and enhance admin menu with category and tag 2025-03-09 15:31:10 +08:00
Ardeman 422c9cbfe2 feat: add InputFile component for file uploads and integrate into FormContentsPage 2025-03-09 15:14:57 +08:00
Ardeman 3d13707359 refactor: update placeholder text for featured image input to clarify URL requirement 2025-03-09 14:55:04 +08:00
Ardeman 9f6c07c3de refactor: rename 'Konten' to 'Artikel' and add submenu for article management 2025-03-09 14:54:18 +08:00
Ardeman e7eda086e4 refactor: integrate autoplay functionality in Banner and enhance news data handling 2025-03-09 14:32:04 +08:00
Ardeman 550b16cfd3 refactor: update display of categories and tags with improved text size 2025-03-09 13:44:04 +08:00
Ardeman 0b3217407c refactor: adjust Navbar styles for improved button ring and text size 2025-03-09 13:39:52 +08:00
Ardeman 7afabdaa03 refactor: update news types and enhance news data handling in components 2025-03-09 12:32:36 +08:00
Ardeman 9f82779456 refactor: adjust spacing in CarouselSection and CategorySection for improved layout 2025-03-09 12:00:10 +08:00
Ardeman ee816b8db7 refactor: implement Tags component for improved tag display and update news types 2025-03-09 11:56:57 +08:00
Ardeman 1b6321f2bd refactor: update tag styling for improved visibility and consistency 2025-03-09 11:39:14 +08:00
Ardeman 4847ef2be3 refactor: enhance news handling and improve content display in various components 2025-03-09 11:36:47 +08:00
Ardeman bad5294030 refactor: remove accessToken from parameters in API requests for cleaner handling 2025-03-09 10:36:38 +08:00
Ardeman 9976626f97 refactor: remove accessToken from parameters in API requests for cleaner handling 2025-03-09 10:34:25 +08:00
Ardeman d38bf3a705 refactor: reorganize API imports and simplify parameter handling in various components 2025-03-09 10:23:11 +08:00
Ardeman 687e3c8d01 refactor: simplify loader function by removing cookie handling in tag update route 2025-03-09 09:59:55 +08:00
Ardeman 4554cca392 refactor: update loaderData imports in dashboard pages for consistency 2025-03-09 09:57:26 +08:00
Ardeman 05b3af8718 fix: update category selection logic to use code instead of id 2025-03-09 09:50:18 +08:00
Ardeman e2e3095c33 refactor: update loaderData usage in NewsCategoriesPage for improved clarity 2025-03-09 09:49:06 +08:00
Ardeman 5b94c6df91 refactor: simplify loaderData usage in dashboard categories and contents pages 2025-03-09 09:44:41 +08:00
Ardeman 634c809489 fix: allow end_date to be nullable in user response schema 2025-03-09 09:41:11 +08:00
Ardeman 0da2006e78 refactor: destructure loaderData in multiple components for cleaner code 2025-03-09 09:32:51 +08:00
Ardeman 09e5f84b8b style: update social share button styles for improved UI consistency 2025-03-09 09:09:54 +08:00
fredy.siswanto ecd1900acc fix: update image aspect ratios in carousel and category sections 2025-03-09 06:46:46 +07:00
fredy.siswanto 0b6327e2fb feat: create NewsAuthor component and update news detail page 2025-03-08 21:29:39 +07:00
fredy.siswanto 06608c7b5d feat: add Instagram share button to social share component 2025-03-08 20:53:30 +07:00
fredy.siswanto 333fa32eda Merge remote-tracking branch 'origin/master' into feature/slicing 2025-03-08 20:36:43 +07:00
fredy.siswanto 1881032ed2 feat: implement social share buttons 2025-03-08 20:25:41 +07:00
Ardeman aad67720c1 fix: correct TODO comment formatting in news detail loader 2025-03-08 17:25:24 +08:00
Ardeman f3be8138c4 feat: add meta title generation for news detail page 2025-03-08 17:24:53 +08:00
fredy.siswanto 46e009f888 feat: update news detail page to display author's avatar and name 2025-03-08 15:01:52 +07:00
Ardeman 6e858f1860 feat: add reading time calculation to news detail page 2025-03-08 15:34:38 +08:00
Ardeman be63337024 fix: correct button label and table title in TagsPage component 2025-03-08 15:25:20 +08:00
Ardeman 4b61fd9501 feat: enhance navbar and news detail page with improved profile picture handling 2025-03-08 15:23:22 +08:00
Ardeman 609866beef feat: update news detail loader to use route parameters and fix typo in TODO comment 2025-03-08 15:03:45 +08:00
Ardeman 1c6bf68b1e feat: remove unused breadcrumb and news detail data components, update TNewsDetail type definition 2025-03-08 15:02:11 +08:00
fredy.siswanto fc67298a85 feat: migrate tag creation and update functionality to new form structure 2025-03-08 06:18:13 +07:00
fredy.siswanto b7f51ea46f Merge remote-tracking branch 'origin/master' into feature/slicing 2025-03-08 04:34:08 +07:00
Ardeman 87156e93ee feat: sort categories by sequence and update category description handling in news pages 2025-03-08 02:14:36 +08:00
Ardeman 40a3553f9d feat: implement category update functionality with validation and new fields in form 2025-03-08 01:58:59 +08:00
Ardeman c975169f05 feat: refactor category update functionality 2025-03-08 01:32:25 +08:00
fredy.siswanto 3a81e7b3db fix: typo & remove readonly on form input 2025-03-08 00:28:26 +07:00
fredy.siswanto 50fdd6bc02 feat: enhance news detail page with dynamic data loading and social sharing functionality 2025-03-08 00:14:30 +07:00
Ardeman 2c64d430bd feat: refactor import paths for content and category schemas to improve clarity and organization 2025-03-08 00:47:33 +08:00
Ardeman 3a15c0d945 feat: enhance category schema with sequence and description fields, and update dashboard display 2025-03-08 00:45:05 +08:00
fredy.siswanto ee209b6ceb Merge remote-tracking branch 'origin/master' into feature/slicing 2025-03-07 19:35:29 +07:00
fredy.siswanto 2b5c3b1111 feat: implement tag creation functionality with validation and routing 2025-03-07 19:31:37 +07:00
Ardeman a7bae3ea43 feat: remove unused admin dashboard components and icons for cleanup 2025-03-07 17:31:30 +08:00
fredy.siswanto c91fe48eb9 feat: add tags management page and update admin menu 2025-03-07 14:56:00 +07:00
fredy.siswanto c46e8fc11b Merge remote-tracking branch 'origin/master' into feature/slicing 2025-03-07 14:13:23 +07:00
Ardeman 40a3f33ea3 feat: update category menu and dashboard titles for consistency and clarity 2025-03-07 15:04:22 +08:00
Ardeman 72e2dac328 feat: refactor category routes and components for improved organization and clarity 2025-03-07 14:58:35 +08:00
Ardeman 097268647a Merge branch 'feature/slicing' 2025-03-07 14:46:05 +08:00
Ardeman aa660f4f5f feat: sort news data by publication date and update column title for clarity 2025-03-07 14:43:12 +08:00
Ardeman d885b3cf26 feat: implement update news functionality with API integration and form handling 2025-03-07 14:40:04 +08:00
fredy.siswanto d9160b11f7 Merge remote-tracking branch 'origin/master' into feature/slicing 2025-03-07 13:19:50 +07:00
fredy.siswanto 80711e744b feat: implement category creation and update pages with routing adjustments 2025-03-07 13:07:28 +07:00
Ardeman e0b68611bd feat: update Titles in ContentsFormPage and ContentsPage for better context 2025-03-07 13:53:07 +08:00
Ardeman 5c716d7210 feat: integrate news data fetching by slug and update ContentsFormPage to utilize fetched data 2025-03-07 13:50:16 +08:00
fredy.siswanto df41e4050c feat: add dashboard categories page with data table and loader 2025-03-07 12:42:08 +07:00
Ardeman 2c05c543ce feat: remove DefaultTextEditor and UpdateContentsPage, replace with ContentsFormPage 2025-03-07 12:42:45 +08:00
Ardeman 18098d63ba feat: add news and dashboard content routes with corresponding layouts 2025-03-07 12:11:48 +08:00
Ardeman afebd8b335 feat: create ContentsFormPage for content creation and update related imports 2025-03-07 11:10:53 +08:00
Ardeman 75e548dc83 feat: update Switch component to support customizable labels and adjust related UI elements 2025-03-07 10:30:46 +08:00
Ardeman d765d92df7 feat: refactor API response schemas for improve consistency 2025-03-07 09:04:11 +08:00
Ardeman 7163266100 feat: update create-category API to use consistent parameter type and improve type definitions 2025-03-07 08:53:55 +08:00
Ardeman 7ce014e70e Merge commit 'eac8e0cb43334053e5d9862f9477ba9d52df3240' 2025-03-07 08:50:42 +08:00
Ardeman 1bb63bec8e feat: refactor news and category schemas, update API response handling, and improve date formatting 2025-03-07 08:50:04 +08:00
fredy.siswanto eac8e0cb43 feat: add category creation functionality with validation and API integration 2025-03-07 05:53:52 +07:00
fredy.siswanto 661437a2d3 feat: update dashboard to render author details and improve row numbering 2025-03-06 23:49:12 +07:00
fredy.siswanto 529ac6b590 feat: update dashboard contents to render author details with improved formatting 2025-03-06 23:32:15 +07:00
fredy.siswanto d3dc3b5016 feat: enhance news API schema with author and categories details,
desc: update dashboard to display author names, and improve pagination button styles
2025-03-06 22:49:56 +07:00
fredy.siswanto b61235a3b5 feat: update dashboard contents to include category names and format dates 2025-03-06 19:47:45 +07:00
fredy.siswanto 4754e75f59 Merge remote-tracking branch 'origin/master' into feature/slicing 2025-03-06 19:32:52 +07:00
Ardeman d5c77dc13b feat: enhance news and user schemas with author and subscription details 2025-03-06 18:46:57 +08:00
fredy.siswanto 6e555184f0 feat: add pagination icons to UiTable component for improved navigation 2025-03-06 17:37:23 +07:00
fredy.siswanto a635dc1431 Merge remote-tracking branch 'origin/master' into feature/slicing 2025-03-06 11:46:58 +07:00
Ardeman 57a20aa048 style: update premium and normal status indicators for improved UI consistency 2025-03-06 09:39:40 +08:00
Ardeman 703e347830 feat: implement news fetching API and integrate with dashboard contents 2025-03-06 09:30:53 +08:00
Ardeman d27c068f39 style: update class names for improved consistency in text editor and switch components 2025-03-06 09:01:26 +08:00
Ardeman 32b435d762 refactor: remove dark mode functionality from text editor components 2025-03-06 08:56:03 +08:00
Ardeman 5cf6eb191d fix: update validation logic to ensure category selection is not empty 2025-03-06 08:47:21 +08:00
Ardeman a7c5da8d9c style: update button and switch components for improved UI consistency 2025-03-06 08:45:24 +08:00
Ardeman 95565b5313 refactor: remove Select component from UI library 2025-03-06 08:20:39 +08:00
Ardeman d4d9861727 feat: add Switch component for premium content toggle in CreateContentsPage 2025-03-06 08:18:36 +08:00
Ardeman 8f8fb6b97c feat: add Save button to CreateContentsPage for improved user interaction 2025-03-06 05:51:03 +08:00
Ardeman c6149f1bd5 fix: update createNewsRequest to use category and tag IDs instead of codes 2025-03-06 05:49:49 +08:00
Ardeman 414806734d feat: implement createNews API and integrate with content creation flow 2025-03-06 05:46:25 +08:00
Ardeman 894698c5d6 feat: enhance TextEditor and Input components with improved styling and error handling 2025-03-06 03:53:15 +08:00
Ardeman 9117a99cc3 feat: add TextEditor component 2025-03-05 23:39:09 +08:00
Ardeman a6e6e10c69 fix: simplify path extraction logic in Sidebar component 2025-03-05 22:39:00 +08:00
Ardeman 9dd7e798f7 feat: refactor sidebar component to use extracted path for active link styling 2025-03-05 22:36:34 +08:00
Ardeman 1a1d8cc209 feat: implement getTags API and integrate with content creation form 2025-03-05 17:57:52 +08:00
fredy.siswanto 8c6d3f0645 fix: update text editor import in CreateContentsPage component 2025-03-05 11:31:03 +07:00
fredy.siswanto aca894729e fix: remove commented-out scrollY property in ContentsPage component 2025-03-04 22:11:13 +07:00
fredy.siswanto d6768f0318 feat: add toggle functionality for advertisement view and integrate button for switching 2025-03-04 22:04:07 +07:00
Ardeman 1758b7837e fix: update variable names in Combobox component for clarity 2025-03-04 12:03:02 +08:00
Ardeman dc794a29d3 feat: enhance layout and button functionality in subscriptions page 2025-03-04 09:53:13 +08:00
Ardeman 6d8e352ffc feat: refactor login handling to use staff-login page and update related imports 2025-03-04 09:44:09 +08:00
Ardeman 8aeb7a3197 feat: replace Select component with Combobox for subscription selection and update validation schema 2025-03-04 09:00:10 +08:00
Ardeman 78287cc6c5 feat: add Combobox component for subscription selection and integrate with form validation 2025-03-04 08:55:01 +08:00
Ardeman c20022f58c Merge commit '90d13fe3422986427b6727428e7401e8880c25a0' 2025-03-04 07:06:45 +08:00
Ardeman 7c244e256f feat: remove NotificationIcon from admin navbar and add @heroicons/react dependency 2025-03-04 05:49:00 +08:00
fredy.siswanto 90d13fe342 feat: add default img navbar for users without profile pictures 2025-03-04 04:41:59 +07:00
Ardeman f4e83f7eac feat: improved padding on modal components and implement logout in mobile menu 2025-03-04 03:47:54 +08:00
Ardeman a9b9b76ef2 fix: correct app description text and adjust header styling for better visibility 2025-03-04 03:41:02 +08:00
Ardeman 256c07a2b9 feat: update admin login page to redirect to home and remove registration link 2025-03-04 03:35:14 +08:00
fredy.siswanto 120450fd7c feat: implement UiTable component and refactor dashboard pages to use it 2025-03-04 00:21:14 +07:00
fredy.siswanto 1538869e49 feat: add currency support to CardReport and update dashboard data structure 2025-03-03 21:17:46 +07:00
fredy.siswanto 030de2c2dd feat: enhance button styling on contents page for better visibility 2025-03-03 19:31:28 +07:00
fredy.siswanto 01925c7c63 Merge remote-tracking branch 'origin/master' into feature/slicing 2025-03-03 19:16:30 +07:00
Ardeman 5f5a0dff31 feat: update admin navbar to display staff profile information and refactor loader data 2025-03-03 19:10:50 +08:00
Ardeman 8f5f1bc552 feat: implement logout functionality in admin navbar and update sidebar links 2025-03-03 19:03:04 +08:00
fredy.siswanto 9809dd0d90 Merge remote-tracking branch 'origin/master' into feature/slicing 2025-03-03 17:55:22 +07:00
fredy.siswanto 69094b85ed feat: update titles and enhance update contents page with tags and status selection 2025-03-03 17:53:35 +07:00
Ardeman 1bebe61634 feat: enhance admin login flow with conditional redirects based on authentication status 2025-03-03 17:50:10 +08:00
Ardeman 5007b8d4db feat: update cookie paths for staff authentication and implement redirect logic in admin login 2025-03-03 17:48:24 +08:00
Ardeman 599a92fba3 feat: update staff API schema and refactor admin login paths 2025-03-03 17:33:52 +08:00
Ardeman 459e25c010 feat: refactor admin authentication to staff authentication with updated cookie handling 2025-03-03 16:47:59 +08:00
Ardeman 4f4e94389e feat: add admin login page and authentication logic with form handling 2025-03-03 16:33:30 +08:00
Ardeman acd3e36b31 Merge commit 'b42aa6c960d7adfc435a7386ae0d983df1b5739c' 2025-03-03 15:42:59 +08:00
Ardeman ba19a4dc5b feat: add staff and user authentication APIs with request handling 2025-03-03 15:42:12 +08:00
fredy.siswanto b42aa6c960 feat: implement banner component with carousel functionality and dynamic content 2025-03-03 13:42:01 +07:00
Ardeman 1b4ff4c3e7 feat: refactor user handling in news components and add premium attribute logic 2025-03-03 08:59:06 +08:00
Ardeman a594227026 feat: update subscription handling in context and components 2025-03-03 08:41:38 +08:00
Ardeman 722966f50e feat: implement subscription form with validation and fetcher integration 2025-03-03 05:16:31 +08:00
fredy.siswanto 5cfad29302 feat: add rich text editor component for content creation page 2025-03-02 23:13:09 +07:00
fredy.siswanto 1486d3a7b7 feat: add create and update contents pages with admin dashboard layout 2025-03-02 21:51:50 +07:00
fredy.siswanto 9da31472f9 Merge remote-tracking branch 'origin/master' into feature/slicing 2025-03-02 20:15:47 +07:00
fredy.siswanto c4297a8167 feat: replace inline button with reusable Button component in dashboard contents 2025-03-02 20:09:09 +07:00
fredy.siswanto cd3637bf03 feat: implement carousel navigation buttons for improved user interaction 2025-03-02 20:08:03 +07:00
Ardeman 0a6f1a2d6f refactor: replace userToken with userData in SuccessModal and HeaderTop components 2025-03-02 16:36:03 +08:00
Ardeman a53f754ec5 refactor: update onClick type in getPremiumAttribute to MouseEventHandler 2025-03-02 16:19:51 +08:00
Ardeman cd85f660b7 refactor: simplify premium button logic by creating getPremiumAttribute utility 2025-03-02 16:15:24 +08:00
Ardeman b0f96c33a5 feat: enhance SuccessModal and conditional rendering for login and subscription options 2025-03-02 15:46:44 +08:00
Ardeman 82c0894ccd refactor: change import to type import for loader in header-top component 2025-03-02 15:11:21 +08:00
fredy.siswanto acb4ff64c1 refactor: remove KAJIAN section from news page for code simplification 2025-03-02 12:24:30 +07:00
fredy.siswanto 46db9c97dd Merge remote-tracking branch 'origin/master' into feature/slicing 2025-03-02 12:19:17 +07:00
fredy.siswanto aaba207165 feat: add KAJIAN section to news page and update content structure 2025-03-02 12:15:31 +07:00
Ardeman 20fa80370b refactor: replace hardcoded descriptions with a constant for consistency 2025-03-02 12:37:19 +08:00
Ardeman 3937cc673a refactor: remove Carousel and TableComponent for code cleanup 2025-03-02 12:22:30 +08:00
Ardeman 9980cb90df feat: refactor subscription handling in registration form and header component 2025-03-02 12:21:18 +08:00
Ardeman 4aa6c8d0d4 refactor: remove unused news data and simplify NewsContextProperties type 2025-03-02 12:09:48 +08:00
Ardeman 25e136ba72 feat: implement category fetching and integrate into header menus 2025-03-02 12:06:30 +08:00
Ardeman cfc864cb43 fix: update subscription status check to use subscribe_plan_code instead of subscribe_status 2025-03-02 10:18:07 +08:00
Ardeman b58c57a6af feat: rename subscription field to subscribe_plan in registration payload and form 2025-03-02 09:17:28 +08:00
Ardeman e76b89277c Merge commit '06c8fb85356ccad9a7de745e2648e365af1a8da5' 2025-03-02 01:36:43 +08:00
Ardeman 02e2ed3f52 feat: transform registration payload to include subscription plan ID and update select options 2025-03-02 01:36:19 +08:00
Ardeman adba58780a feat: add subscription selection to registration form and implement API for fetching subscriptions 2025-03-02 01:20:18 +08:00
fredy.siswanto 06c8fb8535 Merge remote-tracking branch 'origin/master' into feature/slicing 2025-03-01 15:45:42 +07:00
fredy.siswanto a749759164 feat: implement CategorySection component and update carousel usage in news pages 2025-03-01 15:44:23 +07:00
Ardeman 1e876ae04f feat: refactor Button and Input components to use Headless UI 2025-03-01 16:14:03 +08:00
fredy.siswanto 422f3fcba5 Merge remote-tracking branch 'origin/master' into feature/slicing 2025-03-01 11:43:14 +07:00
Ardeman 9ef33d72d3 feat: refactor Input component to use Headless UI and add disabled prop 2025-03-01 06:32:20 +08:00
Ardeman 42eb159a52 feat: implement user registration functionality with form validation and API integration 2025-03-01 01:00:52 +08:00
Ardeman fd745c20a0 feat: conditionally open subscription modal based on user subscribe status in FormLogin 2025-03-01 00:07:57 +08:00
Ardeman 0577975846 feat: implement HttpServer for API requests and add getUser function for user profile retrieval 2025-02-28 23:56:47 +08:00
fredy.siswanto 3a61b38961 feat: simplify layout margins for banner and news categories page 2025-02-28 18:59:27 +07:00
224 changed files with 10109 additions and 3048 deletions
+2
View File
@@ -1,5 +1,7 @@
.DS_Store
/node_modules/
.vscode/cspell.json
.vscode/.cspell/
.env
# React Router
+4 -3
View File
@@ -25,7 +25,8 @@
"labelClassName",
"buttonClassName",
"leftNodeClassName",
"rightNodeClassName"
],
"cSpell.words": []
"rightNodeClassName",
"buttonVariants",
"cva"
]
}
+34
View File
@@ -0,0 +1,34 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TAdsSchema } from '~/pages/form-advertisements'
import { datePayload } from '~/utils/formatter'
const advertisementsResponseSchema = z.object({
data: z.object({
Message: z.string(),
}),
})
type TParameters = {
payload: TAdsSchema
} & THttpServer
export const createAdsRequest = async (parameters: TParameters) => {
const { payload, ...restParameters } = parameters
const transformedPayload = {
...payload,
start_date: datePayload(payload.start_date),
end_date: datePayload(payload.end_date),
}
try {
const { data } = await HttpServer(restParameters).post(
'/api/ads/create',
transformedPayload,
)
return advertisementsResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+28
View File
@@ -0,0 +1,28 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TCategorySchema } from '~/pages/form-category'
const categoryResponseSchema = z.object({
data: z.object({
Message: z.string(),
}),
})
type TParameters = {
payload: TCategorySchema
} & THttpServer
export const createCategoryRequest = async (parameters: TParameters) => {
const { payload, ...restParameters } = parameters
try {
const { data } = await HttpServer(restParameters).post(
'/api/category/create',
payload,
)
return categoryResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+36
View File
@@ -0,0 +1,36 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TContentSchema } from '~/pages/form-contents'
import { datePayload } from '~/utils/formatter'
const newsResponseSchema = z.object({
data: z.object({
Message: z.string(),
}),
})
type TParameter = {
payload: TContentSchema
} & THttpServer
export const createNewsRequest = async (parameters: TParameter) => {
const { payload, ...restParameters } = parameters
const { categories, tags, live_at, ...restPayload } = payload
const transformedPayload = {
...restPayload,
categories: categories.map((category) => category?.id),
tags: tags?.map((tag) => tag?.id) || [],
live_at: datePayload(live_at),
}
try {
const { data } = await HttpServer(restParameters).post(
'/api/news/create',
transformedPayload,
)
return newsResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+28
View File
@@ -0,0 +1,28 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TStaffSchema } from '~/pages/form-staff'
const createStaffResponseSchema = z.object({
data: z.object({
Message: z.string(),
}),
})
type TParameter = {
payload: TStaffSchema
} & THttpServer
export const createStaffsRequest = async (parameters: TParameter) => {
const { payload, ...restParameters } = parameters
try {
const { data } = await HttpServer(restParameters).post(
'/api/staff/register',
payload,
)
return createStaffResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+32
View File
@@ -0,0 +1,32 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TSubscribePlanSchema } from '~/pages/form-subscribe-plan'
const subscribePlanResponseSchema = z.object({
data: z.object({
Message: z.string(),
}),
})
type TParameters = {
payload: TSubscribePlanSchema
} & THttpServer
export const createSubscribePlanRequest = async (parameters: TParameters) => {
const { payload, ...restParameters } = parameters
try {
const transformedPayload = {
...payload,
status: Number(payload.status),
}
const { data } = await HttpServer(restParameters).post(
'/api/subscribe-plan/create',
transformedPayload,
)
return subscribePlanResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+28
View File
@@ -0,0 +1,28 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TTagSchema } from '~/pages/form-tag'
const tagsResponseSchema = z.object({
data: z.object({
Message: z.string(),
}),
})
type TParameters = {
payload: TTagSchema
} & THttpServer
export const createTagsRequest = async (parameters: TParameters) => {
const { payload, ...restParameters } = parameters
try {
const { data } = await HttpServer(restParameters).post(
'/api/tag/create',
payload,
)
return tagsResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+27
View File
@@ -0,0 +1,27 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TAdsSchema } from '~/pages/form-advertisements'
const deleteAdsResponseSchema = z.object({
data: z.object({
Message: z.string(),
}),
})
type TParameters = {
id: TAdsSchema['id']
} & THttpServer
export const deleteAdsRequest = async (parameters: TParameters) => {
const { id, ...restParameters } = parameters
try {
const { data } = await HttpServer(restParameters).delete(
`/api/ads/${id}/delete`,
)
return deleteAdsResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+27
View File
@@ -0,0 +1,27 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TCategorySchema } from '~/pages/form-category'
const deleteCategoriesResponseSchema = z.object({
data: z.object({
Message: z.string(),
}),
})
type TParameters = {
id: TCategorySchema['id']
} & THttpServer
export const deleteCategoriesRequest = async (parameters: TParameters) => {
const { id, ...restParameters } = parameters
try {
const { data } = await HttpServer(restParameters).delete(
`/api/category/${id}/delete`,
)
return deleteCategoriesResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+28
View File
@@ -0,0 +1,28 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TContentSchema } from '~/pages/form-contents'
const deleteContentsResponseSchema = z.object({
data: z.object({
Message: z.string(),
}),
})
type TParameters = {
id: TContentSchema['id']
} & THttpServer
export const deleteContentsRequest = async (parameters: TParameters) => {
const { id, ...restParameters } = parameters
try {
const { data } = await HttpServer(restParameters).delete(
`/api/news/${id}/delete`,
)
return deleteContentsResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+27
View File
@@ -0,0 +1,27 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TSubscribePlanSchema } from '~/pages/form-subscribe-plan'
const subscribePlanResponseSchema = z.object({
data: z.object({
Message: z.string(),
}),
})
type TParameters = {
id: TSubscribePlanSchema['id']
} & THttpServer
export const deleteSubscribePlanRequest = async (parameters: TParameters) => {
const { id, ...restParameters } = parameters
try {
const { data } = await HttpServer(restParameters).delete(
`/api/subscribe-plan/${id}/delete`,
)
return subscribePlanResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+27
View File
@@ -0,0 +1,27 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TTagSchema } from '~/pages/form-tag'
const deleteTagsResponseSchema = z.object({
data: z.object({
Message: z.string(),
}),
})
type TParameters = {
id: TTagSchema['id']
} & THttpServer
export const deleteTagsRequest = async (parameters: TParameters) => {
const { id, ...restParameters } = parameters
try {
const { data } = await HttpServer(restParameters).delete(
`/api/tag/${id}/delete`,
)
return deleteTagsResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+25
View File
@@ -0,0 +1,25 @@
import { z } from 'zod'
import { newsResponseSchema } from '~/apis/common/get-news'
import { HttpServer, type THttpServer } from '~/libs/http-server'
const dataResponseSchema = z.object({
data: z.object(newsResponseSchema.shape),
})
type TParameters = {
id: string
} & THttpServer
export const getNewsById = async (parameters: TParameters) => {
const { id, ...restParameters } = parameters
try {
const { data } = await HttpServer(restParameters).get(
`/api/staff/news/${encodeURIComponent(id)}`,
)
return dataResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+22
View File
@@ -0,0 +1,22 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
const staffResponseSchema = z.object({
data: z.object({
id: z.string(),
email: z.string(),
name: z.string(),
profile_picture: z.string(),
}),
})
export const getProfile = async (parameters: THttpServer) => {
try {
const { data } = await HttpServer(parameters).get(`/api/staff/profile`)
return staffResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+25
View File
@@ -0,0 +1,25 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
const staffResponseSchema = z.object({
id: z.string(),
email: z.string(),
name: z.string(),
profile_picture: z.string(),
})
const staffsResponseSchema = z.object({
data: z.array(staffResponseSchema),
})
export type TStaffResponse = z.infer<typeof staffResponseSchema>
export const getStaffs = async (parameters: THttpServer) => {
try {
const { data } = await HttpServer(parameters).get(`/api/staff/get-all`)
return staffsResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+40
View File
@@ -0,0 +1,40 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
const subscribePlanResponseSchema = z.object({
id: z.string(),
code: z.string(),
name: z.string(),
})
const subscribeResponseSchema = z.object({
id: z.string(),
subscribe_plan_id: z.string(),
start_date: z.string(),
end_date: z.string().nullable(),
status: z.number(),
auto_renew: z.boolean(),
subscribe_plan: subscribePlanResponseSchema,
})
const userResponseSchema = z.object({
id: z.string(),
email: z.string().email(),
phone: z.string(),
subscribe: subscribeResponseSchema,
created_at: z.string(),
})
const usersResponseSchema = z.object({
data: z.array(userResponseSchema),
})
export type TUserResponse = z.infer<typeof userResponseSchema>
export const getUsers = async (parameters: THttpServer) => {
try {
const { data } = await HttpServer(parameters).get(`/api/staff/users`)
return usersResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
@@ -1,7 +1,7 @@
import { z } from 'zod'
import { type TLoginSchema } from '~/layouts/news/form-login'
import HttpClient from '~/libs/http-client'
import { HttpServer } from '~/libs/http-server'
import type { TLoginSchema } from '~/pages/staff-login'
const loginResponseSchema = z.object({
data: z.object({
@@ -9,9 +9,9 @@ const loginResponseSchema = z.object({
}),
})
export const newsLoginRequest = async (payload: TLoginSchema) => {
export const staffLoginRequest = async (payload: TLoginSchema) => {
try {
const { data } = await HttpClient().post('/api/user/login', payload)
const { data } = await HttpServer().post('/api/staff/login', payload)
return loginResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
+35
View File
@@ -0,0 +1,35 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TAdsSchema } from '~/pages/form-advertisements'
import { datePayload } from '~/utils/formatter'
const advertisementsResponseSchema = z.object({
data: z.object({
Message: z.string(),
}),
})
type TParameters = {
payload: TAdsSchema
} & THttpServer
export const updateAdsRequest = async (parameters: TParameters) => {
const { payload, ...restParameters } = parameters
const { id, ...restPayload } = payload
const transformedPayload = {
...restPayload,
start_date: datePayload(payload.start_date),
end_date: datePayload(payload.end_date),
}
try {
const { data } = await HttpServer(restParameters).put(
`/api/ads/${id}/update`,
transformedPayload,
)
return advertisementsResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+29
View File
@@ -0,0 +1,29 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TCategorySchema } from '~/pages/form-category'
const categoryResponseSchema = z.object({
data: z.object({
Message: z.string(),
}),
})
type TParameters = {
payload: TCategorySchema
} & THttpServer
export const updateCategoryRequest = async (parameters: TParameters) => {
const { payload, ...restParameters } = parameters
const { id, ...restPayload } = payload
try {
const { data } = await HttpServer(restParameters).put(
`/api/category/${id}/update`,
restPayload,
)
return categoryResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+36
View File
@@ -0,0 +1,36 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TContentSchema } from '~/pages/form-contents'
import { datePayload } from '~/utils/formatter'
const newsResponseSchema = z.object({
data: z.object({
Message: z.string(),
}),
})
type TParameter = {
payload: TContentSchema
} & THttpServer
export const updateNewsRequest = async (parameters: TParameter) => {
const { payload, ...restParameters } = parameters
const { categories, tags, id, live_at, ...restPayload } = payload
const transformedPayload = {
...restPayload,
categories: categories.map((category) => category?.id),
tags: tags?.map((tag) => tag?.id) || [],
live_at: datePayload(live_at),
}
try {
const { data } = await HttpServer(restParameters).put(
`/api/news/${id}/update`,
transformedPayload,
)
return newsResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+28
View File
@@ -0,0 +1,28 @@
import { z } from 'zod'
import type { TProfileSchema } from '~/layouts/admin/dialog-profile'
import { HttpServer, type THttpServer } from '~/libs/http-server'
const updateProfileResponseSchema = z.object({
data: z.object({
Message: z.string(),
}),
})
type TParameter = {
payload: TProfileSchema
} & THttpServer
export const updateProfileRequest = async (parameters: TParameter) => {
const { payload, ...restParameters } = parameters
try {
const { data } = await HttpServer(restParameters).put(
'/api/staff/update',
payload,
)
return updateProfileResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+33
View File
@@ -0,0 +1,33 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TSubscribePlanSchema } from '~/pages/form-subscribe-plan'
const subscribePlanResponseSchema = z.object({
data: z.object({
Message: z.string(),
}),
})
type TParameters = {
payload: TSubscribePlanSchema
} & THttpServer
export const updateSubscribePlanRequest = async (parameters: TParameters) => {
const { payload, ...restParameters } = parameters
const { id, ...restPayload } = payload
try {
const transformedPayload = {
...restPayload,
status: Number(payload.status),
}
const { data } = await HttpServer(restParameters).put(
`/api/subscribe-plan/${id}/update`,
transformedPayload,
)
return subscribePlanResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+36
View File
@@ -0,0 +1,36 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TSubscribePlanSchema } from '~/pages/form-subscribe-plan'
const subscribePlanResponseSchema = z.object({
data: z.object({
Message: z.string(),
}),
})
type TSubscribeSchema = Pick<TSubscribePlanSchema, 'id'>
type TParameters = {
payload: { subscribe_plan: TSubscribeSchema }
} & THttpServer
export const updateSubscribeRequest = async (parameters: TParameters) => {
const { payload, ...restParameters } = parameters
const { id } = payload.subscribe_plan
try {
const transformedPayload = {
status: 1,
subscribe_plan_id: id,
}
const { data } = await HttpServer(restParameters).patch(
`/api/subscribe/update`,
transformedPayload,
)
console.log(data) // eslint-disable-line no-console
return subscribePlanResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+29
View File
@@ -0,0 +1,29 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TTagSchema } from '~/pages/form-tag'
const tagResponseSchema = z.object({
data: z.object({
Message: z.string(),
}),
})
type TParameters = {
payload: TTagSchema
} & THttpServer
export const updateTagRequest = async (parameters: TParameters) => {
const { payload, ...restParameters } = parameters
const { id, ...restPayload } = payload
try {
const { data } = await HttpServer(restParameters).put(
`/api/tag/${id}/update`,
restPayload,
)
return tagResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+37
View File
@@ -0,0 +1,37 @@
import { z } from 'zod'
import type { TUploadSchema } from '~/layouts/admin/dialog-upload'
import { HttpServer, type THttpServer } from '~/libs/http-server'
const uploadResponseSchema = z.object({
data: z.object({
message: z.string(),
data: z.object({
file_path: z.string(),
file_url: z.string(),
}),
}),
})
type TParameter = {
payload: TUploadSchema & {
file: File
}
} & THttpServer
export const uploadFileRequest = async (parameters: TParameter) => {
const { payload, ...restParameters } = parameters
const formdata = new FormData()
formdata.append('file', payload.file)
try {
const { data } = await HttpServer(restParameters).post(
'/api/file',
formdata,
)
return uploadResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+27
View File
@@ -0,0 +1,27 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
const adResponseSchema = z.object({
id: z.string(),
image_url: z.string(),
url: z.string(),
start_date: z.string(),
end_date: z.string(),
clicked: z.number(),
})
const adsResponseSchema = z.object({
data: z.array(adResponseSchema).nullable(),
})
export type TAdResponse = z.infer<typeof adResponseSchema>
export const getAds = async (parameters?: THttpServer) => {
try {
const { data } = await HttpServer(parameters).get(`/api/ads`)
return adsResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+27
View File
@@ -0,0 +1,27 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
export const categoryResponseSchema = z.object({
id: z.string(),
name: z.string(),
code: z.string(),
sequence: z.number().nullable(),
description: z.string().nullable(),
})
const categoriesResponseSchema = z.object({
data: z.array(categoryResponseSchema),
})
export type TCategoryResponse = z.infer<typeof categoryResponseSchema>
export type TCategoriesResponse = z.infer<typeof categoriesResponseSchema>
export const getCategories = async (parameters?: THttpServer) => {
try {
const { data } = await HttpServer(parameters).get(`/api/category`)
return categoriesResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+66
View File
@@ -0,0 +1,66 @@
import { z } from 'zod'
import { categoryResponseSchema } from '~/apis/common/get-categories'
import { tagResponseSchema } from '~/apis/common/get-tags'
import { HttpServer, type THttpServer } from '~/libs/http-server'
const authorSchema = z.object({
id: z.string(),
name: z.string(),
profile_picture: z.string(),
})
export const newsResponseSchema = z.object({
id: z.string(),
title: z.string(),
content: z.string(),
categories: z.array(categoryResponseSchema),
tags: z.array(tagResponseSchema),
is_premium: z.boolean(),
slug: z.string(),
featured_image: z.string(),
author_id: z.string(),
live_at: z.string(),
created_at: z.string(),
updated_at: z.string(),
author: authorSchema,
})
const dataResponseSchema = z.object({
data: z.array(
newsResponseSchema.extend({
views: z.number(),
}),
),
})
export type TNewsResponse = z.infer<typeof newsResponseSchema>
export type TNewsResponseData = z.infer<typeof dataResponseSchema>
export type TAuthorResponse = z.infer<typeof authorSchema>
type TParameters = {
categories?: string[]
tags?: string[]
active?: boolean
limit?: number
page?: number
query?: string
} & THttpServer
export const getNews = async (parameters?: TParameters) => {
const { categories, tags, active, limit, page, query, ...restParameters } =
parameters || {}
try {
const { data } = await HttpServer(restParameters).get(`/api/news`, {
params: {
...(categories && { categories: categories.join('+') }),
...(tags && { tags: tags.join('+') }),
...(active && { active }),
...(limit && { limit }),
...(page && { page }),
...(query && { q: query }),
},
})
return dataResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+28
View File
@@ -0,0 +1,28 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
const subscribePlanSchema = z.object({
id: z.string(),
code: z.string(),
name: z.string(),
length: z.number(),
price: z.number(),
status: z.number(),
})
const subscribePlanResponseSchema = z.object({
data: z.array(subscribePlanSchema),
})
export type TSubscribePlanResponse = z.infer<typeof subscribePlanSchema>
export const getSubscribePlan = async (parameters?: THttpServer) => {
try {
const { data } = await HttpServer(parameters).get(`/api/subscribe-plan`)
return subscribePlanResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+24
View File
@@ -0,0 +1,24 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
export const tagResponseSchema = z.object({
id: z.string(),
code: z.string(),
name: z.string(),
})
const tagsResponseSchema = z.object({
data: z.array(tagResponseSchema),
})
export type TTagResponse = z.infer<typeof tagResponseSchema>
export const getTags = async (parameters?: THttpServer) => {
try {
const { data } = await HttpServer(parameters).get(`/api/tag`)
return tagsResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+30
View File
@@ -0,0 +1,30 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TAdsSchema } from '~/pages/form-advertisements'
const logAdsResponseSchema = z.object({
data: z.object({
Message: z.string(),
}),
})
type TParameters = {
id: TAdsSchema['id']
} & THttpServer
export const createLogAdsRequest = async (parameters: TParameters) => {
const { id, ...restParameters } = parameters
const payload = {
ads_id: id,
}
try {
const { data } = await HttpServer(restParameters).post(
'/api/logs/ads',
payload,
)
return logAdsResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+25
View File
@@ -0,0 +1,25 @@
import { z } from 'zod'
import { newsResponseSchema } from '~/apis/common/get-news'
import { HttpServer, type THttpServer } from '~/libs/http-server'
const dataResponseSchema = z.object({
data: z.object(newsResponseSchema.shape),
})
type TParameters = {
slug: string
} & THttpServer
export const getNewsBySlug = async (parameters: TParameters) => {
const { slug, ...restParameters } = parameters
try {
const { data } = await HttpServer(restParameters).get(
`/api/news/${encodeURIComponent(slug)}`,
)
return dataResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+36
View File
@@ -0,0 +1,36 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
const userResponseSchema = z.object({
data: z.object({
id: z.string(),
email: z.string(),
phone: z.string(),
subscribe: z.object({
id: z.string(),
subscribe_plan_id: z.string(),
start_date: z.string(),
end_date: z.string().nullable(),
status: z.number(),
auto_renew: z.boolean(),
subscribe_plan: z.object({
id: z.string(),
name: z.string(),
code: z.string(),
}),
}),
}),
})
export type TUserResponse = z.infer<typeof userResponseSchema>
export const getUser = async (parameters: THttpServer) => {
try {
const { data } = await HttpServer(parameters).get(`/api/user/profile`)
return userResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+20
View File
@@ -0,0 +1,20 @@
import { z } from 'zod'
import { type TLoginSchema } from '~/layouts/news/dialog-login'
import { HttpServer } from '~/libs/http-server'
export const loginResponseSchema = z.object({
data: z.object({
token: z.string(),
}),
})
export const userLoginRequest = async (payload: TLoginSchema) => {
try {
const { data } = await HttpServer().post('/api/user/login', payload)
return loginResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+22
View File
@@ -0,0 +1,22 @@
import type { TRegisterSchema } from '~/layouts/news/dialog-register'
import { HttpServer } from '~/libs/http-server'
import { loginResponseSchema } from './login-user'
export const userRegisterRequest = async (payload: TRegisterSchema) => {
const { subscribe_plan, ...restPayload } = payload
const transformedPayload = {
...restPayload,
subscribe_plan_id: subscribe_plan.id,
}
try {
const { data } = await HttpServer().post(
'/api/user/register',
transformedPayload,
)
return loginResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+43 -18
View File
@@ -19,6 +19,14 @@ html,
body {
}
.ProseMirror-focused {
@apply outline-none;
}
.ProseMirror-trailingBreak {
@apply hidden;
}
table.dataTable thead > tr {
border-bottom: 2px solid #c2c2c2;
}
@@ -29,26 +37,43 @@ table.dataTable tbody > tr > td {
border-bottom: 1px solid #ebebeb;
}
/* .embla.hero {
overflow: hidden;
}
.embla__container.hero {
nav[aria-label='pagination'] {
display: flex;
background-color: yellow;
justify-content: center;
}
/* Style untuk tombol aktif (current) */
.dt-paging-button.current {
background-color: #2e2f7c !important;
color: white !important;
}
.embla__slide.hero {
flex: 0 0 100%;
min-width: 0;
} */
.embla__slide {
margin-right: 10px;
flex: 0 0 auto;
min-width: 0;
max-width: 100%;
/* Style tombol aktif, kecuali jika disabled */
div.dt-container .dt-paging .dt-paging-button.current:not(.disabled),
div.dt-container .dt-paging .dt-paging-button.current:not(.disabled):hover {
color: white !important;
background-color: #2e2f7c !important;
min-width: 24px;
padding: 3px 6px;
}
@media (min-width: 768px) {
.embla__slide {
margin-right: 30px;
}
/* Style tombol disabled */
div.dt-container .dt-paging .dt-paging-button.disabled {
background-color: transparent !important;
color: #ccc !important;
cursor: not-allowed;
pointer-events: none; /* Agar tidak bisa diklik */
}
/* Menghindari hover effect untuk tombol yang disabled */
div.dt-container .dt-paging .dt-paging-button:not(.disabled):hover {
background-color: #2e2f7c !important;
color: white !important;
}
/* Style default tombol */
div.dt-container .dt-paging .dt-paging-button {
min-width: 24px;
padding: 3px 6px;
background-color: transparent !important;
color: #2e2f7c !important;
}
+85
View File
@@ -0,0 +1,85 @@
import {
Description,
Dialog,
DialogBackdrop,
DialogPanel,
DialogTitle,
} from '@headlessui/react'
import { useEffect, type PropsWithChildren } from 'react'
import toast from 'react-hot-toast'
import { useFetcher } from 'react-router'
import { Button } from '~/components/ui/button'
type TProperties = PropsWithChildren & {
selectedId?: string
close: () => void
title: string
fetcherAction: string
}
export const DialogDelete = (properties: TProperties) => {
const { selectedId, close, children, title, fetcherAction } = properties || {}
const fetcher = useFetcher()
useEffect(() => {
if (!fetcher.data?.success && fetcher.data?.message) {
toast.error(fetcher.data.message)
}
if (fetcher.data?.success) {
close()
toast.success(`${title} berhasil dihapus!`)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetcher.data])
return (
<Dialog
open={!!selectedId}
onClose={() => {
if (fetcher.state === 'idle') {
close()
}
}}
className="relative z-50"
transition
>
<DialogBackdrop
className="fixed inset-0 bg-black/50 duration-300 ease-out data-[closed]:opacity-0"
transition
/>
<div className="fixed inset-0 flex w-screen justify-center overflow-y-auto p-0 max-sm:bg-white sm:items-center sm:p-4">
<DialogPanel
transition
className="max-w-lg space-y-6 rounded-lg bg-white p-8 duration-300 ease-out data-[closed]:scale-95 data-[closed]:opacity-0 sm:shadow-lg"
>
<DialogTitle className="relative text-xl font-bold">
<span>Anda akan menghapus</span>{' '}
<span className="lowercase">{title}</span> <span>berikut?</span>
</DialogTitle>
<Description className="space-y-1 text-center text-[#565658]">
{children}
</Description>
<div className="flex justify-end">
<fetcher.Form
method="POST"
action={fetcherAction}
className="grid"
>
<Button
type="submit"
variant="danger"
className="text-md h-[42px] rounded-md"
disabled={fetcher.state !== 'idle'}
isLoading={fetcher.state !== 'idle'}
>
Hapus
</Button>
</fetcher.Form>
</div>
</DialogPanel>
</div>
</Dialog>
)
}
@@ -17,7 +17,7 @@ type ModalProperties = {
description?: string
}
export const PopupModal = ({
export const DialogNews = ({
isOpen,
onClose,
children,
@@ -34,7 +34,7 @@ export const PopupModal = ({
className="fixed inset-0 bg-black/50 duration-300 ease-out data-[closed]:opacity-0"
transition
/>
<div className="fixed inset-0 flex w-screen justify-center overflow-y-auto p-4 max-sm:bg-white sm:items-center">
<div className="fixed inset-0 flex w-screen justify-center overflow-y-auto p-0 max-sm:bg-white sm:items-center sm:p-4">
<DialogPanel
transition
className="max-w-lg space-y-6 rounded-lg bg-white p-8 duration-300 ease-out data-[closed]:scale-95 data-[closed]:opacity-0 sm:shadow-lg"
@@ -6,10 +6,13 @@ import {
DialogTitle,
} from '@headlessui/react'
import type { ReactNode } from 'react'
import { Link, useRouteLoaderData } from 'react-router'
import { LeftArrow } from '~/components/icons/left-arrow'
import { Button } from '~/components/ui/button'
import { APP } from '~/configs/meta'
import { useNewsContext } from '~/contexts/news'
import type { loader } from '~/routes/_news'
export type ModalProperties = {
onClose: () => void
@@ -30,7 +33,11 @@ const DESCRIPTIONS: DescriptionMap = {
error: 'Terjadi kesalahan. Silakan coba lagi.',
}
export const SuccessModal = ({ isOpen, onClose }: ModalProperties) => {
export const DialogSuccess = ({ isOpen, onClose }: ModalProperties) => {
const { setIsLoginOpen, setIsSubscribeOpen } = useNewsContext()
const loaderData = useRouteLoaderData<typeof loader>('routes/_news')
const { userData } = loaderData || {}
const message = isOpen
? DESCRIPTIONS[isOpen]
: 'Terjadi kesalahan. Silakan coba lagi.'
@@ -46,7 +53,7 @@ export const SuccessModal = ({ isOpen, onClose }: ModalProperties) => {
className="fixed inset-0 bg-black/50 duration-300 ease-out data-[closed]:opacity-0"
transition
/>
<div className="fixed inset-0 flex w-screen justify-center overflow-y-auto p-4 max-sm:bg-white sm:items-center">
<div className="fixed inset-0 flex w-screen justify-center overflow-y-auto p-0 max-sm:bg-white sm:items-center sm:p-4">
<DialogPanel
className="max-w-lg space-y-6 rounded-lg bg-white p-8 duration-300 ease-out data-[closed]:scale-95 data-[closed]:opacity-0 sm:shadow-lg"
transition
@@ -74,48 +81,58 @@ export const SuccessModal = ({ isOpen, onClose }: ModalProperties) => {
<div className="relative">
<div className="relative flex flex-col items-center justify-center">
<div className="mb-4 p-4 text-center">
{isOpen &&
['resetPassword', 'register', 'payment'].includes(isOpen) && (
<div className="justify-center">
<img
src={'/images/back-to-home.svg'}
alt={APP.title}
className="h-[300px]"
/>
<Button
className="mt-5 w-full rounded-md"
variant={'newsPrimary'}
onClick={onClose}
>
Back to Home
</Button>
</div>
{['resetPassword', 'register', 'payment'].includes(
isOpen || '',
) && (
<>
<img
src={'/images/back-to-home.svg'}
alt={APP.title}
className="h-[300px]"
/>
<Button
className="mt-5 w-full rounded-md"
variant="primary"
as={Link}
to="/"
onClick={onClose}
>
Back to Home
</Button>
</>
)}
{isOpen === 'warning' && (
<>
<img
src={'/images/warning.svg'}
alt={APP.title}
className="h-[300px]"
/>
{userData ? (
<Button
className="mt-5 w-full rounded-md"
variant="outline"
onClick={() => {
onClose()
setIsSubscribeOpen(true)
}}
>
Pilih Paken Berlangganan
</Button>
) : (
<Button
className="mt-5 w-full rounded-md"
variant="primary"
onClick={() => {
onClose()
setIsLoginOpen(true)
}}
>
Login
</Button>
)}
{isOpen === 'warning' && (
<div className="justify-center">
<img
src={'/images/warning.svg'}
alt={APP.title}
className="h-[300px]"
/>
<div>
<Button
className="mt-5 w-full rounded-md"
variant={'newsPrimary'}
>
Login
</Button>
<Button
className="mt-5 w-full rounded-md"
variant={'newsSecondary'}
>
Select Subscription
</Button>
</div>
</div>
)}
</div>
</>
)}
</div>
</div>
</DialogPanel>
+10
View File
@@ -0,0 +1,10 @@
import { useAsyncError } from 'react-router'
export const ErrorAwait = () => {
const error = useAsyncError()
return (
<p>
{error instanceof Error ? error.message : 'An unexpected error occurred.'}
</p>
)
}
-23
View File
@@ -1,23 +0,0 @@
import type { JSX, SVGProps } from 'react'
export const CashierMachineIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={14}
height={16}
viewBox="0 0 14 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.778 3.87h-1.553v-.988h1.268c.324 0 .586-.325.586-.723V.833c0-.4-.262-.723-.586-.723h-3.46c-.325 0-.586.324-.586.723V2.16c0 .4.26.723.585.723h1.292v.989H3.07a.628.628 0 00-.629.626L.876 12.59v2.521H14v-2.52l-1.593-8.093a.628.628 0 00-.63-.626zm-7.15 7.505h-.966v-.982h.967v.982zM3.662 9.5v-.982h.967V9.5H3.66zm2.843 1.89h-.967v-.997h.967v.996zm0-1.905h-.982v-.953h.982v.953zm1.875 1.902h-.982v-.994h.982v.994zm-.982-1.902v-.967h.982v.967h-.982zM8.38 7.61H3.662V5.735H8.38V7.61zm2.798 0H9.285v-.956h1.892v.956z"
fill="currentColor"
/>
</svg>
)
}
-23
View File
@@ -1,23 +0,0 @@
import type { JSX, SVGProps } from 'react'
export const ChartIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={18}
height={19}
viewBox="0 0 18 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.498 1.61h7.004c2.55 0 3.99 1.447 3.998 3.998v7.005c0 2.55-1.447 3.997-3.998 3.997H5.498c-2.551 0-3.998-1.447-3.998-3.997V5.608c0-2.55 1.447-3.998 3.998-3.998zm3.539 11.895a.62.62 0 00.623-.562V5.3a.612.612 0 00-.285-.592.63.63 0 00-.96.592v7.643a.632.632 0 00.622.562zm3.45 0c.316 0 .585-.24.623-.562v-2.46a.629.629 0 00-.96-.593.604.604 0 00-.285.593v2.46c.03.322.3.562.623.562zm-6.322-.562a.62.62 0 01-.623.562.62.62 0 01-.622-.562V7.76a.63.63 0 01.293-.592.617.617 0 01.66 0c.202.127.315.36.292.592v5.183z"
fill="currentColor"
/>
</svg>
)
}
-23
View File
@@ -1,23 +0,0 @@
import type { JSX, SVGProps } from 'react'
export const ChatIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={18}
height={19}
viewBox="0 0 18 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M1.5 9.122C1.5 5.17 4.657 1.61 9.015 1.61c4.26 0 7.485 3.493 7.485 7.49 0 4.633-3.78 7.51-7.5 7.51-1.23 0-2.595-.33-3.69-.976-.382-.233-.705-.406-1.117-.27l-1.515.45c-.383.12-.728-.18-.615-.586l.502-1.682a.786.786 0 00-.052-.676C1.868 11.683 1.5 10.384 1.5 9.122zm6.525 0c0 .533.427.961.96.969a.963.963 0 000-1.923.956.956 0 00-.96.954zm3.457.007c0 .526.428.962.96.962a.963.963 0 000-1.923.958.958 0 00-.96.961zm-5.954.962a.967.967 0 01-.96-.962c0-.533.427-.961.96-.961.532 0 .96.428.96.961a.967.967 0 01-.96.962z"
fill="currentColor"
/>
</svg>
)
}
-28
View File
@@ -1,28 +0,0 @@
import type { JSX, SVGProps } from 'react'
/**
* Note: `ChevronDoubleIcon` default mengarah ke kiri.
* Gunakan class `rotate-xx` untuk mengubah arah ikon.
*/
export const ChevronDoubleIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={20}
height={20}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={properties.className}
{...properties}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.512 4.427l-2.984 3.58 2.877 3.575a.667.667 0 01-1.04.836l-3.218-4a.667.667 0 01.007-.844l3.334-4a.667.667 0 011.024.853zm-5.69-.853a.667.667 0 011.023.853l-2.984 3.58 2.877 3.575a.667.667 0 01-1.039.836l-3.218-4a.666.666 0 01.007-.844l3.333-4z"
fill="currentColor"
/>
</svg>
)
}
-25
View File
@@ -1,25 +0,0 @@
import type { JSX, SVGProps } from 'react'
/**
* Note: `ChevronIcon` default mengarah ke bawah.
* Gunakan class `rotate-xx` untuk mengubah arah ikon.
*/
export const ChevronIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={21}
height={21}
viewBox="0 0 21 21"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={properties.className}
{...properties}
>
<path
d="M10.197 13.623l5.008-5.008-1.177-1.18-3.83 3.834-3.831-3.833-1.178 1.178 5.008 5.009z"
fill="currentColor"
/>
</svg>
)
}
-21
View File
@@ -1,21 +0,0 @@
import type { JSX, SVGProps } from 'react'
export const DoctorIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={25}
height={28}
viewBox="0 0 25 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
d="M12.714 14.364a6.821 6.821 0 006.822-6.822A6.821 6.821 0 0012.714.72a6.822 6.822 0 00-6.822 6.822 6.822 6.822 0 006.822 6.822zM6.32 23.318c0 .708.57 1.279 1.279 1.279s1.279-.57 1.279-1.28c0-.708-.57-1.278-1.28-1.278-.708 0-1.278.57-1.278 1.279zM17.83 16.1v2.612a4.27 4.27 0 013.41 4.178v2.223a.855.855 0 01-.687.837l-1.716.34a.424.424 0 01-.501-.335l-.165-.837a.422.422 0 01.336-.5l1.028-.209v-1.519c0-3.347-5.116-3.47-5.116.102v1.423l1.028.207c.23.048.379.272.336.502l-.165.836a.432.432 0 01-.501.336l-1.663-.224a.852.852 0 01-.736-.847V22.89a4.274 4.274 0 013.412-4.178v-2.41c-.118.038-.235.06-.352.102a9.244 9.244 0 01-3.06.522c-1.07 0-2.1-.186-3.059-.522a5.889 5.889 0 00-1.204-.277v4.349a2.974 2.974 0 012.132 2.846 2.987 2.987 0 01-2.985 2.985 2.987 2.987 0 01-2.985-2.985c0-1.348.901-2.478 2.132-2.846v-4.285c-3.39.57-5.974 3.49-5.974 7.04v2.388a2.39 2.39 0 002.387 2.388h19.102a2.39 2.39 0 002.388-2.388v-2.388c0-3.837-3.028-6.944-6.822-7.13z"
fill="currentColor"
/>
</svg>
)
}
-23
View File
@@ -1,23 +0,0 @@
import type { JSX, SVGProps } from 'react'
export const DocumentIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={18}
height={19}
viewBox="0 0 18 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.857 1.61h6.286c2.317 0 3.607 1.335 3.607 3.623v7.747c0 2.325-1.29 3.63-3.607 3.63H5.857c-2.28 0-3.607-1.305-3.607-3.63V5.233c0-2.288 1.328-3.623 3.607-3.623zm.203 3.495v-.007h2.242a.588.588 0 010 1.178H6.06a.585.585 0 010-1.17zm0 4.56h5.88a.586.586 0 000-1.17H6.06a.586.586 0 000 1.17zm0 3.428h5.88c.3-.03.525-.286.525-.585a.588.588 0 00-.525-.593H6.06a.596.596 0 00-.563.908c.12.187.338.3.563.27z"
fill="currentColor"
/>
</svg>
)
}
-21
View File
@@ -1,21 +0,0 @@
import type { JSX, SVGProps } from 'react'
export const GraphIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={29}
height={28}
viewBox="0 0 29 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
d="M19.383 2.499H9.471c-4.306 0-6.872 2.496-6.872 6.682v9.625c0 4.197 2.566 6.693 6.872 6.693h9.9c4.306 0 6.873-2.495 6.873-6.681V9.18c.012-4.186-2.555-6.682-6.86-6.682zM9.258 21.071c0 .472-.402.863-.887.863s-.887-.391-.887-.863v-2.38c0-.471.402-.863.887-.863s.887.392.887.863v2.38zm6.056 0c0 .472-.402.863-.887.863s-.887-.391-.887-.863V16.3c0-.471.402-.862.887-.862s.887.39.887.862v4.773zm6.057 0c0 .472-.403.863-.888.863s-.887-.391-.887-.863v-7.153c0-.471.402-.862.887-.862s.888.391.888.863v7.152zm0-10.787c0 .472-.403.863-.888.863s-.887-.391-.887-.863V9.17a23.266 23.266 0 01-11.012 6.164c-.071.023-.142.023-.213.023a.891.891 0 01-.864-.655.859.859 0 01.651-1.047 21.515 21.515 0 0010.35-5.876H17.03c-.485 0-.887-.391-.887-.863 0-.471.402-.862.887-.862h3.466c.048 0 .083.023.13.023.06.011.119.011.178.034.059.023.106.058.165.092.036.023.071.035.107.058.012.011.012.023.024.023.047.046.082.092.118.138.035.046.07.08.083.126.023.046.023.092.035.15.012.057.036.115.036.184 0 .011.011.023.011.034v3.37h-.011z"
fill="currentColor"
/>
</svg>
)
}
+19
View File
@@ -0,0 +1,19 @@
import type { JSX, SVGProps } from 'react'
export const ImageSkeletonIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={40}
height={40}
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 20 18"
{...properties}
>
<path d="M18 0H2a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2Zm-5.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm4.376 10.481A1 1 0 0 1 16 15H4a1 1 0 0 1-.895-1.447l3.5-7A1 1 0 0 1 7.468 6a.965.965 0 0 1 .9.5l2.775 4.757 1.546-1.887a1 1 0 0 1 1.618.1l2.541 4a1 1 0 0 1 .028 1.011Z" />
</svg>
)
}
-22
View File
@@ -1,22 +0,0 @@
import type { JSX, SVGProps } from 'react'
export const LinkIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
width={95}
height={95}
stroke="currentColor"
fill="currentColor"
strokeWidth="0"
viewBox="0 0 1024 1024"
{...properties}
>
<path d="M574 665.4a8.03 8.03 0 0 0-11.3 0L446.5 781.6c-53.8 53.8-144.6 59.5-204 0-59.5-59.5-53.8-150.2 0-204l116.2-116.2c3.1-3.1 3.1-8.2 0-11.3l-39.8-39.8a8.03 8.03 0 0 0-11.3 0L191.4 526.5c-84.6 84.6-84.6 221.5 0 306s221.5 84.6 306 0l116.2-116.2c3.1-3.1 3.1-8.2 0-11.3L574 665.4zm258.6-474c-84.6-84.6-221.5-84.6-306 0L410.3 307.6a8.03 8.03 0 0 0 0 11.3l39.7 39.7c3.1 3.1 8.2 3.1 11.3 0l116.2-116.2c53.8-53.8 144.6-59.5 204 0 59.5 59.5 53.8 150.2 0 204L665.3 562.6a8.03 8.03 0 0 0 0 11.3l39.8 39.8c3.1 3.1 8.2 3.1 11.3 0l116.2-116.2c84.5-84.6 84.5-221.5 0-306.1zM610.1 372.3a8.03 8.03 0 0 0-11.3 0L372.3 598.7a8.03 8.03 0 0 0 0 11.3l39.6 39.6c3.1 3.1 8.2 3.1 11.3 0l226.4-226.4c3.1-3.1 3.1-8.2 0-11.3l-39.5-39.6z"></path>
</svg>
)
}
-21
View File
@@ -1,21 +0,0 @@
import type { JSX, SVGProps } from 'react'
export const MedicalNotesIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={12}
height={16}
viewBox="0 0 12 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
d="M9.844 1.985H7.5C7.5.951 6.66.11 5.625.11 4.591.11 3.75.951 3.75 1.985H1.406C.63 1.985 0 2.615 0 3.392v10.312c0 .776.63 1.406 1.406 1.406h8.438c.776 0 1.406-.63 1.406-1.406V3.392c0-.777-.63-1.407-1.406-1.407zm-4.219-.703c.39 0 .703.314.703.703 0 .39-.313.703-.703.703a.701.701 0 01-.703-.703c0-.39.313-.703.703-.703zm2.813 8.906a.235.235 0 01-.235.235h-1.64v1.64a.235.235 0 01-.235.235H4.922a.235.235 0 01-.234-.235v-1.64H3.046a.235.235 0 01-.235-.235V8.782c0-.129.106-.234.235-.234h1.64v-1.64c0-.13.106-.235.235-.235h1.406c.129 0 .234.105.234.234v1.64h1.641c.129 0 .235.106.235.235v1.406zm0-5.625a.235.235 0 01-.235.235H3.047a.235.235 0 01-.235-.235v-.468c0-.13.106-.235.235-.235h5.156c.129 0 .235.106.235.235v.468z"
fill="currentColor"
/>
</svg>
)
}
-37
View File
@@ -1,37 +0,0 @@
import type { JSX, SVGProps } from 'react'
type TNotificationIcon = {
showBadge?: boolean
} & JSX.IntrinsicAttributes &
SVGProps<SVGSVGElement>
export const NotificationIcon = ({
showBadge = false,
...properties
}: TNotificationIcon) => {
return (
<svg
width={16}
height={19}
viewBox="0 0 16 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
d="M6.597 18.53a1.616 1.616 0 01-1.608-1.6h3.2c.001.213-.04.425-.12.623-.21.482-.639.833-1.152.944h-.038c-.093.02-.187.03-.282.032zm6.4-2.4H.197v-1.6l1.6-.8v-4.4a6.452 6.452 0 01.739-3.249 3.723 3.723 0 012.46-1.808V2.53h3.2v1.744c2.064.492 3.2 2.287 3.2 5.056v4.4l1.6.8v1.6z"
fill="currentColor"
/>
{showBadge && (
<circle
cx={11.1968}
cy={4.306_64}
r={3.6}
fill="#EC5252"
stroke="#fff"
strokeWidth={1.2}
/>
)}
</svg>
)
}
-20
View File
@@ -1,20 +0,0 @@
import type { JSX, SVGProps } from 'react'
export const PlusIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={14}
height={14}
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
d="M6 8H1a.965.965 0 01-.712-.288A.972.972 0 010 7c0-.283.095-.52.288-.712A.97.97 0 011 6h5V1c0-.283.096-.52.288-.712A.972.972 0 017 0c.283 0 .52.095.713.288A.96.96 0 018 1v5h5c.283 0 .521.096.713.288.192.192.288.43.287.712 0 .283-.097.52-.288.713A.957.957 0 0113 8H8v5a.968.968 0 01-.288.713A.964.964 0 017 14a.973.973 0 01-.712-.288A.965.965 0 016 13V8z"
fill="currentColor"
/>
</svg>
)
}
-23
View File
@@ -1,23 +0,0 @@
import type { JSX, SVGProps } from 'react'
export const ProfileIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={18}
height={19}
viewBox="0 0 18 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.97 5.82A3.956 3.956 0 019 9.789a3.956 3.956 0 01-3.97-3.97A3.955 3.955 0 019 1.853a3.955 3.955 0 013.97 3.968zM9 16.852c-3.253 0-6-.53-6-2.57s2.764-2.55 6-2.55c3.254 0 6 .529 6 2.569s-2.764 2.55-6 2.55z"
fill="currentColor"
/>
</svg>
)
}
-23
View File
@@ -1,23 +0,0 @@
import type { JSX, SVGProps } from 'react'
export const SettingIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={18}
height={19}
viewBox="0 0 18 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.302 10.537c.268.142.475.367.62.592.284.465.261 1.035-.015 1.538l-.537.9c-.283.48-.812.78-1.356.78a1.6 1.6 0 01-.813-.225c-.199-.128-.429-.173-.674-.173-.759 0-1.395.623-1.418 1.365 0 .863-.705 1.538-1.587 1.538H8.48c-.89 0-1.594-.675-1.594-1.538-.016-.742-.652-1.365-1.41-1.365-.254 0-.483.045-.675.173a1.62 1.62 0 01-.813.225 1.6 1.6 0 01-1.364-.78l-.529-.9c-.283-.488-.299-1.073-.015-1.538.123-.225.353-.45.613-.592.215-.105.353-.278.483-.48.383-.645.153-1.493-.498-1.875a1.533 1.533 0 01-.567-2.123l.513-.885a1.593 1.593 0 012.162-.57c.666.36 1.533.12 1.923-.517a1.16 1.16 0 00.177-.66c-.016-.293.069-.57.214-.795a1.646 1.646 0 011.357-.78h1.08c.568 0 1.081.315 1.365.78.138.225.23.502.207.795-.015.225.054.45.176.66a1.48 1.48 0 001.932.517 1.584 1.584 0 012.153.57l.514.885a1.52 1.52 0 01-.567 2.123c-.652.382-.882 1.23-.49 1.875.122.202.26.375.475.48zm-8.47-1.178c0 1.178.973 2.115 2.177 2.115 1.203 0 2.153-.937 2.153-2.115 0-1.177-.95-2.122-2.153-2.122-1.204 0-2.177.945-2.177 2.122z"
fill="currentColor"
/>
</svg>
)
}
-21
View File
@@ -1,21 +0,0 @@
import type { JSX, SVGProps } from 'react'
export const UserDoctorIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={25}
height={28}
viewBox="0 0 25 28"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
d="M12.714 14.364a6.821 6.821 0 006.822-6.822A6.821 6.821 0 0012.714.72a6.822 6.822 0 00-6.822 6.822 6.822 6.822 0 006.822 6.822zM6.32 23.318c0 .708.57 1.279 1.279 1.279s1.279-.57 1.279-1.28c0-.708-.57-1.278-1.28-1.278-.708 0-1.278.57-1.278 1.279zM17.83 16.1v2.612a4.27 4.27 0 013.41 4.178v2.223a.855.855 0 01-.687.837l-1.716.34a.424.424 0 01-.501-.335l-.165-.837a.422.422 0 01.336-.5l1.028-.209v-1.519c0-3.347-5.116-3.47-5.116.102v1.423l1.028.207c.23.048.379.272.336.502l-.165.836a.432.432 0 01-.501.336l-1.663-.224a.852.852 0 01-.736-.847V22.89a4.274 4.274 0 013.412-4.178v-2.41c-.118.038-.235.06-.352.102a9.244 9.244 0 01-3.06.522c-1.07 0-2.1-.186-3.059-.522a5.889 5.889 0 00-1.204-.277v4.349a2.974 2.974 0 012.132 2.846 2.987 2.987 0 01-2.985 2.985 2.987 2.987 0 01-2.985-2.985c0-1.348.901-2.478 2.132-2.846v-4.285c-3.39.57-5.974 3.49-5.974 7.04v2.388a2.39 2.39 0 002.387 2.388h19.102a2.39 2.39 0 002.388-2.388v-2.388c0-3.837-3.028-6.944-6.822-7.13z"
fill="currentColor"
/>
</svg>
)
}
-23
View File
@@ -1,23 +0,0 @@
import type { JSX, SVGProps } from 'react'
export const WalletIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={18}
height={19}
viewBox="0 0 18 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13.327 6.638H16.5c0-2.548-1.527-4.036-4.113-4.036H5.613C3.027 2.602 1.5 4.09 1.5 6.605v5.493c0 2.515 1.527 4.004 4.113 4.004h6.774c2.586 0 4.113-1.489 4.113-4.004v-.234h-3.173c-1.473 0-2.667-1.164-2.667-2.6 0-1.436 1.194-2.6 2.667-2.6v-.026zm0 1.118h2.613c.31 0 .56.244.56.546V10.2a.56.56 0 01-.56.546h-2.553A1.58 1.58 0 0111.82 9.55c-.085-.44.034-.893.325-1.24.29-.345.723-.548 1.181-.554zm.113 1.995h.247c.316 0 .573-.25.573-.559a.566.566 0 00-.573-.559h-.247a.574.574 0 00-.405.16.545.545 0 00-.168.393c0 .31.255.562.573.565zM5.053 6.638h4.234c.316 0 .573-.25.573-.56a.566.566 0 00-.573-.558H5.053a.567.567 0 00-.573.552c0 .31.256.562.573.566z"
fill="currentColor"
/>
</svg>
)
}
@@ -0,0 +1,35 @@
import { Button } from '@headlessui/react'
import type { CSSProperties, MouseEventHandler, ReactNode } from 'react'
import { twMerge } from 'tailwind-merge'
type TProperties = {
children: ReactNode
onClick: MouseEventHandler
disabled?: boolean
isActive?: boolean
className?: string
title: string
style?: CSSProperties
}
export const EditorButton = (properties: TProperties) => {
const { children, onClick, disabled, className, isActive, title, style } =
properties
return (
<Button
type="button"
onClick={onClick}
disabled={disabled}
className={twMerge(
'flex h-6 w-8 cursor-pointer items-center justify-center rounded-md p-2 text-black hover:bg-[#2E2F7C] hover:text-white active:bg-[#2E2F7C]/50 disabled:cursor-not-allowed disabled:bg-[#2E2F7C]/50 disabled:text-white disabled:opacity-50',
isActive ? 'bg-[#2E2F7C]/10' : '',
className,
)}
style={style}
title={title}
>
{children}
</Button>
)
}
@@ -0,0 +1,424 @@
import { Input } from '@headlessui/react'
import {
ArrowUturnLeftIcon,
ArrowUturnRightIcon,
Bars3BottomLeftIcon,
Bars3BottomRightIcon,
Bars3Icon,
BoldIcon,
CloudArrowUpIcon,
CodeBracketIcon,
DocumentTextIcon,
H1Icon,
H2Icon,
H3Icon,
ItalicIcon,
LinkIcon,
LinkSlashIcon,
ListBulletIcon,
NumberedListIcon,
PhotoIcon,
StrikethroughIcon,
SwatchIcon,
XCircleIcon,
} from '@heroicons/react/24/solid'
import {
Bars3BottomCenterIcon,
QuotationMarkIcon,
} from '@sidekickicons/react/24/solid'
import type { Editor } from '@tiptap/react'
import {
type SetStateAction,
type Dispatch,
useState,
useRef,
useCallback,
useEffect,
} from 'react'
import { HexColorInput, HexColorPicker } from 'react-colorful'
import { Button } from '~/components/ui/button'
import { useAdminContext } from '~/contexts/admin'
import { useClickOutside } from '~/hooks/use-click-outside'
import { isHexCompatible, rgbToHex } from '~/utils/color'
import { EditorButton } from './editor-button'
type TProperties = {
editor: Editor | null
setIsPlainHTML: Dispatch<SetStateAction<boolean>>
category: string
disabled?: boolean
}
export const EditorMenuBar = (properties: TProperties) => {
const { editor, setIsPlainHTML, category, disabled = false } = properties
const { setIsUploadOpen, uploadedFile, setUploadedFile, isUploadOpen } =
useAdminContext()
const [isOpenImage, setIsOpenImage] = useState(false)
const [imageUrl, setImageUrl] = useState('')
const [isOpenColor, setIsOpenColor] = useState(false)
const popover = useRef<HTMLDivElement>(null)
const close = useCallback(() => {
setIsOpenColor(false)
setIsOpenImage(false)
if (imageUrl) {
addImage(imageUrl)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [imageUrl])
useEffect(() => {
if (uploadedFile && isUploadOpen === category) {
addImage(uploadedFile)
setUploadedFile(undefined)
setIsUploadOpen(undefined)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uploadedFile])
useClickOutside(popover, close)
const setLink = useCallback(() => {
const previousUrl = editor?.getAttributes('link').href
const url = globalThis.prompt('URL', previousUrl)
// cancelled
if (url === null) {
return
}
// empty
if (url === '') {
editor?.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
// update link
editor?.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}, [editor])
if (!editor) {
return
}
const addImage = (url: string) => {
if (url) {
editor.chain().focus().setImage({ src: url }).run()
}
}
const currentColor: string = editor.getAttributes('textStyle').color
const rgbColor = isHexCompatible(currentColor)
? currentColor
: rgbToHex(currentColor)
const handleChangeColor = (selectedColor: string) => {
if (selectedColor.length === 7) {
editor.chain().focus().setColor(selectedColor).run()
}
}
return (
<div className="flex items-start justify-between gap-4 px-4 py-3">
<div className="flex divide-x">
<div className="flex max-w-[150px] flex-wrap items-start gap-1 px-1">
<EditorButton
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={
disabled || !editor.can().chain().focus().toggleBold().run()
}
isActive={editor.isActive('bold')}
title="Bold"
>
<BoldIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={
disabled || !editor.can().chain().focus().toggleItalic().run()
}
isActive={editor.isActive('italic')}
title="Italic"
>
<ItalicIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().toggleStrike().run()}
disabled={
disabled || !editor.can().chain().focus().toggleStrike().run()
}
isActive={editor.isActive('strike')}
title="Strike"
>
<StrikethroughIcon className="size-4" />
</EditorButton>
<div className="relative">
<EditorButton
onClick={() => setIsOpenColor(true)}
title="Text Color"
style={{
color: rgbColor,
}}
isActive={true}
disabled={disabled}
>
<SwatchIcon className="size-4" />
</EditorButton>
{isOpenColor && (
<div
className="border-md absolute top-8 left-0"
ref={popover}
>
<HexColorPicker
className="z-10 rounded-lg shadow"
color={rgbColor}
onChange={handleChangeColor}
/>
<div className="">
<HexColorInput
color={rgbColor}
onChange={handleChangeColor}
prefixed
className="relative z-10 mt-1 flex w-full rounded-lg border-0 bg-white px-3 py-2 text-sm shadow read-only:bg-gray-100 focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none focus-visible:outline-0 disabled:bg-gray-100"
/>
</div>
</div>
)}
</div>
<EditorButton
onClick={() => editor.chain().focus().setTextAlign('left').run()}
disabled={
disabled ||
!editor.can().chain().focus().setTextAlign('left').run()
}
isActive={editor.isActive({ textAlign: 'left' })}
title="Align Left"
>
<Bars3BottomLeftIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().setTextAlign('center').run()}
disabled={
disabled ||
!editor.can().chain().focus().setTextAlign('center').run()
}
isActive={editor.isActive({ textAlign: 'center' })}
title="Align Center"
>
<Bars3BottomCenterIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().setTextAlign('right').run()}
disabled={
disabled ||
!editor.can().chain().focus().setTextAlign('right').run()
}
isActive={editor.isActive({ textAlign: 'right' })}
title="Align Right"
>
<Bars3BottomRightIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
disabled={
disabled ||
!editor.can().chain().focus().setTextAlign('justify').run()
}
isActive={editor.isActive({ textAlign: 'justify' })}
title="Align Justify"
>
<Bars3Icon className="size-4" />
</EditorButton>
</div>
<div className="flex max-w-[150px] flex-wrap items-start gap-1 px-1">
<EditorButton
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
isActive={editor.isActive('heading', { level: 1 })}
title="Heading 1"
disabled={disabled}
>
<H1Icon className="size-4" />
</EditorButton>
<EditorButton
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
isActive={editor.isActive('heading', { level: 2 })}
title="Heading 2"
disabled={disabled}
>
<H2Icon className="size-4" />
</EditorButton>
<EditorButton
onClick={() =>
editor.chain().focus().toggleHeading({ level: 3 }).run()
}
isActive={editor.isActive('heading', { level: 3 })}
title="Heading 3"
disabled={disabled}
>
<H3Icon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
isActive={editor.isActive('codeBlock')}
title="Code Block"
disabled={disabled}
>
<CodeBracketIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
title="Bullet List"
disabled={disabled}
>
<ListBulletIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
title="Ordered List"
disabled={disabled}
>
<NumberedListIcon className="size-4" />
</EditorButton>
{/* <EditorButton
onClick={() => editor.chain().focus().setParagraph().run()}
isActive={editor.isActive('paragraph')}
title="Paragraph"
disabled={disabled}
>
<PilcrowIcon className="size-4" />
</EditorButton> */}
<EditorButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')}
title="Blockquote"
disabled={disabled}
>
<QuotationMarkIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => {
editor.chain().focus().unsetAllMarks().run()
editor.chain().focus().clearNodes().run()
}}
title="Clear Format"
disabled={disabled}
>
<XCircleIcon className="size-4" />
</EditorButton>
{/* <EditorButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Horizontal Rule"
disabled={disabled}
>
<RiSeparator />
</EditorButton> */}
</div>
{/* <div className="flex items-start gap-1 px-1">
<EditorButton
onClick={() => editor.chain().focus().setHardBreak().run()}
title="Hard Break"
disabled={disabled}
>
<RiTextWrap />
</EditorButton>
</div> */}
<div className="flex items-start gap-1 px-1">
<div className="relative">
<EditorButton
onClick={() => setIsOpenImage(true)}
title="Insert Image"
disabled={disabled}
>
<PhotoIcon className="size-4" />
</EditorButton>
{isOpenImage && (
<div
className="border-md absolute top-8 left-0 w-50"
ref={popover}
>
<div className="relative">
<Input
placeholder="Masukkan URL Gambar"
color={rgbColor}
onChange={(event) => {
setImageUrl(event.target.value)
}}
className="z-10 flex h-[42px] w-full rounded-lg border-0 bg-white p-2 pr-8 text-sm shadow read-only:bg-gray-100 focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none focus-visible:outline-0 disabled:bg-gray-100"
/>
<Button
type="button"
variant="icon"
size="fit"
className="absolute top-0 right-3 h-[42px]"
onClick={() => {
setIsUploadOpen('content')
}}
>
<CloudArrowUpIcon className="size-4 text-gray-500/50" />
</Button>
</div>
</div>
)}
</div>
<EditorButton
onClick={() => setLink()}
disabled={
disabled ||
!editor
.can()
.chain()
.focus()
.extendMarkRange('link')
.setLink({ href: '' })
.run()
}
isActive={editor.isActive('link')}
title="Set Link"
>
<LinkIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().unsetLink().run()}
disabled={disabled || !editor.isActive('link')}
title="Unset Link"
>
<LinkSlashIcon className="size-4" />
</EditorButton>
</div>
<div className="flex items-start gap-1 px-1">
<EditorButton
onClick={() => editor.chain().focus().undo().run()}
disabled={disabled || !editor.can().chain().focus().undo().run()}
title="Undo"
>
<ArrowUturnLeftIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().redo().run()}
disabled={disabled || !editor.can().chain().focus().redo().run()}
title="Redo"
>
<ArrowUturnRightIcon className="size-4" />
</EditorButton>
</div>
</div>
<div>
<div className="flex gap-1 px-1">
<EditorButton
onClick={() => setIsPlainHTML(true)}
title="Switch to Plain Text"
>
<DocumentTextIcon className="size-4" />
</EditorButton>
</div>
</div>
</div>
)
}
@@ -0,0 +1,55 @@
import { CodeBracketSquareIcon } from '@heroicons/react/24/solid'
import MonacoEditor from '@monaco-editor/react'
import type { Dispatch, SetStateAction } from 'react'
import { Controller } from 'react-hook-form'
import { useRemixFormContext } from 'remix-hook-form'
import { EditorButton } from './editor-button'
type TProperties = {
name: string
disabled?: boolean
setIsPlainHTML: Dispatch<SetStateAction<boolean>>
}
// const MonacoEditor = dynamic(() => import('@monaco-editor/react'), {
// ssr: false,
// })
export const EditorTextArea = (properties: TProperties) => {
const { setIsPlainHTML, name, disabled = false } = properties
const { control } = useRemixFormContext()
return (
<>
<div className="flex justify-end rounded-[5px_5px_0_0] border-x border-t border-[#D2D2D2] px-4 py-3">
<div className="flex gap-1 px-1">
<EditorButton
onClick={() => setIsPlainHTML(false)}
title="Switch to Rich Text"
>
<CodeBracketSquareIcon />
</EditorButton>
</div>
</div>
<Controller
name={name}
control={control}
render={({ field }) => (
<MonacoEditor
language="html"
onChange={(newValue) => {
field.onChange(newValue)
}}
value={field.value}
options={{
readOnly: disabled,
wordWrap: 'on',
}}
className="mb-1 h-96 max-w-none overflow-y-auto rounded-[0_0_5px_5px] border border-[#D2D2D2] p-1"
/>
)}
/>
</>
)
}
+144
View File
@@ -0,0 +1,144 @@
import { Field, Label } from '@headlessui/react'
import { Color } from '@tiptap/extension-color'
import Highlight from '@tiptap/extension-highlight'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import TextAlign from '@tiptap/extension-text-align'
import TextStyle from '@tiptap/extension-text-style'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useEffect, useId, useState } from 'react'
import { get, type FieldError, type RegisterOptions } from 'react-hook-form'
import { useRemixFormContext } from 'remix-hook-form'
import { twMerge } from 'tailwind-merge'
import { EditorMenuBar } from './editor-menubar'
import { EditorTextArea } from './editor-textarea'
type TProperties = {
id?: string
name: string
label?: string
placeholder?: string
labelClassName?: string
className?: string
inputClassName?: string
containerClassName?: string
rules?: RegisterOptions
disabled?: boolean
isRequired?: boolean
category: string
}
export const TextEditor = (properties: TProperties) => {
const {
id,
label,
name,
labelClassName,
className,
inputClassName,
category,
disabled = false,
containerClassName,
} = properties
const [isPlainHTML, setIsPlainHTML] = useState(false)
const [init, setInit] = useState(true)
const generatedId = useId()
const {
setValue,
watch,
formState: { errors },
} = useRemixFormContext()
const watchContent = watch(name)
const error: FieldError = get(errors, name)
const editor = useEditor({
editable: !disabled,
extensions: [
StarterKit.configure({
paragraph: {
HTMLAttributes: {
class: 'min-h-1',
},
},
}),
Highlight,
Image.configure({
inline: true,
}),
TextStyle,
Color.configure({
types: ['textStyle'],
}),
Link.configure({
openOnClick: false,
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
],
immediatelyRender: false,
content: watchContent,
onUpdate: ({ editor }) => {
setValue(name, editor.getHTML() as string)
},
})
useEffect(() => {
if (
watchContent &&
watchContent.length > 0 &&
editor &&
(isPlainHTML || init)
) {
editor.commands.setContent(watchContent)
setInit(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [watchContent])
return (
<Field
className={twMerge('relative', containerClassName)}
disabled={disabled}
id={id}
>
{label && (
<Label className={twMerge('mb-1 block text-gray-700', labelClassName)}>
{label}{' '}
{error && <span className="text-red-500">{error.message}</span>}
</Label>
)}
{isPlainHTML ? (
<EditorTextArea
setIsPlainHTML={setIsPlainHTML}
name={name}
disabled={disabled}
/>
) : (
<div className={twMerge('rounded-md', className)}>
<EditorMenuBar
disabled={disabled}
category={category}
editor={editor}
setIsPlainHTML={setIsPlainHTML}
/>
<EditorContent
readOnly={disabled}
editor={editor}
id={id ?? generatedId}
className={twMerge(
'prose prose-headings:my-0.5 prose-p:my-0.5 max-w-none cursor-text overflow-y-auto rounded-b-md p-2',
inputClassName,
)}
onClick={() => editor?.commands.focus()}
/>
</div>
)}
</Field>
)
}
-22
View File
@@ -1,22 +0,0 @@
import { Link } from 'react-router'
import { APP } from '~/configs/meta'
export const Banner = () => {
return (
<div className="min-h-[65px] sm:mx-10">
<div className="relative">
<Link
to="/#"
className="mt-2 h-full py-2"
>
<img
src={'/images/banner.png'}
alt={APP.title}
className="h-[70px] w-[100%] content-center sm:h-full"
/>
</Link>
</div>
</div>
)
}
-21
View File
@@ -1,21 +0,0 @@
import { Link } from 'react-router'
type BreadcrumbProperty = {
slug: string
}
export const Breadcrumb = (property: BreadcrumbProperty) => {
const { slug } = property
return (
<div className="mb-5 flex items-center gap-2">
<Link to={'#'}>Blog</Link>
<div>{'>'}</div>
<Link
to={'#'}
className="text-ellipsis md:text-clip ..."
>
{slug.slice(0, 20) + '...'}
</Link>
</div>
)
}
+24
View File
@@ -0,0 +1,24 @@
import { CarouselNextIcon } from '~/components/icons/carousel-next'
import { CarouselPreviousIcon } from '~/components/icons/carousel-previous'
type TCarouselButton = {
direction: 'prev' | 'next'
isEnabled: boolean
onClick: () => void
}
export const CarouselButton = ({
direction,
isEnabled,
onClick,
}: TCarouselButton) => {
const Icon = direction === 'prev' ? CarouselPreviousIcon : CarouselNextIcon
return (
<Icon
color={isEnabled ? '#2E2F7C' : '#DCDCDC'}
className={isEnabled ? 'cursor-pointer' : 'cursor-not-allowed'}
width={45}
height={45}
onClick={onClick}
/>
)
}
+33 -16
View File
@@ -1,3 +1,5 @@
import { Button as HeadlessButton } from '@headlessui/react'
import { ArrowPathIcon } from '@heroicons/react/24/solid'
import { cva, type VariantProps } from 'class-variance-authority'
import type { ReactNode, ElementType, ComponentPropsWithoutRef } from 'react'
import { twMerge } from 'tailwind-merge'
@@ -7,23 +9,30 @@ const buttonVariants = cva(
{
variants: {
variant: {
newsPrimary: 'bg-[#2E2F7C] text-white text-lg',
newsPrimaryOutline: 'border-[3px] border-white text-white text-lg',
newsSecondary: 'border-[3px] border-[#2E2F7C] text-[#2E2F7C] text-lg',
primary:
'bg-[#2E2F7C] text-white text-lg hover:bg-[#4C5CA0] hover:shadow transition active:bg-[#6970B4]',
danger:
'bg-[#EF4444] text-white text-lg hover:shadow transition active:bg-[#FEE2E2] hover:bg-[#FCA5A5]',
primaryOutline:
'border-[3px] bg-[#2E2F7C] border-white text-white text-lg hover:bg-[#4C5CA0] hover:shadow-lg active:shadow-2xl transition active:bg-[#6970B4]',
outline:
'border-[3px] bg-white hover:shadow-lg active:shadow-2xl border-[#2E2F7C] text-[#2E2F7C] hover:text-[#4C5CA0] active:text-[#6970B4] text-lg hover:border-[#4C5CA0] transition active:border-[#6970B4]',
icon: '',
link: '',
link: 'font-semibold text-[#2E2F7C] hover:text-[#4C5CA0] active:text-[#6970B4] transition',
secondary:
'hover:bg-[#707FDD]/10 active:bg-[#707FDD]/20 hover:text-[#707FDD] text-[#273240]',
},
size: {
default: 'h-[50px] w-[150px]',
block: 'h-[50px] w-full',
icon: 'h-9 w-9',
icon: 'size-9 rounded-full',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
fit: 'w-fit',
},
},
defaultVariants: {
variant: 'newsPrimary',
variant: 'primary',
size: 'default',
},
},
@@ -34,6 +43,8 @@ type ButtonBaseProperties = {
variant?: VariantProps<typeof buttonVariants>['variant']
size?: VariantProps<typeof buttonVariants>['size']
className?: string
isLoading?: boolean
icon?: ReactNode
}
type PolymorphicReference<C extends ElementType> =
@@ -44,22 +55,28 @@ type ButtonProperties<C extends ElementType> = ButtonBaseProperties & {
ref?: PolymorphicReference<C>
} & Omit<ComponentPropsWithoutRef<C>, keyof ButtonBaseProperties>
export const Button = <C extends ElementType = 'button'>({
as,
children,
variant,
size,
className,
...properties
}: ButtonProperties<C>) => {
const Component = as || 'button'
export const Button = <C extends ElementType = 'button'>(
properties: ButtonProperties<C>,
) => {
const {
as,
children,
variant,
size,
className,
isLoading = false,
icon,
...restProperties
} = properties
const Component = as || HeadlessButton
const classes = twMerge(buttonVariants({ variant, size, className }))
return (
<Component
className={classes}
{...properties}
{...restProperties}
>
{isLoading ? <ArrowPathIcon className="size-5 animate-spin" /> : icon}
{children}
</Component>
)
+9 -14
View File
@@ -1,39 +1,34 @@
import type { JSX } from 'react'
import type { ComponentType, SVGProps } from 'react'
import { formatNumberWithPeriods } from '~/utils/formatter'
type CardReportProperty = {
title: string
amount: number
icon: (
properties: React.JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>,
) => JSX.Element
currency?: string
icon: ComponentType<SVGProps<SVGSVGElement>>
url?: string
counter?: number[]
}
export const CardReport = (properties: CardReportProperty) => {
const { title, amount, icon: Icon, counter } = properties
const { title, amount, icon: Icon, currency } = properties
return (
<div className="rounded bg-white px-4 py-6 shadow-sm">
<div className="rounded-xl bg-white px-4 py-6 shadow-sm">
<div className="flex items-center">
<Icon
className="ml-2 rounded-xl bg-[#2E2F7C] p-2 text-white"
height={48}
width={48}
/>
<div className="ml-10">
<div className="ml-7">
<h2 className="text-lg font-semibold">{title}</h2>
<p className="text-2xl font-bold text-[#2E2F7C]">
Rp. {formatNumberWithPeriods(amount)}
{currency
? `${currency} ${formatNumberWithPeriods(amount)}`
: formatNumberWithPeriods(amount)}
</p>
</div>
</div>
{counter?.length && (
<div className="flex items-center">
Pribadi: {counter[0]} | Perusahaan: {counter[1]}
</div>
)}
</div>
)
}
+2 -2
View File
@@ -3,11 +3,11 @@ import type { ComponentProps, FC, PropsWithChildren } from 'react'
type TProperties = PropsWithChildren<ComponentProps<'div'>>
export const Card: FC<TProperties> = (properties) => {
const { children, ...rest } = properties
const { children, ...restProperties } = properties
return (
<div
className="border-[.2px] border-black/20 bg-white p-[30px]"
{...rest}
{...restProperties}
>
{children}
</div>
+113 -55
View File
@@ -1,25 +1,49 @@
import useEmblaCarousel from 'embla-carousel-react'
import { useCallback } from 'react'
import { Link } from 'react-router'
import { Suspense, useCallback, useEffect, useState } from 'react'
import { Await, useRouteLoaderData } from 'react-router'
import { stripHtml } from 'string-strip-html'
import { CarouselNextIcon } from '~/components/icons/carousel-next'
import { CarouselPreviousIcon } from '~/components/icons/carousel-previous'
import { Button } from '~/components/ui/button'
import { ErrorAwait } from '~/components/error/await'
import { ImageSkeletonIcon } from '~/components/icons/image-skeleton'
import { CarouselButton } from '~/components/ui/button-slide'
import { useNewsContext } from '~/contexts/news'
import type { loader } from '~/routes/_news'
import type { TNews } from '~/types/news'
import { getPremiumAttribute } from '~/utils/render'
import { Button } from './button'
export const CarouselHero = (properties: TNews) => {
const { setIsSuccessOpen } = useNewsContext()
const loaderData = useRouteLoaderData<typeof loader>('routes/_news')
const { userData } = loaderData || {}
const { title, description, items } = properties
const [emblaReference, emblaApi] = useEmblaCarousel({ loop: true })
const [emblaReference, emblaApi] = useEmblaCarousel({ loop: false })
const [canScrollNext, setCanScrollNext] = useState(false)
const [canScrollPrevious, setCanScrollPrevious] = useState(false)
const updateButtons = useCallback(() => {
if (emblaApi) {
setCanScrollPrevious(emblaApi.canScrollPrev())
setCanScrollNext(emblaApi.canScrollNext())
}
}, [emblaApi])
useEffect(() => {
if (emblaApi) {
updateButtons()
emblaApi.on('select', updateButtons)
}
}, [emblaApi, updateButtons])
const previousSlide = useCallback(() => {
if (emblaApi) emblaApi.scrollPrev()
}, [emblaApi])
if (canScrollPrevious && emblaApi) emblaApi.scrollPrev()
}, [emblaApi, canScrollPrevious])
const nextSlide = useCallback(() => {
if (emblaApi) emblaApi.scrollNext()
}, [emblaApi])
if (canScrollNext && emblaApi) emblaApi.scrollNext()
}, [emblaApi, canScrollNext])
return (
<div className="">
@@ -33,18 +57,14 @@ export const CarouselHero = (properties: TNews) => {
</p>
</div>
<div className="flex gap-2.5">
<CarouselPreviousIcon
color="#DCDCDC"
className="cursor-pointer"
width={45}
height={45}
<CarouselButton
direction="prev"
isEnabled={canScrollPrevious}
onClick={previousSlide}
/>
<CarouselNextIcon
color="#2E2F7C"
className="cursor-pointer"
width={45}
height={45}
<CarouselButton
direction="next"
isEnabled={canScrollNext}
onClick={nextSlide}
/>
</div>
@@ -55,43 +75,81 @@ export const CarouselHero = (properties: TNews) => {
ref={emblaReference}
>
<div className="embla__container hero flex sm:gap-x-8">
{items.map(({ featured, title, content, slug, isPremium }, index) => (
<div
className="embla__slide hero w-full min-w-0 flex-none"
key={index}
>
<div className="max-sm:mt-2 sm:flex">
<img
className="col-span-2 aspect-[174/100] object-cover"
src={featured}
alt={title}
/>
<div className="flex h-full flex-col justify-between gap-7 sm:px-5">
<div>
<h3 className="mt-2 w-full text-2xl font-bold sm:mt-0 sm:text-4xl">
{title}
</h3>
<p className="text-md mt-5 text-[#777777] sm:text-xl">
{content}
</p>
</div>
<Button
size="block"
{...(isPremium
? {
onClick: () => {
setIsSuccessOpen('warning')
},
to: '',
}
: { as: Link, to: `/detail/${slug}` })}
>
View More
</Button>
<Suspense
fallback={
<div className="embla__slide hero flex w-full min-w-0 flex-none animate-pulse justify-between gap-3 max-sm:mt-2 sm:flex">
<div className="flex aspect-[174/100] h-full items-center justify-center rounded-md bg-gray-300 dark:bg-gray-700">
<ImageSkeletonIcon />
</div>
<div className="flex w-full flex-col gap-7">
<div className="flex flex-col gap-4">
<div className="h-6 w-full rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div className="h-6 w-[20%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
</div>
<div className="flex flex-col gap-2.5">
<div className="h-5 rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div className="h-5 max-w-[90%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div className="h-5 rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div className="h-5 max-w-[90%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div className="h-5 rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div className="h-5 max-w-[90%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div className="h-5 rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div className="h-5 max-w-[90%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div className="h-5 rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div className="h-5 max-w-[50%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
</div>
<div className="h-[50px] w-full bg-gray-200 dark:bg-gray-700" />
</div>
<span className="sr-only">Loading...</span>
</div>
</div>
))}
}
>
<Await
resolve={items}
errorElement={<ErrorAwait />}
>
{(value) =>
value.data.map(
(
{ featured_image, title, content, slug, is_premium },
index,
) => (
<div
className="embla__slide hero w-full min-w-0 flex-none max-sm:mt-2 sm:flex"
key={index}
>
<img
className="aspect-[174/100] h-full rounded-md object-cover"
src={featured_image}
alt={title}
/>
<div className="flex h-full flex-col justify-between gap-7 sm:px-5">
<div>
<h3 className="mt-2 w-full text-2xl font-bold sm:mt-0 sm:text-4xl">
{title}
</h3>
<p className="text-md mt-5 line-clamp-10 text-[#777777] sm:text-xl">
{stripHtml(content).result}
</p>
</div>
<Button
size="block"
{...getPremiumAttribute({
isPremium: is_premium,
slug,
onClick: () => setIsSuccessOpen('warning'),
userData,
})}
>
View More
</Button>
</div>
</div>
),
)
}
</Await>
</Suspense>
</div>
</div>
</div>
+116 -71
View File
@@ -1,25 +1,55 @@
import useEmblaCarousel from 'embla-carousel-react'
import { useCallback } from 'react'
import { Link } from 'react-router'
import { Suspense, useCallback, useEffect, useState } from 'react'
import { Await, useRouteLoaderData } from 'react-router'
import { stripHtml } from 'string-strip-html'
import { CarouselNextIcon } from '~/components/icons/carousel-next'
import { CarouselPreviousIcon } from '~/components/icons/carousel-previous'
import { Button } from '~/components/ui/button'
import { ErrorAwait } from '~/components/error/await'
import { ImageSkeletonIcon } from '~/components/icons/image-skeleton'
import { CarouselButton } from '~/components/ui/button-slide'
import { useNewsContext } from '~/contexts/news'
import type { loader } from '~/routes/_news'
import type { TNews } from '~/types/news'
import { getPremiumAttribute } from '~/utils/render'
import { Button } from './button'
import { Tags } from './tags'
export const CarouselSection = (properties: TNews) => {
const { setIsSuccessOpen } = useNewsContext()
const loaderData = useRouteLoaderData<typeof loader>('routes/_news')
const { userData } = loaderData || {}
const { title, description, items } = properties
const [emblaReference, emblaApi] = useEmblaCarousel({ loop: false })
const [emblaReference, emblaApi] = useEmblaCarousel({
loop: false,
slidesToScroll: 1,
align: 'start',
})
const [canScrollNext, setCanScrollNext] = useState(false)
const [canScrollPrevious, setCanScrollPrevious] = useState(false)
const updateButtons = useCallback(() => {
if (emblaApi) {
setCanScrollPrevious(emblaApi.canScrollPrev())
setCanScrollNext(emblaApi.canScrollNext())
}
}, [emblaApi])
useEffect(() => {
if (emblaApi) {
updateButtons()
emblaApi.on('select', updateButtons)
}
}, [emblaApi, updateButtons])
const previousSlide = useCallback(() => {
if (emblaApi) emblaApi.scrollPrev()
}, [emblaApi])
if (canScrollPrevious && emblaApi) emblaApi.scrollPrev()
}, [emblaApi, canScrollPrevious])
const nextSlide = useCallback(() => {
if (emblaApi) emblaApi.scrollNext()
}, [emblaApi])
if (canScrollNext && emblaApi) emblaApi.scrollNext()
}, [emblaApi, canScrollNext])
return (
<div className="">
<div className="mt-3 mb-3 flex items-center justify-between border-b border-black pb-3 sm:mb-[30px] sm:pb-[30px]">
@@ -33,18 +63,14 @@ export const CarouselSection = (properties: TNews) => {
</div>
<div className="flex gap-2.5">
<CarouselPreviousIcon
color="#DCDCDC"
className="cursor-pointer"
width={45}
height={45}
<CarouselButton
direction="prev"
isEnabled={canScrollPrevious}
onClick={previousSlide}
/>
<CarouselNextIcon
color="#2E2F7C"
className="cursor-pointer"
width={45}
height={45}
<CarouselButton
direction="next"
isEnabled={canScrollNext}
onClick={nextSlide}
/>
</div>
@@ -54,63 +80,82 @@ export const CarouselSection = (properties: TNews) => {
className="embla overflow-hidden"
ref={emblaReference}
>
<div className="embla__container col-span-3 flex max-h-[586px]">
{items.map(
({ featured, title, content, tags, slug, isPremium }, index) => (
<div className="embla__container col-span-3 flex max-h-[586px] sm:gap-x-8">
<Suspense
fallback={Array.from({ length: 3 }).map((_, index) => (
<div
className="embla__slide w-full min-w-0 flex-none sm:w-1/3"
key={index}
className="embla__slide flex w-full min-w-0 flex-none animate-pulse flex-col justify-between gap-3 sm:w-1/3"
>
<div className="flex flex-col justify-between max-sm:mt-2">
<img
className="aspect-[5/4] max-h-[300px] w-full rounded-md object-cover"
src={featured}
alt={title}
/>
<div className={'flex flex-col justify-between gap-4'}>
<div className={'flex uppercase'}>
{tags?.map((item) => (
<span
key={index}
className="my-3 mr-2 inline-block rounded bg-[#F4F4F4] px-3 py-1 font-bold text-[#777777]"
>
{item}
</span>
))}
{isPremium && (
<span className="my-3 mr-2 inline-block rounded bg-[#D1C675] px-3 py-1 font-bold text-[#9D761D]">
Premium Content
</span>
)}
</div>
<div>
<h3 className="mt-2 w-full text-xl font-bold sm:text-2xl lg:mt-0">
{title}
</h3>
<p className="text-md mt-5 text-[#777777] sm:text-xl">
{content}
</p>
</div>
<Button
size="block"
{...(isPremium
? {
onClick: () => {
setIsSuccessOpen('warning')
},
to: '',
}
: { as: Link, to: `/news/detail/${slug}` })}
className="mb-5"
>
View More
</Button>
<div className="flex h-[280px] w-full items-center justify-center rounded-md bg-gray-300 dark:bg-gray-700">
<ImageSkeletonIcon />
</div>
<div className="flex w-full flex-col gap-4">
<div className="flex flex-col gap-4">
<div className="h-6 w-full rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div className="h-6 w-[20%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
</div>
<div className="flex flex-col gap-2.5">
<div className="h-5 max-w-[80%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div className="h-5 rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div className="h-5 max-w-[50%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
</div>
</div>
<div className="h-[50px] w-full bg-gray-200 dark:bg-gray-700" />
<span className="sr-only">Loading...</span>
</div>
),
)}
))}
>
<Await
resolve={items}
errorElement={<ErrorAwait />}
>
{(value) =>
value.data.map(
(
{ featured_image, title, content, tags, slug, is_premium },
index,
) => (
<div
className="embla__slide flex w-full min-w-0 flex-none flex-col justify-between gap-3 sm:w-1/3"
key={index}
>
<img
className="aspect-[174/100] max-h-[280px] w-full rounded-md object-cover sm:aspect-[5/4]"
src={featured_image}
alt={title}
/>
<div className={'flex flex-col justify-between gap-4'}>
<div className="flex h-28 flex-col items-start justify-center gap-4">
<Tags
tags={tags || []}
is_premium={is_premium}
/>
<h3 className="mt-2 line-clamp-2 w-full text-xl font-bold sm:text-2xl lg:mt-0">
{title}
</h3>
</div>
<p className="line-clamp-3 text-base text-[#777777] sm:text-xl">
{stripHtml(content).result}
</p>
<Button
size="block"
{...getPremiumAttribute({
isPremium: is_premium,
slug,
onClick: () => setIsSuccessOpen('warning'),
userData,
})}
>
View More
</Button>
</div>
</div>
),
)
}
</Await>
</Suspense>
</div>
</div>
</div>
-171
View File
@@ -1,171 +0,0 @@
import { useState } from 'react'
import { Link, useLocation } from 'react-router'
import { twMerge } from 'tailwind-merge'
import { CarouselNextIcon } from '~/components/icons/carousel-next'
import { CarouselPreviousIcon } from '~/components/icons/carousel-previous'
import { useNewsContext } from '~/contexts/news'
import type { TNews } from '~/types/news'
import { Button } from './button'
export const Carousel = (properties: TNews) => {
const { setIsSuccessOpen } = useNewsContext()
const [currentIndex, setCurrentIndex] = useState(0)
const { pathname } = useLocation()
const hasCategory = pathname.includes('/category/')
const { title, description, items, type } = properties
const itemsPerPage = type === 'hero' ? 1 : 3
const totalPages = Math.ceil(items.length / itemsPerPage)
const nextSlide = () => {
setCurrentIndex((previousIndex) => (previousIndex + 1) % totalPages)
}
const previousSlide = () => {
setCurrentIndex(
(previousIndex) => (previousIndex - 1 + totalPages) % totalPages,
)
}
return (
<div className="">
<div className="mt-3 mb-3 flex items-center justify-between border-b border-black pb-3 sm:mb-[30px] sm:pb-[30px]">
<div className="grid">
<h2 className="text-2xl font-extrabold text-[#2E2F7C] sm:text-4xl">
{title}
</h2>
<p className="text-xl font-light text-[#777777] italic sm:text-2xl">
{description}
</p>
</div>
{!hasCategory && (
<div className="flex gap-2.5">
<CarouselPreviousIcon
color="#DCDCDC"
className="cursor-pointer"
width={45}
height={45}
onClick={previousSlide}
/>
<CarouselNextIcon
color="#2E2F7C"
className="cursor-pointer"
width={45}
height={45}
onClick={nextSlide}
/>
</div>
)}
</div>
<div
className={twMerge(
'grid sm:gap-x-8',
type === 'hero' ? 'grid-cols-1' : 'sm:grid-cols-3',
)}
>
{items
.slice(currentIndex * itemsPerPage, (currentIndex + 1) * itemsPerPage)
.map(({ featured, title, content, tags, slug, isPremium }, index) => (
<div
key={index}
className={twMerge(
'grid sm:gap-x-8',
type === 'hero' ? 'grid-cols-1 sm:grid-cols-3' : '',
)}
>
<img
className={twMerge(
'w-full object-cover',
type === 'hero'
? 'col-span-2 aspect-[174/100]'
: 'aspect-[5/4] rounded-md',
)}
src={featured}
alt={title}
/>
<div
className={twMerge(
'flex flex-col justify-between',
type === 'hero' ? 'gap-7' : 'gap-4',
)}
>
<div
className={twMerge(
'uppercase',
type === 'hero' ? 'hidden' : '',
)}
>
{tags?.map((item) => (
<span
key={`${index}-${item}`}
className="my-3 mr-2 inline-block rounded bg-[#F4F4F4] px-3 py-1 font-bold text-[#777777]"
>
{item}
</span>
))}
{isPremium && (
<span className="my-3 mr-2 inline-block rounded bg-[#D1C675] px-3 py-1 font-bold text-[#9D761D]">
Premium Content
</span>
)}
</div>
<div>
<h3
className={twMerge(
'mt-2 w-full font-bold sm:mt-0',
type === 'hero'
? 'text-2xl sm:text-4xl'
: 'text-xl sm:text-2xl',
)}
>
{title}
</h3>
<p className="text-md mt-5 text-[#777777] sm:text-xl">
{content}
</p>
</div>
<Button
size="block"
{...(isPremium
? {
onClick: () => {
setIsSuccessOpen('warning')
},
to: '',
}
: { as: Link, to: `/detail/${slug}` })}
className={twMerge('', type === 'hero' ? '' : 'mb-5')}
>
View More
</Button>
</div>
</div>
))}
</div>
{hasCategory && (
<div className="my-5 mt-5 flex flex-row-reverse">
<div className="flex gap-2.5">
<CarouselPreviousIcon
color="#DCDCDC"
className="cursor-pointer"
width={45}
height={45}
onClick={previousSlide}
/>
<CarouselNextIcon
color="#2E2F7C"
className="cursor-pointer"
width={45}
height={45}
onClick={nextSlide}
/>
</div>
</div>
)}
</div>
)
}
+150
View File
@@ -0,0 +1,150 @@
import { Suspense } from 'react'
import { Await, useRouteLoaderData } from 'react-router'
import { stripHtml } from 'string-strip-html'
import { twMerge } from 'tailwind-merge'
import { ErrorAwait } from '~/components/error/await'
import { CarouselNextIcon } from '~/components/icons/carousel-next'
import { CarouselPreviousIcon } from '~/components/icons/carousel-previous'
import { ImageSkeletonIcon } from '~/components/icons/image-skeleton'
import { Button } from '~/components/ui/button'
import { useNewsContext } from '~/contexts/news'
import type { loader } from '~/routes/_news'
import type { TNews } from '~/types/news'
import { getPremiumAttribute } from '~/utils/render'
import { Tags } from './tags'
export const CategorySection = (properties: TNews) => {
const { setIsSuccessOpen } = useNewsContext()
const loaderData = useRouteLoaderData<typeof loader>('routes/_news')
const { userData } = loaderData || {}
const { title, description, items } = properties
const nextSlide = () => {
// patch data next page
}
const previousSlide = () => {
// patch previous page
}
return (
<div className="">
<div className="mt-3 mb-3 flex items-center justify-between border-b border-black pb-3 sm:mb-[30px] sm:pb-[30px]">
<div className="grid">
<h2 className="text-2xl font-extrabold text-[#2E2F7C] sm:text-4xl">
{title}
</h2>
<p className="text-xl font-light text-[#777777] italic sm:text-2xl">
{description}
</p>
</div>
</div>
<div className="grid sm:grid-cols-3 sm:gap-x-8">
<Suspense
fallback={Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="grid gap-3 sm:gap-x-8"
>
<div className="flex h-[280px] w-full items-center justify-center rounded-md bg-gray-300 dark:bg-gray-700">
<ImageSkeletonIcon />
</div>
<div className="flex w-full flex-col gap-4">
<div className="flex flex-col gap-4">
<div className="h-6 w-full rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div className="h-6 w-[20%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
</div>
<div className="flex flex-col gap-2.5">
<div className="h-5 max-w-[80%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div className="h-5 rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div className="h-5 max-w-[50%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
</div>
</div>
<div className="h-[50px] w-full bg-gray-200 dark:bg-gray-700" />
<span className="sr-only">Loading...</span>
</div>
))}
>
<Await
resolve={items}
errorElement={<ErrorAwait />}
>
{(value) =>
value.data.map(
(
{ featured_image, title, content, tags, slug, is_premium },
index,
) => (
<div
key={index}
className="grid gap-3 sm:gap-x-8"
>
<img
className="aspect-[174/100] w-full rounded-md object-cover sm:aspect-[5/4]"
src={featured_image}
alt={title}
/>
<div className="flex flex-col justify-between gap-4">
<Tags
tags={tags}
is_premium={is_premium}
/>
<div>
<h3
className={twMerge(
'mt-2 w-full text-xl font-bold sm:mt-0 sm:text-2xl',
)}
>
{title}
</h3>
<p className="text-md mt-5 line-clamp-3 text-[#777777] sm:text-xl">
{stripHtml(content).result}
</p>
</div>
<Button
size="block"
{...getPremiumAttribute({
isPremium: is_premium,
slug,
onClick: () => setIsSuccessOpen('warning'),
userData,
})}
className="mb-5"
>
View More
</Button>
</div>
</div>
),
)
}
</Await>
</Suspense>
</div>
<div className="my-5 mt-5 flex flex-row-reverse">
<div className="flex gap-2.5">
<CarouselPreviousIcon
color="#DCDCDC"
className="cursor-pointer"
width={45}
height={45}
onClick={previousSlide}
/>
<CarouselNextIcon
color="#2E2F7C"
className="cursor-pointer"
width={45}
height={45}
onClick={nextSlide}
/>
</div>
</div>
</div>
)
}
+23
View File
@@ -0,0 +1,23 @@
export type TColorBadge =
| 'baru'
| 'premium'
| 'pembayaran'
| 'active'
| 'inactive'
| 'expired'
| 1
| 0
export const getStatusBadge = (status: TColorBadge) => {
const statusColors = {
baru: 'bg-[#DFE5FF] text-[#4C5CA0]',
premium: 'bg-[#FFFCAF] text-[#DBCA6E]',
pembayaran: 'bg-[#FEC4FF] text-[#CC6EDB]',
active: 'bg-[#D1FAE5] text-[#10B981]',
inactive: 'bg-[#FEE2E2] text-[#EF4444]',
expired: 'bg-[#FEC4FF] text-[#CC6EDB]',
1: 'bg-[#DFE5FF] text-[#4C5CA0]',
0: 'bg-[#FEE2E2] text-[#EF4444]',
}
return statusColors[status] || 'bg-gray-200 text-gray-700'
}
+131
View File
@@ -0,0 +1,131 @@
import {
Field,
Label,
Combobox as HeadlessCombobox,
ComboboxInput,
ComboboxButton,
ComboboxOptions,
ComboboxOption,
} from '@headlessui/react'
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/solid'
import { useState, type ComponentProps, type ReactNode } from 'react'
import {
get,
type FieldError,
type FieldValues,
type Path,
type RegisterOptions,
Controller,
} from 'react-hook-form'
import { useRemixFormContext } from 'remix-hook-form'
import { twMerge } from 'tailwind-merge'
type TComboboxOption = {
code: string
name: string
id: string
}
type TComboboxProperties<T extends FieldValues> = ComponentProps<
typeof HeadlessCombobox
> & {
id: string
label?: ReactNode
name: Path<T>
rules?: RegisterOptions
placeholder?: string
options?: TComboboxOption[]
labelClassName?: string
containerClassName?: string
}
export const Combobox = <TFormValues extends Record<string, unknown>>(
properties: TComboboxProperties<TFormValues>,
) => {
const {
id,
label,
name,
rules,
disabled,
placeholder,
options,
className,
labelClassName,
containerClassName,
...restProperties
} = properties
const {
control,
formState: { errors },
} = useRemixFormContext()
const [query, setQuery] = useState('')
const filteredOptions =
query === ''
? options
: options?.filter((option) =>
option.name.toLowerCase().includes(query.toLowerCase()),
)
const error: FieldError = get(errors, name)
return (
<Field
className={twMerge('relative', containerClassName)}
disabled={disabled}
id={id}
>
<Label className={twMerge('mb-1 block text-gray-700', labelClassName)}>
{label} {error && <span className="text-red-500">{error.message}</span>}
</Label>
<Controller
name={name}
control={control}
rules={rules}
render={({ field }) => (
<HeadlessCombobox
value={field.value}
onChange={field.onChange}
disabled={disabled}
immediate
{...restProperties}
>
<div className="relative">
<ComboboxInput
placeholder={placeholder}
displayValue={(option: TComboboxOption) => option?.name}
onChange={(event) => setQuery(event.target.value)}
className={twMerge(
'focus:inheriten h-[42px] w-full rounded-md border border-[#DFDFDF] p-2 placeholder:text-inherit',
className,
)}
/>
<ComboboxButton className="group absolute inset-y-0 right-0 px-2.5">
<ChevronDownIcon className="size-4 fill-gray-500" />
</ComboboxButton>
</div>
<ComboboxOptions
anchor={{ to: 'bottom', gap: '8px' }}
transition
className={twMerge(
'w-[var(--input-width)] rounded-md border border-[#DFDFDF] p-1 empty:invisible',
'bg-white transition duration-100 ease-in data-[leave]:data-[closed]:opacity-0',
)}
>
{filteredOptions?.map((option) => (
<ComboboxOption
key={option.id}
value={option}
className="group flex cursor-default items-center gap-2 rounded-lg px-3 py-1.5 select-none data-[focus]:bg-white/10"
>
<CheckIcon className="invisible size-4 group-data-[selected]:visible" />
<div className="text-sm/6">{option.name}</div>
</ComboboxOption>
))}
</ComboboxOptions>
</HeadlessCombobox>
)}
/>
</Field>
)
}
+87
View File
@@ -0,0 +1,87 @@
import { Field, Label, Input as HeadlessInput } from '@headlessui/react'
import { CloudArrowUpIcon } from '@heroicons/react/24/solid'
import { useEffect, type ComponentProps, type ReactNode } from 'react'
import { get, type FieldError, type RegisterOptions } from 'react-hook-form'
import { useRemixFormContext } from 'remix-hook-form'
import { twMerge } from 'tailwind-merge'
import { useAdminContext, type TUpload } from '~/contexts/admin'
import { Button } from './button'
type TInputProperties = Omit<ComponentProps<'input'>, 'size'> & {
id: string
label?: ReactNode
name: string
rules?: RegisterOptions
containerClassName?: string
labelClassName?: string
category: TUpload
}
export const InputFile = (properties: TInputProperties) => {
const {
id,
label,
name,
rules,
placeholder,
disabled,
className,
containerClassName,
labelClassName,
category,
...restProperties
} = properties
const { setIsUploadOpen, uploadedFile, setUploadedFile, isUploadOpen } =
useAdminContext()
const {
register,
formState: { errors },
setValue,
} = useRemixFormContext()
const error: FieldError = get(errors, name)
useEffect(() => {
if (uploadedFile && isUploadOpen === (category || name)) {
setValue(name, uploadedFile)
setUploadedFile(undefined)
setIsUploadOpen(undefined)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uploadedFile])
return (
<Field
className={twMerge('relative', containerClassName)}
disabled={disabled}
id={id}
>
<Label className={twMerge('mb-1 block text-gray-700', labelClassName)}>
{label} {error && <span className="text-red-500">{error.message}</span>}
</Label>
<HeadlessInput
className={twMerge(
'h-[42px] w-full rounded-md border border-[#DFDFDF] p-2 pr-8',
className,
)}
placeholder={placeholder}
{...register(name, rules)}
{...restProperties}
/>
<Button
type="button"
variant="icon"
size="fit"
className="absolute right-3 h-[42px]"
onClick={() => {
setIsUploadOpen(category)
}}
>
<CloudArrowUpIcon className="size-4 text-gray-500/50" />
</Button>
</Field>
)
}
+25 -14
View File
@@ -1,3 +1,4 @@
import { Field, Label, Input as HeadlessInput } from '@headlessui/react'
import { useState, type ComponentProps, type ReactNode } from 'react'
import {
get,
@@ -21,6 +22,8 @@ type TInputProperties<T extends FieldValues> = Omit<
label?: ReactNode
name: Path<T>
rules?: RegisterOptions
containerClassName?: string
labelClassName?: string
}
export const Input = <TFormValues extends Record<string, unknown>>(
@@ -33,7 +36,11 @@ export const Input = <TFormValues extends Record<string, unknown>>(
rules,
type = 'text',
placeholder,
...rest
disabled,
className,
containerClassName,
labelClassName,
...restProperties
} = properties
const [inputType, setInputType] = useState(type)
@@ -45,39 +52,43 @@ export const Input = <TFormValues extends Record<string, unknown>>(
const error: FieldError = get(errors, name)
return (
<div className="relative">
<label
htmlFor={id}
className="mb-1 block text-gray-700"
>
<Field
className={twMerge('relative', containerClassName)}
disabled={disabled}
id={id}
>
<Label className={twMerge('mb-1 block text-gray-700', labelClassName)}>
{label} {error && <span className="text-red-500">{error.message}</span>}
</label>
<input
id={id}
</Label>
<HeadlessInput
type={inputType}
className="h-[42px] w-full rounded-md border border-[#DFDFDF] p-2"
className={twMerge(
'h-[42px] w-full rounded-md border border-[#DFDFDF] p-2',
className,
type === 'password' ? 'pr-8' : '',
)}
placeholder={inputType === 'password' ? '******' : placeholder}
{...register(name, rules)}
{...rest}
{...restProperties}
/>
{type === 'password' && (
<Button
type="button"
variant="icon"
size="fit"
className="absolute right-3 h-[42px] text-gray-500"
className="absolute right-3 h-[42px]"
onClick={() =>
setInputType(inputType === 'password' ? 'text' : 'password')
}
>
<EyeIcon
className={twMerge(
'h-4 w-4',
'size-4',
inputType === 'password' ? 'text-gray-500/50' : 'text-gray-500',
)}
/>
</Button>
)}
</div>
</Field>
)
}
+32
View File
@@ -0,0 +1,32 @@
import type { TAuthorResponse } from '~/apis/common/get-news'
import { formatDate } from '~/utils/formatter'
type TDetailNewsAuthor = {
author?: TAuthorResponse
live_at?: string
text?: string
}
export const NewsAuthor = ({ author, live_at, text }: TDetailNewsAuthor) => {
return (
<div className="mb-2 flex items-center gap-2 align-middle">
<img
src={author?.profile_picture || '/images/profile-placeholder.svg'}
onError={(event) => {
event.currentTarget.src = '/images/profile-placeholder.svg'
}}
alt={author?.name}
className="size-12 rounded-full bg-[#C4C4C4] object-cover"
/>
<div>
<h4 className="text-md">{author?.name}</h4>
<p className="flex gap-1 text-sm">
<span>{live_at && `${formatDate(live_at)}`}</span>
<span>·</span>
<span>{text}</span>
</p>
</div>
</div>
)
}
+1 -1
View File
@@ -45,7 +45,7 @@ export const Newsletter = (property: NewsletterProperties) => {
/>
<Button
type="submit"
variant="newsPrimary"
variant="primary"
size="block"
>
Subscribe
-66
View File
@@ -1,66 +0,0 @@
import React from 'react'
import { ChevronIcon } from '~/components/icons/chevron'
import { ChevronDoubleIcon } from '~/components/icons/chevron-double'
type PaginationProperties = {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
}
export const Pagination: React.FC<PaginationProperties> = ({
currentPage = 1,
totalPages,
onPageChange,
}) => {
const renderPageNumbers = () => {
const pages = []
for (let index = 1; index <= totalPages; index++) {
pages.push(
<button
key={index}
onClick={() => onPageChange(index)}
className={`rounded-lg px-3 py-1 ${
currentPage === index ? 'bg-[#2E2F7C] text-white' : 'text-gray-500'
}`}
>
{index}
</button>,
)
}
return pages
}
return (
<div className="flex items-center justify-center space-x-2 text-[#2E2F7C]">
<button
onClick={() => onPageChange(1)}
disabled={currentPage === 1}
>
<ChevronDoubleIcon />
</button>
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronIcon className="rotate-90" />
</button>
{renderPageNumbers()}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
<ChevronIcon className="rotate-270" />
</button>
<button
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}
>
<ChevronDoubleIcon className="rotate-180" />
</button>
</div>
)
}
+98
View File
@@ -0,0 +1,98 @@
import { Field, Label, Select as HeadlessSelect } from '@headlessui/react'
import { type ComponentProps, type ReactNode } from 'react'
import {
get,
type FieldError,
type FieldValues,
type Path,
type RegisterOptions,
Controller,
} from 'react-hook-form'
import { useRemixFormContext } from 'remix-hook-form'
import { twMerge } from 'tailwind-merge'
type TSelectProperties<T extends FieldValues> = Omit<
ComponentProps<'select'>,
'size'
> & {
id: string
label?: ReactNode
name: Path<T>
rules?: RegisterOptions
containerClassName?: string
labelClassName?: string
placeholder?: string
options?: {
name: string
value: string | number
}[]
}
export const Select = <TFormValues extends Record<string, unknown>>(
properties: TSelectProperties<TFormValues>,
) => {
const {
id,
label,
name,
rules,
disabled,
placeholder,
options,
className,
labelClassName,
containerClassName,
...restProperties
} = properties
const {
control,
formState: { errors },
} = useRemixFormContext()
const error: FieldError = get(errors, name)
return (
<Field
className={twMerge('relative', containerClassName)}
disabled={disabled}
id={id}
>
<Label className={twMerge('mb-1 block text-gray-700', labelClassName)}>
{label} {error && <span className="text-red-500">{error.message}</span>}
</Label>
<Controller
name={name}
control={control}
rules={rules}
render={({ field }) => (
<HeadlessSelect
value={field.value}
onChange={field.onChange}
disabled={disabled}
className={twMerge(
'h-[42px] w-full rounded-md border border-[#DFDFDF] p-2',
className,
)}
{...restProperties}
>
<option
value=""
disabled
selected={!field.value}
>
{placeholder}
</option>
{options?.map(({ value, name }) => (
<option
key={value}
value={value}
>
{name}
</option>
))}
</HeadlessSelect>
)}
/>
</Field>
)
}
+66 -45
View File
@@ -1,58 +1,79 @@
import type { FC } from 'react'
import { Link } from 'react-router'
import { twMerge } from 'tailwind-merge'
import { LinkIcon } from '@heroicons/react/24/solid'
import { useState } from 'react'
import {
FacebookShareButton,
LinkedinShareButton,
TwitterShareButton,
} from 'react-share'
import { FacebookIcon } from '~/components/icons/facebook'
import { InstagramIcon } from '~/components/icons/instagram'
import { LinkIcon } from '~/components/icons/link-icon'
import { LinkedinIcon } from '~/components/icons/linkedin'
import { XIcon } from '~/components/icons/x'
type SocialMediaProperties = {
className?: string
type SocialShareButtonsProperties = {
url: string
title: string
}
const dataSocialMedia = [
{
type: 'link',
url: 'post-id/',
icon: LinkIcon,
},
{
type: 'facebook',
url: 'https://facebook.com/',
icon: FacebookIcon,
},
{
type: 'linkedin',
url: 'https://linkedin.com/',
icon: LinkedinIcon,
},
{
type: 'x',
url: 'https://x.com/',
icon: XIcon,
},
{
type: 'instagram',
url: 'https://instagram.com/',
icon: InstagramIcon,
},
]
export const SocialShareButtons = ({
url,
title,
}: SocialShareButtonsProperties) => {
const [showPopup, setShowPopup] = useState(false)
const handleCopyLink = () => {
navigator.clipboard.writeText(url)
setShowPopup(true)
setTimeout(() => setShowPopup(false), 2000)
}
const handleInstagramShare = () => {
const instagramUrl = `https://www.instagram.com/direct/new/?text=${encodeURIComponent(title + ' ' + url)}`
window.open(instagramUrl, '_blank')
}
export const IconsSocial: FC<SocialMediaProperties> = ({ className }) => {
return (
<div className={twMerge('flex gap-2', className)}>
{dataSocialMedia.map(({ url, icon: Icon }, index) => (
<Link
key={index}
to={url}
target="_blank"
rel="noopener noreferrer"
>
<Icon className="h-8 w-8 rounded-full bg-gray-400 p-2 sm:h-10 sm:w-10" />
</Link>
))}
<div className="flex items-center space-x-2">
<button
onClick={handleCopyLink}
className="relative cursor-pointer"
>
<LinkIcon className="size-8 rounded-full bg-[#F4F4F4] p-2 transition hover:bg-gray-200 hover:shadow active:bg-gray-300 sm:h-10 sm:w-10" />
{showPopup && (
<div className="absolute top-12 w-48 rounded-lg border-2 border-gray-400 bg-white p-2 shadow-lg">
Link berhasil disalin!
</div>
)}
</button>
<FacebookShareButton
url={url}
title={title}
>
<FacebookIcon className="size-8 rounded-full bg-[#F4F4F4] p-2 transition hover:bg-gray-200 hover:shadow active:bg-gray-300 sm:h-10 sm:w-10" />
</FacebookShareButton>
<LinkedinShareButton
url={url}
title={title}
>
<LinkedinIcon className="size-8 rounded-full bg-[#F4F4F4] p-2 transition hover:bg-gray-200 hover:shadow active:bg-gray-300 sm:h-10 sm:w-10" />
</LinkedinShareButton>
<TwitterShareButton
url={url}
title={title}
>
<XIcon className="size-8 rounded-full bg-[#F4F4F4] p-2 transition hover:bg-gray-200 hover:shadow active:bg-gray-300 sm:h-10 sm:w-10" />
</TwitterShareButton>
<button
onClick={handleInstagramShare}
className="cursor-pointer"
>
<InstagramIcon className="size-8 rounded-full bg-[#F4F4F4] p-2 transition hover:bg-gray-200 hover:shadow active:bg-gray-300 sm:h-10 sm:w-10" />
</button>
</div>
)
}
+94
View File
@@ -0,0 +1,94 @@
import { Field, Label, Switch as HeadlessSwitch } from '@headlessui/react'
import { type ReactNode } from 'react'
import {
Controller,
get,
type FieldError,
type FieldValues,
type Path,
type RegisterOptions,
} from 'react-hook-form'
import { useRemixFormContext } from 'remix-hook-form'
import { twMerge } from 'tailwind-merge'
type TSwitchProperties<T extends FieldValues> = {
id: string
label?: ReactNode
name: Path<T>
rules?: RegisterOptions
containerClassName?: string
labelClassName?: string
className?: string
inputClassName?: string
options?: {
true: string
false: string
}
}
export const Switch = <TFormValues extends Record<string, unknown>>(
properties: TSwitchProperties<TFormValues>,
) => {
const {
id,
label,
name,
rules,
containerClassName,
labelClassName,
className,
inputClassName,
options,
} = properties
const {
control,
formState: { errors },
} = useRemixFormContext()
const error: FieldError = get(errors, name)
return (
<Field
className={twMerge('relative', containerClassName)}
id={id}
>
<Label className={twMerge('mb-1 block text-gray-700', labelClassName)}>
{label} {error && <span className="text-red-500">{error.message}</span>}
</Label>
<Controller
name={name}
control={control}
rules={rules}
render={({ field }) => (
<div className={twMerge('flex items-center', className)}>
<HeadlessSwitch
checked={field.value}
onChange={(checked) => {
field.onChange(checked)
}}
className={twMerge(
'group flex h-7 cursor-pointer items-center rounded-full bg-[#2E2F7C]/10 p-1 shadow transition-colors duration-200 ease-in-out focus:outline-none data-[checked]:bg-[#2E2F7C]/90 data-[focus]:outline-1 data-[focus]:outline-white',
inputClassName,
)}
>
<div
className={twMerge(
'order-2 text-xs transition duration-200 group-data-[checked]:order-1',
options?.true ? 'w-18' : 'w-8',
field.value ? 'text-white' : 'text-black/80',
)}
>
{field.value ? options?.true : options?.false}
</div>
<div
aria-hidden="true"
className="pointer-events-none order-1 size-5 rounded-full bg-white ring-0 shadow-lg transition duration-200 group-data-[checked]:order-2"
/>
</HeadlessSwitch>
</div>
)}
/>
</Field>
)
}
+69
View File
@@ -0,0 +1,69 @@
import { Field, Input, Label, Select } from '@headlessui/react'
import { MagnifyingGlassIcon } from '@heroicons/react/24/solid'
import { useState } from 'react'
interface SearchFilterProperties {
title: string
columns?: string[]
onSearch: (value: string) => void
onStatusFilter?: (value: string) => Promise<void>
}
export const TableSearchFilter: React.FC<SearchFilterProperties> = ({
onSearch,
onStatusFilter,
title,
}: SearchFilterProperties) => {
const [searchTerm, setSearchTerm] = useState<string>('')
const [statusFilter, setStatusFilter] = useState<string>('')
const handleSearch = (searchValue: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(searchValue.target.value)
onSearch(searchValue.target.value)
}
const handleStatusFilter = (
searchValue: React.ChangeEvent<HTMLSelectElement>,
) => {
setStatusFilter(searchValue.target.value)
onStatusFilter?.(searchValue.target.value)
}
return (
<div className="flex items-center gap-5 rounded-lg bg-gray-50 text-[#363636]">
<div className="w-[400px]">
<Field>
<Label className="mb-2 block text-sm font-medium">Cari {title}</Label>
<div className="relative">
<Input
type="text"
value={searchTerm}
onChange={handleSearch}
placeholder={`Cari Nama ${title}`}
className="w-full rounded-lg bg-white p-2 pr-10 pl-4 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none"
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
<MagnifyingGlassIcon className="size-5 text-[#363636]" />
</div>
</div>
</Field>
</div>
{onStatusFilter && (
// will handel if filter have dropdown status
<div className="w-[235px]">
<Field>
<Label className="mb-2 block text-sm font-medium">Status</Label>
<Select
value={statusFilter}
onChange={handleStatusFilter}
className="w-full rounded-lg bg-white p-2 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none"
>
<option>Pilih Status</option>
<option>Aktif</option>
<option>Nonaktif</option>
</Select>
</Field>
</div>
)}
</div>
)
}
+54 -16
View File
@@ -1,22 +1,60 @@
import DT from 'datatables.net-dt'
import DataTable from 'datatables.net-react'
import DataTable, { type DataTableProps } from 'datatables.net-react'
import React from 'react'
export const TableComponent = () => {
type UiTableProperties = {
title: string
} & DataTableProps
const renderPaginationIcon = (icon: string) => {
return `<div class="pagination-icon">${icon}</div>`
}
export const UiTable: React.FC<UiTableProperties> = ({
data,
columns,
slots,
options,
title,
}) => {
DataTable.use(DT)
return (
<>
<h1>test</h1>
<DataTable
className="cell-border"
data={[]}
columns={[]}
options={{
paging: true,
searching: true,
ordering: true,
info: true,
}}
></DataTable>
</>
<div className="rounded-lg bg-white p-5 shadow-md">
<h3 className="py-1 font-semibold text-[#4C5CA0]">{title}</h3>
<div className="rounded-lg">
<DataTable
className="cell-border text-sm"
data={data}
columns={columns}
slots={slots}
options={{
headerCallback: (thead) => {
thead.classList.add('text-left')
},
paging: true,
searching: false,
ordering: true,
info: false,
language: {
paginate: {
first: renderPaginationIcon(
`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4 "><path stroke-linecap="round" stroke-linejoin="round" d="m18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5" /></svg>`,
),
previous: renderPaginationIcon(
`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" /></svg>`,
),
next: renderPaginationIcon(
`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg>`,
),
last: renderPaginationIcon(
`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4"> <path stroke-linecap="round" stroke-linejoin="round" d="m5.25 4.5 7.5 7.5-7.5 7.5m6-15 7.5 7.5-7.5 7.5" /> </svg>`,
),
},
},
...options,
}}
/>
</div>
</div>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { twMerge } from 'tailwind-merge'
import type { TTagResponse } from '~/apis/common/get-tags'
type TProperties = {
tags: TTagResponse[]
is_premium?: boolean
className?: string
}
export const Tags = (properties: TProperties) => {
const { tags, is_premium, className } = properties
return (
<div className={twMerge('flex gap-2 uppercase max-sm:text-sm', className)}>
{tags?.map((tag) => (
<span
key={tag.id}
className="inline-block rounded bg-[#F4F4F4] px-3 py-1 font-bold text-[#777777]"
>
{tag.name}
</span>
))}
{is_premium && (
<span className="inline-block rounded bg-[#D1C675] px-3 py-1 font-bold text-[#9D761D]">
Premium Content
</span>
)}
</div>
)
}
+1 -1
View File
@@ -4,7 +4,7 @@ type TitleDashboardProperties = {
export const TitleDashboard = (properties: TitleDashboardProperties) => {
const { title } = properties
return (
<div className="container mx-auto">
<div className="container">
<div className="mb-5 flex items-center justify-between">
<h1 className="text-xl font-bold">{title}</h1>
</div>
+2 -2
View File
@@ -2,6 +2,6 @@ export const USER_COOKIES = {
token: '__lg-usr-tkn',
}
export const ADMIN_COOKIES = {
token: '__lg-adm-tkn',
export const STAFF_COOKIES = {
token: '__lg-stf-tkn',
}
+4 -8
View File
@@ -1,9 +1,8 @@
import { MENU as ADMIN_MENU } from '~/layouts/admin/menu'
import { MENU as ADMIN_MENU, SUB_MENU } from '~/layouts/admin/menu'
export const APP = {
title: 'LegalGo',
description:
'Bonus judex secundum aequum et\n bonum judicat et aequitatem stricto juri praefert',
description: 'LegalGo Platform Berita Dunia dan Indonesia.',
logo: '/images/logo.png',
}
@@ -18,14 +17,11 @@ export const META_TITLE_CONFIG: TMetaTitleConfig = [
title: 'Home',
},
{
path: '/lg-admin/auth/login',
path: '/lg-admin/login',
title: 'Login',
},
{
path: '/lg-admin/auth/register',
title: 'Register',
},
...ADMIN_MENU.flatMap((menu) =>
menu.items.map((item) => ({ path: item.url, title: item.title })),
),
...SUB_MENU,
]
+6
View File
@@ -0,0 +1,6 @@
export const AUTH_PAGES = [
'/lg-admin/login',
'/lg-admin/forgot-password',
'/lg-admin/reset-password',
'/lg-admin/register',
]
+26 -10
View File
@@ -3,17 +3,24 @@ import {
useState,
useContext,
type PropsWithChildren,
type SetStateAction,
type Dispatch,
type SetStateAction,
} from 'react'
import { z } from 'zod'
type AdminProfile = {
name: string
}
export const uploadCategorySchema = z
.enum(['featured_image', 'ads', 'content', 'profile_picture'])
.optional()
export type TUpload = z.infer<typeof uploadCategorySchema>
type AdminContextProperties = {
adminProfile: AdminProfile
setAdminProfile: Dispatch<SetStateAction<AdminProfile>>
isUploadOpen: TUpload
setIsUploadOpen: Dispatch<SetStateAction<TUpload>>
uploadedFile?: string
setUploadedFile: Dispatch<SetStateAction<string | undefined>>
editProfile: boolean
setEditProfile: Dispatch<SetStateAction<boolean>>
}
const AdminContext = createContext<AdminContextProperties | undefined>(
@@ -21,12 +28,21 @@ const AdminContext = createContext<AdminContextProperties | undefined>(
)
export const AdminProvider = ({ children }: PropsWithChildren) => {
const [adminProfile, setAdminProfile] = useState<AdminProfile>({
name: 'Admin',
})
const [isUploadOpen, setIsUploadOpen] = useState<TUpload>()
const [uploadedFile, setUploadedFile] = useState<string | undefined>()
const [editProfile, setEditProfile] = useState(false)
return (
<AdminContext.Provider value={{ adminProfile, setAdminProfile }}>
<AdminContext.Provider
value={{
isUploadOpen,
setIsUploadOpen,
uploadedFile,
setUploadedFile,
editProfile,
setEditProfile,
}}
>
{children}
</AdminContext.Provider>
)
+7 -7
View File
@@ -7,9 +7,9 @@ import {
type SetStateAction,
} from 'react'
import type { ModalProperties } from '~/components/popup/success-modal'
import type { ModalProperties } from '~/components/dialog/success'
export type NewsContextProperties = {
type NewsContextProperties = {
isLoginOpen: boolean
setIsLoginOpen: Dispatch<SetStateAction<boolean>>
isRegisterOpen: boolean
@@ -20,8 +20,8 @@ export type NewsContextProperties = {
setIsSuccessOpen: Dispatch<
SetStateAction<ModalProperties['isOpen'] | undefined>
>
isInitSubscribeOpen: boolean
setIsInitSubscribeOpen: Dispatch<SetStateAction<boolean>>
isSubscribeOpen: boolean
setIsSubscribeOpen: Dispatch<SetStateAction<boolean>>
}
const NewsContext = createContext<NewsContextProperties | undefined>(undefined)
@@ -32,7 +32,7 @@ export const NewsProvider = ({ children }: PropsWithChildren) => {
const [isForgetOpen, setIsForgetOpen] = useState(false)
const [isSuccessOpen, setIsSuccessOpen] =
useState<ModalProperties['isOpen']>()
const [isInitSubscribeOpen, setIsInitSubscribeOpen] = useState(false)
const [isSubscribeOpen, setIsSubscribeOpen] = useState(false)
return (
<NewsContext.Provider
@@ -45,8 +45,8 @@ export const NewsProvider = ({ children }: PropsWithChildren) => {
setIsForgetOpen,
isSuccessOpen,
setIsSuccessOpen,
isInitSubscribeOpen,
setIsInitSubscribeOpen,
isSubscribeOpen,
setIsSubscribeOpen,
}}
>
{children}
-132
View File
@@ -1,132 +0,0 @@
import type { TNews } from '~/types/news'
export const SPOTLIGHT: TNews = {
title: 'SPOTLIGHT',
description: 'Berita Terhangat hari ini',
type: 'hero',
items: [
{
title: '01 Hotman Paris Membuka Perpustakaan di tengah Diskotik',
content:
'Pengacara Kondang, Hotman Paris Hutapea, membuka sebuah perpustakaan baru di dalam diskotik nya yang berlokasi di daerah Jakarta Pusat, Hotman berkata Perpustakaan ini dibuka dengan harapan untuk meningkatkan gairah membaca masyarakat Indonesia, namun sayangnya..',
featured: '/images/news-1.jpg',
slug: 'hotman-paris-membuka-perpustakaan-di-tengah-diskotik',
},
{
title: '02 Travelling as a way of self-discovery and progress',
content:
'Pengacara Kondang, Hotman Paris Hutapea, membuka sebuah perpustakaan baru di dalam diskotik nya yang berlokasi di daerah Jakarta Pusat, Hotman berkata Perpustakaan ini dibuka dengan harapan untuk meningkatkan gairah membaca masyarakat Indonesia, namun sayangnya..',
featured: 'https://placehold.co/600x400.png',
slug: 'hotman-paris-membuka-perpustakaan-di-tengah-diskotik',
},
{
title: '03 Travelling as a way of self-discovery and progress',
content:
'Pengacara Kondang, Hotman Paris Hutapea, membuka sebuah perpustakaan baru di dalam diskotik nya yang berlokasi di daerah Jakarta Pusat, Hotman berkata Perpustakaan ini dibuka dengan harapan untuk meningkatkan gairah membaca masyarakat Indonesia, namun sayangnya..',
featured: '/images/news-1.jpg',
slug: 'hotman-paris-membuka-perpustakaan-di-tengah-diskotik',
},
],
}
export const BERITA: TNews = {
title: 'BERITA',
description: 'Berita Terhangat hari ini',
type: 'grid',
items: [
{
title: '01 Travelling as a way of self-discovery and progress ',
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
featured: '/images/news-2.jpg',
tags: ['Hukum Property'],
slug: 'travelling-as-a-way-of-self-discovery-and-progress',
},
{
title: '02 How does writing influence your personal brand?',
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
featured: '/images/news-3.jpg',
tags: ['Hukum'],
slug: 'how-does-writing-influence-your-personal-brand',
},
{
title: '03 Helping a local business reinvent itself',
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
featured: '/images/news-4.jpg',
tags: ['Hukum Property'],
isPremium: true,
slug: 'helping-a-local-business-reinvent-itself',
},
{
title: 'Travelling as a way of self-discovery and progress',
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
featured: 'https://placehold.co/600x400.png',
tags: ['Hukum Property'],
slug: 'travelling-as-a-way-of-self-discovery-and-progress',
},
{
title: 'How does writing influence your personal brand?',
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
featured: '/images/news-3.jpg',
tags: ['Hukum'],
slug: 'how-does-writing-influence-your-personal-brand',
},
{
title: 'Helping a local business reinvent itself',
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
featured: '/images/news-4.jpg',
tags: ['Hukum Property'],
isPremium: true,
slug: 'helping-a-local-business-reinvent-itself',
},
],
}
export const KAJIAN: TNews = {
title: 'KAJIAN',
description: 'Berita Terhangat hari ini',
type: 'grid',
items: [
{
title: 'Travelling as a way of self-discovery and progress',
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
featured: '/images/news-2.jpg',
tags: ['Hukum Property'],
isPremium: true,
slug: 'travelling-as-a-way-of-self-discovery-and-progress',
},
{
title: 'Travelling as a way of self-discovery and progress',
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
featured: 'https://placehold.co/600x400.png',
tags: ['Hukum Property'],
isPremium: true,
slug: 'travelling-as-a-way-of-self-discovery-and-progress',
},
{
title: 'How does writing influence your personal brand?',
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
featured: '/images/news-3.jpg',
tags: ['Hukum Property'],
isPremium: true,
slug: 'how-does-writing-influence-your-personal-brand',
},
{
title: 'Helping a local business reinvent itself',
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
featured: '/images/news-4.jpg',
tags: ['Hukum Property'],
isPremium: true,
slug: 'helping-a-local-business-reinvent-itself',
},
],
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, type RefObject } from 'react'
type Event = MouseEvent | TouchEvent
export const useClickOutside = <T extends HTMLElement = HTMLElement>(
reference: RefObject<T | null>,
handler: (event: Event) => void,
) => {
useEffect(() => {
const listener = (event: Event) => {
const element = reference?.current
if (!element || element.contains((event?.target as Node) || undefined)) {
return
}
handler(event)
}
document.addEventListener('mousedown', listener)
document.addEventListener('touchstart', listener)
return () => {
document.removeEventListener('mousedown', listener)
document.removeEventListener('touchstart', listener)
}
}, [reference, handler])
}
+5
View File
@@ -1,5 +1,7 @@
import type { PropsWithChildren } from 'react'
import { DialogProfile } from './dialog-profile'
import { DialogUpload } from './dialog-upload'
import { Navbar } from './navbar'
import { Sidebar } from './sidebar'
@@ -12,6 +14,9 @@ export const AdminDashboardLayout = (properties: PropsWithChildren) => {
<Sidebar />
<div className="min-h-[calc(100dvh-80px)] flex-1 p-8">{children}</div>
</div>
<DialogUpload />
<DialogProfile />
</div>
)
}
+2
View File
@@ -1,10 +1,12 @@
import type { PropsWithChildren } from 'react'
import { Toaster } from 'react-hot-toast'
export const AdminDefaultLayout = (properties: PropsWithChildren) => {
const { children } = properties
return (
<main className="font-admin relative min-h-dvh bg-[#F7F8FC]">
{children}
<Toaster />
</main>
)
}
+126
View File
@@ -0,0 +1,126 @@
import {
Dialog,
DialogBackdrop,
DialogPanel,
DialogTitle,
} from '@headlessui/react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect } from 'react'
import toast from 'react-hot-toast'
import { useFetcher, useRouteLoaderData } from 'react-router'
import { RemixFormProvider, useRemixForm } from 'remix-hook-form'
import { z } from 'zod'
import { Button } from '~/components/ui/button'
import { Input } from '~/components/ui/input'
import { InputFile } from '~/components/ui/input-file'
import { useAdminContext } from '~/contexts/admin'
import type { loader } from '~/routes/_admin.lg-admin'
export const profileSchema = z.object({
name: z.string().min(1, 'Wajib diisi'),
email: z.string().email('Email tidak valid'),
profile_picture: z.string().url({
message: 'URL tidak valid',
}),
})
export type TProfileSchema = z.infer<typeof profileSchema>
export const DialogProfile = () => {
const { editProfile, setEditProfile } = useAdminContext()
const loaderData = useRouteLoaderData<typeof loader>('routes/_admin.lg-admin')
const { staffData } = loaderData || {}
const fetcher = useFetcher()
const formMethods = useRemixForm<TProfileSchema>({
mode: 'onSubmit',
fetcher,
resolver: zodResolver(profileSchema),
values: {
name: staffData?.name || '',
email: staffData?.email || '',
profile_picture: staffData?.profile_picture || '',
},
})
const { handleSubmit } = formMethods
useEffect(() => {
if (!fetcher.data?.success && fetcher.data?.message) {
toast.error(fetcher.data.message)
}
if (fetcher.data?.success) {
setEditProfile(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetcher.data])
return (
<Dialog
open={editProfile}
onClose={() => {
if (fetcher.state === 'idle') {
setEditProfile(false)
}
}}
className="relative z-50"
transition
>
<DialogBackdrop
className="fixed inset-0 bg-black/50 duration-300 ease-out data-[closed]:opacity-0"
transition
/>
<div className="fixed inset-0 flex w-screen justify-center overflow-y-auto p-0 max-sm:bg-white sm:items-center sm:p-4">
<DialogPanel
transition
className="w-full max-w-lg space-y-6 rounded-lg bg-white p-8 duration-300 ease-out data-[closed]:scale-95 data-[closed]:opacity-0 sm:shadow-lg"
>
<DialogTitle
as="h3"
className="text-xl font-bold"
>
Update Profile
</DialogTitle>
<RemixFormProvider {...formMethods}>
<fetcher.Form
method="post"
onSubmit={handleSubmit}
className="space-y-4"
action="/actions/admin/profile"
>
<Input
name="name"
id="name"
label="Nama"
placeholder="Enter your name"
/>
<Input
id="email"
label="Email"
placeholder="Contoh: legal@legalgo.id"
name="email"
/>
<InputFile
name="profile_picture"
id="profile_picture"
label="Gambar Profil"
placeholder="Unggah gambar profil Anda"
category="profile_picture"
/>
<Button
disabled={fetcher.state !== 'idle'}
isLoading={fetcher.state !== 'idle'}
type="submit"
className="w-full rounded-md py-2"
>
Simpan
</Button>
</fetcher.Form>
</RemixFormProvider>
</DialogPanel>
</div>
</Dialog>
)
}
+139
View File
@@ -0,0 +1,139 @@
import { Dialog, DialogBackdrop, DialogPanel, Input } from '@headlessui/react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect, type ChangeEvent } from 'react'
import toast from 'react-hot-toast'
import { useFetcher } from 'react-router'
import { RemixFormProvider, useRemixForm } from 'remix-hook-form'
import { z } from 'zod'
import { Button } from '~/components/ui/button'
import { uploadCategorySchema, useAdminContext } from '~/contexts/admin'
export const uploadSchema = z.object({
file: z.instanceof(File),
category: uploadCategorySchema,
})
export type TUploadSchema = z.infer<typeof uploadSchema>
export const DialogUpload = () => {
const { isUploadOpen, setUploadedFile, setIsUploadOpen } = useAdminContext()
const fetcher = useFetcher()
const maxFileSize = 10 * 1024 // 10MB
const formMethods = useRemixForm<TUploadSchema>({
mode: 'onSubmit',
fetcher,
resolver: zodResolver(uploadSchema),
})
const { handleSubmit, register, setValue } = formMethods
useEffect(() => {
if (!fetcher.data?.success && fetcher.data?.message) {
toast.error(fetcher.data.message)
}
if (fetcher.data?.success) {
setUploadedFile(fetcher.data.uploadData.data.file_url)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetcher.data])
const handleChange = async function (event: ChangeEvent<HTMLInputElement>) {
event.preventDefault()
if (event.target.files && event.target.files[0]) {
const files: File[] = [...event.target.files]
onChange(files, event)
}
}
const onChange = async function (
files: File[],
event: ChangeEvent<HTMLInputElement>,
) {
const file = files[0]
const img = new Image()
if (!file.type.startsWith('image/')) {
toast.error('Please upload an image file.')
return
}
if (file.size > maxFileSize * 1024) {
toast.error(`File size is too big!`)
return
}
img.addEventListener('load', () => {
handleFiles(event)
})
img.src = URL.createObjectURL(file)
}
const handleFiles = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files
if (files && files.length > 0) {
const file = files[0]
setValue('file', file)
}
}
return (
<Dialog
open={!!isUploadOpen}
onClose={() => {
if (fetcher.state === 'idle') {
setIsUploadOpen(undefined)
}
}}
className="relative z-50"
transition
>
<DialogBackdrop
className="fixed inset-0 bg-black/50 duration-300 ease-out data-[closed]:opacity-0"
transition
/>
<div className="fixed inset-0 flex w-screen justify-center overflow-y-auto p-0 max-sm:bg-white sm:items-center sm:p-4">
<DialogPanel
transition
className="w-full max-w-lg space-y-6 rounded-lg bg-white p-8 duration-300 ease-out data-[closed]:scale-95 data-[closed]:opacity-0 sm:shadow-lg"
>
<RemixFormProvider {...formMethods}>
<fetcher.Form
method="post"
onSubmit={handleSubmit}
className="space-y-4"
action="/actions/admin/upload"
encType="multipart/form-data"
>
<Input
type="file"
id="input-file-upload"
accept="image/*"
className="h-[42px] w-full cursor-pointer rounded-md border border-[#DFDFDF] p-2"
onChange={handleChange}
/>
<input
type="hidden"
id="input-file-upload-type"
value={isUploadOpen}
{...register('category')}
/>
<Button
disabled={fetcher.state !== 'idle'}
isLoading={fetcher.state !== 'idle'}
type="submit"
className="w-full rounded-md py-2"
>
Upload
</Button>
</fetcher.Form>
</RemixFormProvider>
</DialogPanel>
</div>
</Dialog>
)
}
+64 -31
View File
@@ -1,21 +1,22 @@
import type { JSX, SVGProps } from 'react'
import { ChartIcon } from '~/components/icons/chart'
import { ChatIcon } from '~/components/icons/chat'
import { DocumentIcon } from '~/components/icons/document'
import { MedicalNotesIcon } from '~/components/icons/medical-notes'
import { ProfileIcon } from '~/components/icons/profile'
import { SettingIcon } from '~/components/icons/setting'
import { WalletIcon } from '~/components/icons/wallet'
import {
BriefcaseIcon,
ChartBarSquareIcon,
ClipboardDocumentCheckIcon,
DocumentCurrencyDollarIcon,
MegaphoneIcon,
NewspaperIcon,
PresentationChartLineIcon,
TagIcon,
UsersIcon,
} from '@heroicons/react/24/solid'
import type { ComponentType, SVGProps } from 'react'
type TMenu = {
group: string
items: {
title: string
url: string
icon: (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => JSX.Element
icon: ComponentType<SVGProps<SVGSVGElement>>
}[]
}
@@ -24,29 +25,29 @@ export const MENU: TMenu[] = [
group: 'Menu',
items: [
{
title: 'Dashboard',
title: 'Dasbor',
url: '/lg-admin',
icon: ChartIcon,
icon: ChartBarSquareIcon,
},
{
title: 'User',
title: 'Pengguna',
url: '/lg-admin/users',
icon: DocumentIcon,
icon: UsersIcon,
},
{
title: 'Konten',
title: 'Artikel',
url: '/lg-admin/contents',
icon: ChatIcon,
icon: NewspaperIcon,
},
{
title: 'Advertisement',
title: 'Spanduk Iklan',
url: '/lg-admin/advertisements',
icon: MedicalNotesIcon,
icon: MegaphoneIcon,
},
{
title: 'Subscription',
title: 'Pelanggan',
url: '/lg-admin/subscriptions',
icon: ChartIcon,
icon: PresentationChartLineIcon,
},
],
},
@@ -54,20 +55,52 @@ export const MENU: TMenu[] = [
group: 'Others',
items: [
{
title: 'Data Situs',
url: '/lg-admin/site-data',
icon: WalletIcon,
title: 'Kategori',
url: '/lg-admin/categories',
icon: ClipboardDocumentCheckIcon,
},
{
title: 'Pengaturan',
url: '/lg-admin/settings',
icon: SettingIcon,
title: 'Tag',
url: '/lg-admin/tags',
icon: TagIcon,
},
{
title: 'Admin',
url: '/lg-admin/admins',
icon: ProfileIcon,
title: 'Paket Berlangganan',
url: '/lg-admin/subscribe-plan',
icon: DocumentCurrencyDollarIcon,
},
{
title: 'Staf',
url: '/lg-admin/staffs',
icon: BriefcaseIcon,
},
],
},
]
export const SUB_MENU = [
{
title: 'Buat Artikel',
path: '/lg-admin/contents/create',
},
{
title: 'Update Artikel',
path: '/lg-admin/contents/update',
},
{
title: 'Buat Kategori',
path: '/lg-admin/categories/create',
},
{
title: 'Update Kategori',
path: '/lg-admin/categories/update',
},
{
title: 'Buat Tag',
path: '/lg-admin/tags/create',
},
{
title: 'Update Tag',
path: '/lg-admin/tags/update',
},
]
+69 -15
View File
@@ -1,12 +1,21 @@
import { Link } from 'react-router'
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
import {
ArrowRightStartOnRectangleIcon,
UserIcon,
} from '@heroicons/react/24/outline'
import { ChevronDownIcon } from '@heroicons/react/24/solid'
import { Link, useFetcher, useRouteLoaderData } from 'react-router'
import { ChevronIcon } from '~/components/icons/chevron'
import { NotificationIcon } from '~/components/icons/notification'
import { Button } from '~/components/ui/button'
import { APP } from '~/configs/meta'
import { useAdminContext } from '~/contexts/admin'
import type { loader } from '~/routes/_admin.lg-admin'
export const Navbar = () => {
const { adminProfile } = useAdminContext()
const loaderData = useRouteLoaderData<typeof loader>('routes/_admin.lg-admin')
const { staffData } = loaderData || {}
const fetcher = useFetcher()
const { setEditProfile } = useAdminContext()
return (
<div className="flex h-20 items-center justify-between border-b border-[#ECECEC] bg-white px-10 py-5">
@@ -21,17 +30,62 @@ export const Navbar = () => {
/>
</Link>
<div className="flex items-center gap-x-8">
<div className="flex w-3xs items-center justify-between">
<div className="flex items-center">
<div className="mr-3 h-8 w-8 rounded-full bg-[#C4C4C4]" />
<span className="text-xs">{adminProfile.name}</span>
</div>
<ChevronIcon className="opacity-50" />
</div>
<NotificationIcon
className="text-[#B0C3CC]"
showBadge={true}
/>
<Popover className="relative">
<PopoverButton className="flex w-3xs cursor-pointer items-center justify-between rounded-xl p-2 ring-1 ring-[#707FDD]/10 hover:shadow focus:outline-none">
<div className="flex items-center space-x-3">
<img
src={
staffData?.profile_picture ||
'/images/profile-placeholder.svg'
}
onError={(event) => {
event.currentTarget.src = '/images/profile-placeholder.svg'
}}
alt={staffData?.name}
className="size-8 rounded-full bg-[#C4C4C4] object-cover"
/>
<span className="text-sm">{staffData?.name}</span>
</div>
<ChevronDownIcon className="size-4 opacity-50" />
</PopoverButton>
<PopoverPanel
anchor={{ to: 'bottom', gap: '8px' }}
transition
className="flex w-3xs flex-col divide-y divide-black/5 rounded-xl border border-[#ECECEC] bg-white transition duration-200 ease-in-out data-[closed]:-translate-y-1 data-[closed]:opacity-0"
>
<div className="p-2">
<Button
variant="secondary"
className="w-full justify-start rounded p-1 px-3 text-base font-bold"
onClick={() => {
setEditProfile(true)
}}
>
<UserIcon className="size-5" />
<span>Profile</span>
</Button>
</div>
<div className="p-2">
<fetcher.Form
method="POST"
action="/actions/admin/logout"
className="grid"
>
<Button
disabled={fetcher.state !== 'idle'}
isLoading={fetcher.state !== 'idle'}
type="submit"
className="w-full justify-start rounded p-1 px-3 text-base font-bold"
variant="secondary"
icon={<ArrowRightStartOnRectangleIcon className="size-5" />}
>
<span>Logout</span>
</Button>
</fetcher.Form>
</div>
</PopoverPanel>
</Popover>
</div>
</div>
)
+9 -7
View File
@@ -1,10 +1,12 @@
import { NavLink, useLocation } from 'react-router'
import { Link, useLocation } from 'react-router'
import { twMerge } from 'tailwind-merge'
import { MENU } from './menu'
export const Sidebar = () => {
const { pathname } = useLocation()
const segments = pathname.split('/')
const path = segments.length > 3 ? segments.slice(0, 3).join('/') : pathname
return (
<div className="flex min-h-[calc(100dvh-80px)] flex-col gap-y-10 overflow-y-auto bg-white p-5">
@@ -15,29 +17,29 @@ export const Sidebar = () => {
>
<div className="px-5 pb-4 text-[#4C5CA0]/50 uppercase">{group}</div>
{items.map(({ title, url, icon: Icon }) => (
<NavLink
<Link
to={url}
key={`${group}-${title}`}
className={twMerge(
pathname === url ? 'bg-[#707FDD]/10 font-bold' : '',
'group/menu flex h-[42px] w-[200px] items-center gap-x-3 rounded-md px-5 transition-all hover:bg-[#707FDD]/10',
path === url ? 'bg-[#707FDD]/10 font-bold' : '',
'group/menu flex h-[42px] w-[240px] items-center gap-x-3 rounded-md px-5 transition hover:bg-[#707FDD]/10 active:bg-[#707FDD]/20',
)}
>
<Icon
className={twMerge(
pathname === url ? 'text-[#5363AB]' : 'text-[#A6ABC8]',
path === url ? 'text-[#5363AB]' : 'text-[#A6ABC8]',
'h-[18px] w-[18px] transition-all group-hover/menu:text-[#5363AB]',
)}
/>
<span
className={twMerge(
pathname === url ? 'text-[#5363AB]' : 'text-[#273240]',
path === url ? 'text-[#5363AB]' : 'text-[#273240]',
'text-base transition-all group-hover/menu:text-[#5363AB]',
)}
>
{title}
</span>
</NavLink>
</Link>
))}
</div>
))}

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