Compare commits

...
317 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
Ardeman 9bc7fbb902 Merge commit '9b13733205cf48354c86997c188063706657962a' 2025-02-28 22:58:55 +08:00
Ardeman 13f44f336d feat: update News context to use consistent setter naming and refactor FormLogin to utilize context 2025-02-28 22:50:58 +08:00
fredy.siswanto 3a61b38961 feat: simplify layout margins for banner and news categories page 2025-02-28 18:59:27 +07:00
fredy.siswanto 9b13733205 feat: update breadcrumb component for improved slug display and enhance news detail page layout 2025-02-28 18:55:12 +07:00
fredy.siswanto 2b551aad28 feat: implement carousel components for news section and update layout styles 2025-02-28 16:50:44 +07:00
224 changed files with 10216 additions and 2777 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)
}
}
+49
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;
}
@@ -28,3 +36,44 @@ table.dataTable tbody > tr > td {
border-right: none !important;
border-bottom: 1px solid #ebebeb;
}
nav[aria-label='pagination'] {
display: flex;
justify-content: center;
}
/* Style untuk tombol aktif (current) */
.dt-paging-button.current {
background-color: #2e2f7c !important;
color: white !important;
}
/* 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;
}
/* 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="flex items-center gap-2">
<Link to={'#'}>Blog</Link>
<div>{'>'}</div>
<Link
to={'#'}
className="text-ellipsis md:text-clip ..."
>
{slug}
</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>
+157
View File
@@ -0,0 +1,157 @@
import useEmblaCarousel from 'embla-carousel-react'
import { Suspense, useCallback, useEffect, useState } from 'react'
import { Await, useRouteLoaderData } from 'react-router'
import { stripHtml } from 'string-strip-html'
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: 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 (canScrollPrevious && emblaApi) emblaApi.scrollPrev()
}, [emblaApi, canScrollPrevious])
const nextSlide = useCallback(() => {
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]">
<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 className="flex gap-2.5">
<CarouselButton
direction="prev"
isEnabled={canScrollPrevious}
onClick={previousSlide}
/>
<CarouselButton
direction="next"
isEnabled={canScrollNext}
onClick={nextSlide}
/>
</div>
</div>
<div
className="embla hero overflow-hidden"
ref={emblaReference}
>
<div className="embla__container hero flex sm:gap-x-8">
<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>
}
>
<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>
)
}
+163
View File
@@ -0,0 +1,163 @@
import useEmblaCarousel from 'embla-carousel-react'
import { Suspense, useCallback, useEffect, useState } from 'react'
import { Await, useRouteLoaderData } from 'react-router'
import { stripHtml } from 'string-strip-html'
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,
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 (canScrollPrevious && emblaApi) emblaApi.scrollPrev()
}, [emblaApi, canScrollPrevious])
const nextSlide = useCallback(() => {
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]">
<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 className="flex gap-2.5">
<CarouselButton
direction="prev"
isEnabled={canScrollPrevious}
onClick={previousSlide}
/>
<CarouselButton
direction="next"
isEnabled={canScrollNext}
onClick={nextSlide}
/>
</div>
</div>
<div
className="embla overflow-hidden"
ref={emblaReference}
>
<div className="embla__container col-span-3 flex max-h-[586px] sm:gap-x-8">
<Suspense
fallback={Array.from({ length: 3 }).map((_, index) => (
<div
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 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>
)
+10 -10
View File
@@ -7,21 +7,21 @@ 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
setIsRegisterOpen: Dispatch<SetStateAction<boolean>>
isForgetOpen: boolean
setForgetOpen: Dispatch<SetStateAction<boolean>>
setIsForgetOpen: Dispatch<SetStateAction<boolean>>
isSuccessOpen: ModalProperties['isOpen']
setIsSuccessOpen: Dispatch<
SetStateAction<ModalProperties['isOpen'] | undefined>
>
isInitSubscribeOpen: boolean
setIsInitSubscribeOpen: Dispatch<SetStateAction<boolean>>
isSubscribeOpen: boolean
setIsSubscribeOpen: Dispatch<SetStateAction<boolean>>
}
const NewsContext = createContext<NewsContextProperties | undefined>(undefined)
@@ -29,10 +29,10 @@ const NewsContext = createContext<NewsContextProperties | undefined>(undefined)
export const NewsProvider = ({ children }: PropsWithChildren) => {
const [isLoginOpen, setIsLoginOpen] = useState(false)
const [isRegisterOpen, setIsRegisterOpen] = useState(false)
const [isForgetOpen, setForgetOpen] = useState(false)
const [isForgetOpen, setIsForgetOpen] = useState(false)
const [isSuccessOpen, setIsSuccessOpen] =
useState<ModalProperties['isOpen']>()
const [isInitSubscribeOpen, setIsInitSubscribeOpen] = useState(false)
const [isSubscribeOpen, setIsSubscribeOpen] = useState(false)
return (
<NewsContext.Provider
@@ -42,11 +42,11 @@ export const NewsProvider = ({ children }: PropsWithChildren) => {
isRegisterOpen,
setIsRegisterOpen,
isForgetOpen,
setForgetOpen,
setIsForgetOpen,
isSuccessOpen,
setIsSuccessOpen,
isInitSubscribeOpen,
setIsInitSubscribeOpen,
isSubscribeOpen,
setIsSubscribeOpen,
}}
>
{children}
+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>
))}
+52
View File
@@ -0,0 +1,52 @@
import { Button } from '@headlessui/react'
import Autoplay from 'embla-carousel-autoplay'
import useEmblaCarousel from 'embla-carousel-react'
import { useFetcher, useRouteLoaderData } from 'react-router'
import type { loader } from '~/routes/_news'
export const Banner = () => {
const loaderData = useRouteLoaderData<typeof loader>('routes/_news')
const { adsData } = loaderData || {}
const [emblaReference] = useEmblaCarousel({ loop: true }, [
Autoplay({
stopOnInteraction: false,
stopOnMouseEnter: true,
}),
])
const fetcher = useFetcher()
return (
<div className="">
<div className="relative">
<div
className="embla overflow-hidden"
ref={emblaReference}
>
<div className="embla__container flex">
{adsData?.map(({ image_url: urlImage, url, id }, index) => (
<fetcher.Form
method="POST"
action={`/actions/log/ads/${id}`}
key={index}
className="embla__slide max-h-[100px] min-h-[65px] w-full min-w-0 flex-none"
>
<Button
className="h-full w-full cursor-pointer py-2"
type="submit"
onClick={() => window.open(url, '_blank')}
>
<img
src={urlImage}
alt={id}
className="h-[70px] w-[100%] object-contain object-center sm:h-full"
/>
</Button>
</fetcher.Form>
))}
</div>
</div>
</div>
</div>
)
}

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