Compare commits

..
411 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
Ardeman 3f9bfded80 feat: update admin routes and cookie paths to use '/lg-admin' prefix 2025-02-28 12:19:05 +08:00
Ardeman 5f1fd8b1a2 feat: implement admin authentication and dashboard layout with routing 2025-02-28 12:05:20 +08:00
Ardeman 23e3493a3f feat: restructure routing and layouts for admin and news sections, update cookie paths 2025-02-28 11:49:51 +08:00
Ardeman 4b8fc0721e feat: enhance Input component with placeholder support and styling adjustments 2025-02-28 09:23:36 +08:00
Ardeman f66ddde6f0 feat: switch from useFormContext to useRemixFormContext in Input component 2025-02-28 09:12:38 +08:00
Ardeman 0b6b43a951 feat: enhance login form with error handling and disable state 2025-02-28 09:10:45 +08:00
Ardeman e546ebd3dd docs: update README to include environment variable setup instructions 2025-02-27 23:54:53 +08:00
Ardeman 7db38d3b67 feat: implement logout action 2025-02-27 23:50:46 +08:00
Ardeman 5acf3970a3 fix: improve error message handling in login action 2025-02-27 23:10:01 +08:00
Ardeman a15c267eda feat: remove .env file and update .gitignore to exclude it 2025-02-27 22:53:22 +08:00
Ardeman 16a6a14d67 feat: enhance Newsletter component with props 2025-02-27 19:40:31 +08:00
Ardeman f9d861f24d feat: add environment configuration and implement cookie handling for user authentication 2025-02-27 19:37:31 +08:00
Ardeman 9386b8dd69 feat: remove placeholder text from Banner component 2025-02-27 17:59:19 +08:00
fredy.siswanto 1d23fea02a Merge remote-tracking branch 'origin/master' into feature/slicing 2025-02-27 16:45:21 +07:00
fredy.siswanto 7eb38d03a0 feat: enhance newsletter component with customizable properties and add search/filter adj dashboard 2025-02-27 16:42:36 +07:00
Ardeman 581e99c4b3 feat: remove react-query dependency and simplify App component 2025-02-27 16:48:55 +08:00
fredy.siswanto a333b7924f feat: enhance subscriptions page with status rendering and search/filter functionality 2025-02-27 00:09:47 +07:00
fredy.siswanto c36c5721c8 feat: update content tags and enhance rendering in dashboard data table 2025-02-26 23:26:46 +07:00
fredy.siswanto d273d9ecc6 feat: implement DataTable component with custom styling for dashboard and subscriptions pages 2025-02-26 23:25:05 +07:00
fredy.siswanto 238e69b5e2 feat: enhance subscriptions page with new data structure and toggle view for settings 2025-02-26 15:10:47 +07:00
fredy.siswanto 7218c7036b feat: add banner upload functionality with preview and link input 2025-02-26 13:52:47 +07:00
fredy.siswanto bd3ee8db53 fix: update bar chart max value based on view type 2025-02-26 00:43:47 +07:00
fredy.siswanto 6a00b9207e feat: add bar and subscription chart components, update dashboard integration 2025-02-26 00:35:53 +07:00
fredy.siswanto bd92f9a09f feat: add pie chart component and integrate it into the dashboard 2025-02-25 22:52:26 +07:00
fredy.siswanto b7e029546f feat: add subscriptions page with data table and sample data 2025-02-25 15:55:41 +07:00
fredy.siswanto e476e3a3ad feat: implement dashboard contents page with data table and styling 2025-02-25 15:45:27 +07:00
Ardeman 929f5c91b0 chore: add eslint-import-resolver-typescript to dependencies and update version 2025-02-25 09:47:28 +08:00
Ardeman bf831b9c46 refactor: update ESLint configuration to include prettier and adjust rules 2025-02-25 09:41:47 +08:00
Ardeman 6829bc86fc refactor: update ESLint configuration to include @tanstack/query plugin and rules 2025-02-25 08:56:16 +08:00
Ardeman c1bacacb49 feat: add cookie management and HTTP client for user and admin authentication 2025-02-25 08:51:08 +08:00
Ardeman 0c38a775e3 refactor: move APP import from data to configs for better organization 2025-02-25 06:07:45 +08:00
Ardeman 5227204791 refactor: remove unnecessary size attribute from HeaderSearch input for improved styling 2025-02-25 06:06:01 +08:00
Ardeman cf130a00d9 refactor: update SuccessModal and News context for improved state management and clarity 2025-02-25 06:00:12 +08:00
Ardeman 6b2ba85642 feat: enhance SuccessModal and News context for improved modal management and subscription flow 2025-02-25 05:18:40 +08:00
Ardeman fe9b8bc97a refactor: simplify Carousel component and update news data structure for clarity 2025-02-25 04:48:13 +08:00
fredy.siswanto 7e82768423 refactor: clean up Carousel component by removing commented-out code 2025-02-24 23:30:01 +07:00
fredy.siswanto 9e1fb4ed48 feat: update news items with new titles, content, and slugs for improved organization 2025-02-24 23:27:41 +07:00
fredy.siswanto a94b5c2f65 Merge branch 'master' into feature/slicing 2025-02-24 21:52:32 +07:00
fredy.siswanto d42ea410c6 feat: implement TitleDashboard component and integrate it into UsersPage and AdvertisementsPage 2025-02-24 21:48:41 +07:00
fredy.siswanto 877921b9f6 feat: update news detail content structure and enhance styling for improved readability 2025-02-24 20:36:43 +07:00
fredy.siswanto 1cd5b0e848 feat: enhance USERS data structure with idTransaction and update UsersPage to display it 2025-02-24 14:23:08 +07:00
fredy.siswanto 1c0176ac4c style: enhance input and select components with shadow for improved UI 2025-02-24 14:12:25 +07:00
fredy.siswanto 4833a1e45b feat: add ChevronIcon and ChevronDoubleIcon components, and implement pagination in UsersPage 2025-02-24 14:10:15 +07:00
Ardeman 2a0b0af45f feat: add @tanstack/react-query, and DataTables packages 2025-02-24 12:27:49 +08:00
Ardeman 8bf7bd812d feat: add @tanstack/react-query and related packages for improved data fetching 2025-02-24 12:15:59 +08:00
Ardeman ca0dfbc5f8 feat: add DoctorIcon component and integrate it into dashboard reports 2025-02-24 09:31:48 +08:00
Ardeman 4d7fa04d91 fix: update GraphIcon fill property to 'none' for better SVG rendering 2025-02-24 08:36:06 +08:00
Ardeman 6082cf0cdb refactor: replace interface with type for component properties and improve code readability 2025-02-24 08:23:22 +08:00
Ardeman 9e6d2fe6c0 feat: add loader to redirect to admin dashboard 2025-02-24 08:17:35 +08:00
Ardeman e11f007eb1 feat: fix amount property typo and format numbers for dashboard reports 2025-02-24 08:04:09 +08:00
fredy.siswanto e484af6e71 feat: update report amounts to string format and enhance dashboard layout 2025-02-24 00:54:00 +07:00
fredy.siswanto 00bb4f99cb feat: update dashboard components and add new icons for improved reporting 2025-02-23 23:17:06 +07:00
fredy.siswanto 7334e07537 Merge remote-tracking branch 'origin/master' into feature/slicing 2025-02-23 21:14:28 +07:00
fredy.siswanto 712b9155d2 feat: add dashboard page and data structure for reporting 2025-02-23 21:13:31 +07:00
Ardeman 2b8d0604e0 feat: refactor components to use named exports for better tree-shaking 2025-02-23 22:10:47 +08:00
Ardeman 4dda852c62 feat: add reusable Input component and integrate it into the login form 2025-02-23 22:08:47 +08:00
fredy.siswanto 7b42f6bfe5 feat: refactor components to use named exports for better consistency 2025-02-23 20:41:19 +07:00
Ardeman 4fa3873dda feat: add @hookform/resolvers dependency and update zod version in package.json 2025-02-23 19:47:12 +08:00
Ardeman feff32f588 feat: update social share component, and enhance modal structure 2025-02-23 19:43:45 +08:00
fredy.siswanto e8df14880d Merge remote-tracking branch 'origin/master' into feature/slicing 2025-02-23 17:03:52 +07:00
Ardeman 91163288db feat: update sidebar navigation to use pathname and add subscriptions layout component 2025-02-23 17:45:46 +08:00
Ardeman 396721fbf2 feat: add multiple admin dashboard layout components and update sidebar and navbar 2025-02-23 17:41:54 +08:00
Ardeman 661baca101 feat: add NotificationIcon component and update admin layout for improved UI 2025-02-23 17:00:17 +08:00
fredy.siswanto 9ef1b2d823 feat: rename success state variables for consistency and clarity in News context 2025-02-23 15:11:35 +07:00
fredy.siswanto a261df3a96 feat: remove unused success popups and implement a unified SuccessModal component 2025-02-23 14:59:26 +07:00
Ardeman 15f8dbb8e2 feat: improve sidebar link styling with transition effects for better user experience 2025-02-23 12:42:17 +08:00
Ardeman cf072255a0 feat: enhance sidebar layout with improved styling and hover effects 2025-02-23 12:39:52 +08:00
Ardeman 48ac1d6f4d feat: add admin layout components including Navbar and Sidebar, and update icon components 2025-02-23 12:18:10 +08:00
Ardeman 47af75ebd3 Merge branch 'feature/slicing'
# Conflicts:
#	app/contexts/news.tsx
#	app/routes/_layout.admin.dashboard._index.tsx
2025-02-23 11:38:30 +08:00
Ardeman 23ac20c017 feat: implement admin dashboard layout and update authentication flow 2025-02-23 10:22:51 +08:00
Ardeman 8b1af335ec feat: add admin and dashboard layouts with context for improved structure and functionality 2025-02-23 10:11:47 +08:00
fredy.siswanto ee663c1bf5 fix: improve modal layout by adding overflow handling and adjusting button visibility 2025-02-22 21:34:09 +07:00
fredy.siswanto 169f264b50 feat: fix conflic 2025-02-22 21:17:56 +07:00
Ardeman 2435f9a5d5 fix: update login text to 'Login form' for clarity 2025-02-22 17:37:25 +08:00
Ardeman 00d385d7ab feat: add AuthLayout component for login page structure 2025-02-22 17:36:51 +08:00
Ardeman 77c9843267 feat: implement news layout components and context for improved structure and functionality 2025-02-22 17:34:25 +08:00
Ardeman d9c36c5ff0 feat: create AuthLayout component and refactor modal usage in forms 2025-02-22 17:14:33 +08:00
Ardeman 0d6f2e300f Merge branch 'feature/slicing' 2025-02-22 16:46:46 +08:00
fredy.siswanto ffc649d06d Merge remote-tracking branch 'origin/master' into feature/slicing 2025-02-22 15:29:49 +07:00
fredy.siswanto ac13e2a854 feat: adj component 2025-02-22 15:15:04 +07:00
Ardeman 9e50f8c7dc feat: enhance PopupModal with improved transition effects and backdrop styling 2025-02-22 16:08:40 +08:00
Ardeman 54faf57262 feat: refactor PopupModal to use Headless UI components and update FormLogin layout 2025-02-22 15:17:46 +08:00
Ardeman 531871d349 chore: update package.json to use minimum version constraints for Node.js and pnpm 2025-02-22 12:07:29 +08:00
Ardeman 293f38b7e4 chore: update Dockerfile and README for pnpm support and Node.js version 2025-02-22 12:03:32 +08:00
ArdemanandGitHub 5a97427756 Merge pull request #4 from ardeman/feature/slicing
Feature/slicing
2025-02-22 11:37:57 +08:00
fredy.siswanto 84e49cd335 feat: create component modal, popup and form input 2025-02-21 20:47:15 +07:00
fredy.siswanto 7c4440cc6b feat: add component form-login
add component form-login
2025-02-21 16:44:51 +07:00
ArdemanandGitHub 8495ae120d Merge pull request #3 from ardeman/feature/slicing
Feature/slicing
2025-02-21 00:32:29 +08:00
fredy.siswanto eafe70a021 feat: adj links kategory 2025-02-20 23:22:04 +07:00
fredy.siswanto 2e09e8a962 feat: adj ui page detail, add breadcom component, add media sosial group 2025-02-20 22:20:27 +07:00
ArdemanandGitHub 3971cbfefc Merge pull request #2 from ardeman/feature/slicing
feat: implement news detail layout and update carousel link
2025-02-20 07:06:11 +08:00
Ardeman 878fb640af feat: implement news detail layout and update carousel link 2025-02-20 07:04:56 +08:00
ArdemanandGitHub cb5255111c Merge pull request #1 from ardeman/feature/slicing
style: slicing home page, adjust for mobile view
2025-02-20 07:02:53 +08:00
Ardeman e73949295d feat: refactor news components and add news detail layout 2025-02-20 07:01:36 +08:00
215 changed files with 12839 additions and 740 deletions
+2
View File
@@ -0,0 +1,2 @@
VITE_API_URL=YOUR_API_URL
VITE_SALT_KEY=YOUR_SALT_KEY
+3
View File
@@ -1,5 +1,8 @@
.DS_Store
/node_modules/
.vscode/cspell.json
.vscode/.cspell/
.env
# React Router
/.react-router/
+4 -3
View File
@@ -25,7 +25,8 @@
"labelClassName",
"buttonClassName",
"leftNodeClassName",
"rightNodeClassName"
],
"cSpell.words": []
"rightNodeClassName",
"buttonVariants",
"cva"
]
}
+14 -10
View File
@@ -1,22 +1,26 @@
FROM node:20-alpine AS development-dependencies-env
FROM node:22.13.1-alpine AS development-dependencies-env
RUN npm install -g pnpm@9.15.4
COPY . /app
WORKDIR /app
RUN npm ci
RUN pnpm install
FROM node:20-alpine AS production-dependencies-env
COPY ./package.json package-lock.json /app/
FROM node:22.13.1-alpine AS production-dependencies-env
RUN npm install -g pnpm@9.15.4
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN npm ci --omit=dev
RUN pnpm install --prod
FROM node:20-alpine AS build-env
FROM node:22.13.1-alpine AS build-env
RUN npm install -g pnpm@9.15.4
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN npm run build
RUN pnpm build
FROM node:20-alpine
COPY ./package.json package-lock.json /app/
FROM node:22.13.1-alpine
RUN npm install -g pnpm@9.15.4
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["npm", "run", "start"]
CMD ["pnpm", "start"]
+29 -33
View File
@@ -1,27 +1,35 @@
# Welcome to React Router!
A modern, production-ready template for building full-stack React applications using React Router.
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
## Features
- 🚀 Server-side rendering
- ⚡️ Hot Module Replacement (HMR)
- 📦 Asset bundling and optimization
- 🔄 Data loading and mutations
- 🔒 TypeScript by default
- 🎉 TailwindCSS for styling
- 📖 [React Router docs](https://reactrouter.com/)
## Getting Started
### Prerequisite
This project requires:
- Node.js version 22.13.1
- pnpm version 9.15.4
To install Node.js, visit [nodejs.org](https://nodejs.org/)
To install pnpm, run:
```bash
npm install -g pnpm@9.15.4
```
### Setup Environtment
You'll need to set the following environment variables to connect to your backend:
```bash
VITE_API_URL: The domain or IP address of your backend server (e.g., http://localhost:3000, http://127.0.0.1:8080, or the actual backend server URL).
VITE_SALT_KEY: The salt key that used for hashing the secret variable.
```
### Installation
Install the dependencies:
```bash
npm install
pnpm install
```
### Development
@@ -29,7 +37,7 @@ npm install
Start the development server with HMR:
```bash
npm run dev
pnpm dev
```
Your application will be available at `http://localhost:5173`.
@@ -39,31 +47,19 @@ Your application will be available at `http://localhost:5173`.
Create a production build:
```bash
npm run build
pnpm build
```
## Deployment
### Docker Deployment
This template includes three Dockerfiles optimized for different package managers:
- `Dockerfile` - for npm
- `Dockerfile.pnpm` - for pnpm
- `Dockerfile.bun` - for bun
To build and run using Docker:
```bash
# For npm
# Build the docker
docker build -t my-app .
# For pnpm
docker build -f Dockerfile.pnpm -t my-app .
# For bun
docker build -f Dockerfile.bun -t my-app .
# Run the container
docker run -p 3000:3000 my-app
```
@@ -81,7 +77,7 @@ The containerized application can be deployed to any platform that supports Dock
If you're familiar with deploying Node applications, the built-in app server is production-ready.
Make sure to deploy the output of `npm run build`
Make sure to deploy the output of `pnpm build`
```
├── package.json
+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)
}
}
+20
View File
@@ -0,0 +1,20 @@
import { z } from 'zod'
import { HttpServer } from '~/libs/http-server'
import type { TLoginSchema } from '~/pages/staff-login'
const loginResponseSchema = z.object({
data: z.object({
token: z.string(),
}),
})
export const staffLoginRequest = async (payload: TLoginSchema) => {
try {
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
return Promise.reject(error)
}
}
+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)
}
}
+69
View File
@@ -1,10 +1,79 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
@import 'tailwindcss';
@plugin "@tailwindcss/typography";
@import 'datatables.net-dt';
/* @import 'datatables.net-responsive-dt';
@import 'datatables.net-select-dt'; */
@theme {
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-admin: 'Poppins', ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
}
html,
body {
}
.ProseMirror-focused {
@apply outline-none;
}
.ProseMirror-trailingBreak {
@apply hidden;
}
table.dataTable thead > tr {
border-bottom: 2px solid #c2c2c2;
}
table.dataTable tbody > tr > td {
border-left: none !important;
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>
)
}
+68
View File
@@ -0,0 +1,68 @@
import {
Description,
Dialog,
DialogBackdrop,
DialogPanel,
DialogTitle,
} from '@headlessui/react'
import type { ReactNode } from 'react'
import { LeftArrow } from '~/components/icons/left-arrow'
import { APP } from '~/configs/meta'
type ModalProperties = {
isOpen: boolean
onClose: () => void
children: ReactNode
description?: string
}
export const DialogNews = ({
isOpen,
onClose,
children,
description,
}: ModalProperties) => {
return (
<Dialog
open={isOpen}
onClose={onClose}
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 flex justify-center">
<button
onClick={onClose}
className="absolute top-0 left-0 items-center sm:hidden"
>
<LeftArrow
width={50}
height={50}
/>
</button>
<img
src={APP.logo}
alt={APP.title}
className="h-[80px]"
/>
</DialogTitle>
{description && (
<Description className="text-center text-[#565658]">
{description}
</Description>
)}
<div className="relative">{children}</div>
</DialogPanel>
</div>
</Dialog>
)
}
+142
View File
@@ -0,0 +1,142 @@
import {
Description,
Dialog,
DialogBackdrop,
DialogPanel,
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
children?: ReactNode
isOpen?: 'error' | 'warning' | 'resetPassword' | 'register' | 'payment'
}
type DescriptionMap = {
[key in Exclude<ModalProperties['isOpen'], undefined>]: string
}
const DESCRIPTIONS: DescriptionMap = {
resetPassword: 'Link Reset Password telah dikirimkan ke email anda',
register: 'Selamat! Pendaftaran anda berhasil!',
payment: 'Selamat! Pembayaran anda berhasil!',
warning:
'Mohon maaf fitur berikut hanya untuk anggota yang sudah tersubscribe',
error: 'Terjadi kesalahan. Silakan coba lagi.',
}
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.'
return (
<Dialog
open={!!isOpen}
onClose={onClose}
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
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
>
<DialogTitle className="relative flex justify-center">
<button
onClick={onClose}
className="absolute top-0 left-0 items-center sm:hidden"
>
<LeftArrow
width={50}
height={50}
/>
</button>
<img
src={APP.logo}
alt={APP.title}
className="h-[80px]"
/>
</DialogTitle>
<Description className="text-center text-[#565658]">
{message}
</Description>
<div className="relative">
<div className="relative flex flex-col items-center justify-center">
{['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>
)}
</>
)}
</div>
</div>
</DialogPanel>
</div>
</Dialog>
)
}
+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
@@ -0,0 +1,23 @@
import type { JSX, SVGProps } from 'react'
export const CloseIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={40}
height={40}
viewBox="0 0 40 40"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M20 23.537l8.838 8.838a2.5 2.5 0 003.537-3.537L23.533 20l8.84-8.838a2.498 2.498 0 000-3.536 2.5 2.5 0 00-3.536 0L20 16.466l-8.838-8.838a2.5 2.5 0 10-3.537 3.533L16.467 20l-8.84 8.84a2.5 2.5 0 103.536 3.533L20 23.537z"
fill="currentColor"
/>
</svg>
)
}
+21
View File
@@ -0,0 +1,21 @@
import type { JSX, SVGProps } from 'react'
export const EyeIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={28}
height={20}
viewBox="0 0 28 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
d="M14 .676C3.823.676.764 9.49.736 9.58L.595 10l.14.422c.03.09 3.088 8.903 13.265 8.903s13.236-8.814 13.264-8.903l.141-.421-.14-.42C27.236 9.49 24.177.675 14 .675zm0 14.652A5.336 5.336 0 018.667 10 5.336 5.336 0 0114 4.672 5.336 5.336 0 0119.333 10 5.336 5.336 0 0114 15.328z"
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>
)
}
+23
View File
@@ -0,0 +1,23 @@
import type { JSX, SVGProps } from 'react'
export const LeftArrow = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
width={100}
height={100}
viewBox="0 0 70 70"
fill="currentColor"
{...properties}
>
<path
d="M1.60494 17.938C1.05815 18.4849 0.750977 19.2266 0.750977 20C0.750977 20.7734 1.05815 21.5152 1.60494 22.0621L18.1045 38.5617C18.6546 39.093 19.3914 39.387 20.1561 39.3803C20.9208 39.3737 21.6524 39.0669 22.1932 38.5262C22.7339 37.9854 23.0407 37.2539 23.0473 36.4891C23.054 35.7244 22.76 34.9876 22.2287 34.4375L10.7079 22.9167H49.3333C50.1068 22.9167 50.8487 22.6094 51.3957 22.0624C51.9426 21.5154 52.2499 20.7736 52.2499 20C52.2499 19.2265 51.9426 18.4846 51.3957 17.9376C50.8487 17.3907 50.1068 17.0834 49.3333 17.0834H10.7079L22.2287 5.56254C22.76 5.01245 23.054 4.27569 23.0473 3.51095C23.0407 2.74621 22.7339 2.01467 22.1932 1.4739C21.6524 0.933127 20.9208 0.626385 20.1561 0.619739C19.3914 0.613094 18.6546 0.907077 18.1045 1.43837L1.60494 17.938Z"
fill="#2E2F7C"
/>
</svg>
)
}
+21
View File
@@ -0,0 +1,21 @@
import type { JSX, SVGProps } from 'react'
export const MenuIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={38}
height={26}
viewBox="0 0 38 26"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
d="M2.333 25.5c-.59 0-1.084-.2-1.483-.6-.399-.4-.599-.894-.6-1.483-.001-.59.199-1.084.6-1.484.401-.4.896-.6 1.483-.6h33.334c.59 0 1.085.2 1.485.6.4.4.6.895.598 1.484-.001.589-.201 1.084-.6 1.485-.399.401-.893.6-1.483.598H2.333zm0-10.417c-.59 0-1.084-.2-1.483-.6-.399-.4-.599-.894-.6-1.483-.001-.589.199-1.083.6-1.483.401-.4.896-.6 1.483-.6h33.334c.59 0 1.085.2 1.485.6.4.4.6.894.598 1.483-.001.589-.201 1.084-.6 1.485-.399.402-.893.601-1.483.598H2.333zm0-10.416c-.59 0-1.084-.2-1.483-.6-.399-.4-.599-.895-.6-1.484C.249 1.994.449 1.5.85 1.1c.401-.4.896-.6 1.483-.6h33.334c.59 0 1.085.2 1.485.6.4.4.6.894.598 1.483-.001.59-.201 1.084-.6 1.486-.399.401-.893.6-1.483.598H2.333z"
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>
)
}
-25
View File
@@ -1,25 +0,0 @@
import { Link } from 'react-router'
import { APP } from '~/data/meta'
export default function Banner() {
return (
<div className="min-h-[65px] bg-amber-400 sm:mx-10">
<div className="relative">
<Link
to="/#"
className="mt-2 h-full py-2"
>
<img
src={'https://placehold.co/1200x70.png'}
alt={APP.title}
className="h-[70px] w-auto sm:h-full"
/>
</Link>
<p className="absolute top-2 mx-10">
Lorem ipsum dolor sit, amet consectetur
</p>
</div>
</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}
/>
)
}
+45 -35
View File
@@ -1,10 +1,7 @@
import { Button as HeadlessButton } from '@headlessui/react'
import { ArrowPathIcon } from '@heroicons/react/24/solid'
import { cva, type VariantProps } from 'class-variance-authority'
import type {
ButtonHTMLAttributes,
HTMLAttributes,
MouseEventHandler,
ReactNode,
} from 'react'
import type { ReactNode, ElementType, ComponentPropsWithoutRef } from 'react'
import { twMerge } from 'tailwind-merge'
const buttonVariants = cva(
@@ -12,62 +9,75 @@ 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: '',
// destructive:
// 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
// outline:
// 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
// secondary:
// 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
// ghost: 'hover:bg-accent hover:text-accent-foreground',
// link: 'text-primary underline-offset-4 hover:underline',
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',
},
},
)
type TProperties = {
type?: ButtonHTMLAttributes<HTMLButtonElement>['type']
onClick?: MouseEventHandler<HTMLButtonElement>
className?: HTMLAttributes<HTMLButtonElement>['className']
disabled?: boolean
type ButtonBaseProperties = {
children: ReactNode
variant?: VariantProps<typeof buttonVariants>['variant']
size?: VariantProps<typeof buttonVariants>['size']
className?: string
isLoading?: boolean
icon?: ReactNode
}
export const Button = (properties: TProperties) => {
type PolymorphicReference<C extends ElementType> =
ComponentPropsWithoutRef<C>['ref']
type ButtonProperties<C extends ElementType> = ButtonBaseProperties & {
as?: C
ref?: PolymorphicReference<C>
} & Omit<ComponentPropsWithoutRef<C>, keyof ButtonBaseProperties>
export const Button = <C extends ElementType = 'button'>(
properties: ButtonProperties<C>,
) => {
const {
type = 'button',
onClick,
className,
disabled,
as,
children,
variant,
size,
className,
isLoading = false,
icon,
...restProperties
} = properties
const Component = as || HeadlessButton
const classes = twMerge(buttonVariants({ variant, size, className }))
return (
<button
type={type}
onClick={onClick}
className={twMerge(buttonVariants({ variant, size, className }))}
disabled={disabled}
<Component
className={classes}
{...restProperties}
>
{isLoading ? <ArrowPathIcon className="size-5 animate-spin" /> : icon}
{children}
</button>
</Component>
)
}
+34
View File
@@ -0,0 +1,34 @@
import type { ComponentType, SVGProps } from 'react'
import { formatNumberWithPeriods } from '~/utils/formatter'
type CardReportProperty = {
title: string
amount: number
currency?: string
icon: ComponentType<SVGProps<SVGSVGElement>>
url?: string
}
export const CardReport = (properties: CardReportProperty) => {
const { title, amount, icon: Icon, currency } = properties
return (
<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-7">
<h2 className="text-lg font-semibold">{title}</h2>
<p className="text-2xl font-bold text-[#2E2F7C]">
{currency
? `${currency} ${formatNumberWithPeriods(amount)}`
: formatNumberWithPeriods(amount)}
</p>
</div>
</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>
)
}
-167
View File
@@ -1,167 +0,0 @@
import { twMerge } from 'tailwind-merge'
import { CarouselNextIcon } from '~/components/icons/carousel-next'
import { CarouselPreviousIcon } from '~/components/icons/carousel-previous'
import type { TNews } from '~/types/news'
import { Button } from './button'
export const Carousel = (properties: TNews) => {
const { title, description, items, type } = properties
return (
<>
{/* <div >
<div className="mb-[30px] flex items-center justify-between border-b border-black pb-[30px]">
<div className="grid">
<h2 className="text-4xl font-extrabold text-[#2E2F7C]">{title}</h2>
<p className="text-2xl font-light text-[#777777] italic">
{description}
</p>
</div>
<div className="flex gap-2.5">
<CarouselPreviousIcon
color="#DCDCDC"
className="cursor-pointer"
/>
<CarouselNextIcon
color="#2E2F7C"
className="cursor-pointer"
/>
</div>
</div>
<div
className={twMerge(
'grid gap-x-8',
type === 'hero' ? 'grid-cols-1' : 'grid-cols-3',
)}
>
{items.map(({ image, title, content }, index) => (
<div
key={index}
className={twMerge(
'grid gap-x-8',
type === 'hero' ? 'grid-cols-3' : '',
)}
>
<img
className={twMerge(
'w-full object-cover',
type === 'hero'
? 'col-span-2 aspect-[174/100]'
: 'aspect-[5/4]',
)}
src={image}
alt={title}
/>
<div
className={twMerge(
'flex flex-col justify-between',
type === 'hero' ? 'gap-7' : 'gap-4',
)}
>
<div>
<h3
className={twMerge(
'font-bold',
type === 'hero' ? 'text-4xl' : 'text-2xl',
)}
>
{title}
</h3>
<p className="text-xl text-[#777777]">{content}</p>
</div>
<Button size="block">View More</Button>
</div>
</div>
))}
</div>
</div> */}
<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">
<CarouselPreviousIcon
color="#DCDCDC"
className="cursor-pointer"
width={45}
height={45}
/>
<CarouselNextIcon
color="#2E2F7C"
className="cursor-pointer"
width={45}
height={45}
/>
</div>
</div>
<div
className={twMerge(
'grid sm:grid sm:gap-x-8',
type === 'hero' ? 'grid-cols-1' : 'sm:grid-cols-3',
)}
>
{items.map(({ image, title, content, tag }, 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={image}
alt={title}
/>
<div
className={twMerge(
'flex flex-col justify-between',
type === 'hero' ? 'gap-7' : 'gap-4',
)}
>
<div className={`${type === 'hero' ? 'hidden' : ''} `}>
{tag?.map((item) => (
<span className="my-3 mr-2 inline-block rounded bg-gray-200 px-3 py-1">
{item}
</span>
))}
</div>
<div>
<h3
className={twMerge(
'mt-2 w-full font-bold lg: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">View More</Button>
</div>
</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>
)
}
+94
View File
@@ -0,0 +1,94 @@
import { Field, Label, Input as HeadlessInput } from '@headlessui/react'
import { useState, type ComponentProps, type ReactNode } from 'react'
import {
get,
type FieldError,
type FieldValues,
type Path,
type RegisterOptions,
} from 'react-hook-form'
import { useRemixFormContext } from 'remix-hook-form'
import { twMerge } from 'tailwind-merge'
import { EyeIcon } from '~/components/icons/eye'
import { Button } from './button'
type TInputProperties<T extends FieldValues> = Omit<
ComponentProps<'input'>,
'size'
> & {
id: string
label?: ReactNode
name: Path<T>
rules?: RegisterOptions
containerClassName?: string
labelClassName?: string
}
export const Input = <TFormValues extends Record<string, unknown>>(
properties: TInputProperties<TFormValues>,
) => {
const {
id,
label,
name,
rules,
type = 'text',
placeholder,
disabled,
className,
containerClassName,
labelClassName,
...restProperties
} = properties
const [inputType, setInputType] = useState(type)
const {
register,
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>
<HeadlessInput
type={inputType}
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)}
{...restProperties}
/>
{type === 'password' && (
<Button
type="button"
variant="icon"
size="fit"
className="absolute right-3 h-[42px]"
onClick={() =>
setInputType(inputType === 'password' ? 'text' : 'password')
}
>
<EyeIcon
className={twMerge(
'size-4',
inputType === 'password' ? 'text-gray-500/50' : 'text-gray-500',
)}
/>
</Button>
)}
</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>
)
}
+36 -22
View File
@@ -1,37 +1,51 @@
import { twMerge } from 'tailwind-merge'
import { Button } from '~/components/ui/button'
import { APP } from '~/data/meta'
type NewsletterProperties = {
className?: string
title?: string
description?: string
textButton?: string
onClick?: () => void
}
export const Newsletter = (property: NewsletterProperties) => {
const { className, title, description } = property
export const UiNewsLetter = () => {
return (
<>
<div className="relative col-span-2 my-5 grid max-h-[400px] gap-y-6 bg-[#2E2F7C] p-5 px-10 text-white sm:grid-cols-2 sm:px-10">
<div className="grid-1">
<div className="relative">
<div
className={`absolute flex h-[400px] w-screen flex-col sm:h-[300px] md:flex-row ${twMerge('', className)}`}
>
<div className="flex h-1/2 w-full flex-col justify-center gap-5 bg-[#2E2F7C] px-6 py-5 text-white sm:h-full sm:w-[50%] sm:px-30">
<h2 className="text-2xl font-bold sm:text-4xl">
Join Our Newsletter
{title ? `${title}` : 'Join Our Newsletter'}
</h2>
<p className="text:md sm:text-lg">
Tidak ingin ketinggalan Berita Hukum terhangat? ingin mendapat
informasi kajian dan networking terbaru? ikuti Newsletter kami and
Stay up to Speed!
{description
? `${description}`
: `Tidak ingin ketinggalan Berita Hukum terhangat?
Ingin mendapat informasi kajian dan networking terbaru?
Ikuti Newsletter kami dan Stay up to Speed!`}
</p>
</div>
<div className="w-max-h-[400px] absolute right-0 bottom-0 w-auto sm:top-0">
<img
src={'https://placehold.co/800x200.png'}
alt={APP.title}
className="h-full w-auto"
/>
</div>
<div className="z-10">
<form className="grid gap-5">
<div
style={{
backgroundImage: "url('/images/bg-newsletter.png')",
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
className="flex h-1/2 w-full flex-col items-center justify-center text-center sm:h-full sm:w-[50%]"
>
<form className="grid w-[50%] gap-5">
<input
type="email"
placeholder="Daftarkan Email Disini"
className="h-[50px] flex-1 bg-white text-center text-lg font-light text-black placeholder:text-[#777777] focus:ring-0 focus:outline-none"
size={1}
className="w-full rounded-md border border-gray-300 bg-white p-4 text-sm focus:outline-none"
/>
<Button
type="submit"
variant="newsPrimary"
variant="primary"
size="block"
>
Subscribe
@@ -39,6 +53,6 @@ export const UiNewsLetter = () => {
</form>
</div>
</div>
</>
</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>
)
}
+79
View File
@@ -0,0 +1,79 @@
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 { LinkedinIcon } from '~/components/icons/linkedin'
import { XIcon } from '~/components/icons/x'
type SocialShareButtonsProperties = {
url: string
title: string
}
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')
}
return (
<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>
)
}
+60
View File
@@ -0,0 +1,60 @@
import DT from 'datatables.net-dt'
import DataTable, { type DataTableProps } from 'datatables.net-react'
import React from 'react'
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 (
<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>
)
}
+13
View File
@@ -0,0 +1,13 @@
type TitleDashboardProperties = {
title: string
}
export const TitleDashboard = (properties: TitleDashboardProperties) => {
const { title } = properties
return (
<div className="container">
<div className="mb-5 flex items-center justify-between">
<h1 className="text-xl font-bold">{title}</h1>
</div>
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
export const USER_COOKIES = {
token: '__lg-usr-tkn',
}
export const STAFF_COOKIES = {
token: '__lg-stf-tkn',
}
+27
View File
@@ -0,0 +1,27 @@
import { MENU as ADMIN_MENU, SUB_MENU } from '~/layouts/admin/menu'
export const APP = {
title: 'LegalGo',
description: 'LegalGo Platform Berita Dunia dan Indonesia.',
logo: '/images/logo.png',
}
type TMetaTitleConfig = {
path: string
title: string
}[]
export const META_TITLE_CONFIG: TMetaTitleConfig = [
{
path: '/',
title: 'Home',
},
{
path: '/lg-admin/login',
title: 'Login',
},
...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',
]
+57
View File
@@ -0,0 +1,57 @@
import {
createContext,
useState,
useContext,
type PropsWithChildren,
type Dispatch,
type SetStateAction,
} from 'react'
import { z } from 'zod'
export const uploadCategorySchema = z
.enum(['featured_image', 'ads', 'content', 'profile_picture'])
.optional()
export type TUpload = z.infer<typeof uploadCategorySchema>
type AdminContextProperties = {
isUploadOpen: TUpload
setIsUploadOpen: Dispatch<SetStateAction<TUpload>>
uploadedFile?: string
setUploadedFile: Dispatch<SetStateAction<string | undefined>>
editProfile: boolean
setEditProfile: Dispatch<SetStateAction<boolean>>
}
const AdminContext = createContext<AdminContextProperties | undefined>(
undefined,
)
export const AdminProvider = ({ children }: PropsWithChildren) => {
const [isUploadOpen, setIsUploadOpen] = useState<TUpload>()
const [uploadedFile, setUploadedFile] = useState<string | undefined>()
const [editProfile, setEditProfile] = useState(false)
return (
<AdminContext.Provider
value={{
isUploadOpen,
setIsUploadOpen,
uploadedFile,
setUploadedFile,
editProfile,
setEditProfile,
}}
>
{children}
</AdminContext.Provider>
)
}
export const useAdminContext = (): AdminContextProperties => {
const context = useContext(AdminContext)
if (!context) {
throw new Error('useAdminContext must be used within a AdminProvider')
}
return context
}
+63
View File
@@ -0,0 +1,63 @@
import {
createContext,
useState,
useContext,
type PropsWithChildren,
type Dispatch,
type SetStateAction,
} from 'react'
import type { ModalProperties } from '~/components/dialog/success'
type NewsContextProperties = {
isLoginOpen: boolean
setIsLoginOpen: Dispatch<SetStateAction<boolean>>
isRegisterOpen: boolean
setIsRegisterOpen: Dispatch<SetStateAction<boolean>>
isForgetOpen: boolean
setIsForgetOpen: Dispatch<SetStateAction<boolean>>
isSuccessOpen: ModalProperties['isOpen']
setIsSuccessOpen: Dispatch<
SetStateAction<ModalProperties['isOpen'] | undefined>
>
isSubscribeOpen: boolean
setIsSubscribeOpen: Dispatch<SetStateAction<boolean>>
}
const NewsContext = createContext<NewsContextProperties | undefined>(undefined)
export const NewsProvider = ({ children }: PropsWithChildren) => {
const [isLoginOpen, setIsLoginOpen] = useState(false)
const [isRegisterOpen, setIsRegisterOpen] = useState(false)
const [isForgetOpen, setIsForgetOpen] = useState(false)
const [isSuccessOpen, setIsSuccessOpen] =
useState<ModalProperties['isOpen']>()
const [isSubscribeOpen, setIsSubscribeOpen] = useState(false)
return (
<NewsContext.Provider
value={{
isLoginOpen,
setIsLoginOpen,
isRegisterOpen,
setIsRegisterOpen,
isForgetOpen,
setIsForgetOpen,
isSuccessOpen,
setIsSuccessOpen,
isSubscribeOpen,
setIsSubscribeOpen,
}}
>
{children}
</NewsContext.Provider>
)
}
export const useNewsContext = (): NewsContextProperties => {
const context = useContext(NewsContext)
if (!context) {
throw new Error('useNewsContext must be used within a NewsProvider')
}
return context
}
-21
View File
@@ -1,21 +0,0 @@
export const APP = {
title: 'LegalGo',
description:
'Bonus judex secundum aequum et\n bonum judicat et aequitatem stricto juri praefert',
logo: '/images/logo.png',
}
export const META_TITLE_CONFIG = [
{
path: '/news',
title: 'Home',
},
{
path: '/auth/login',
title: 'Login',
},
{
path: '/auth/register',
title: 'Register',
},
]
+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])
}
+22
View File
@@ -0,0 +1,22 @@
import type { PropsWithChildren } from 'react'
import { DialogProfile } from './dialog-profile'
import { DialogUpload } from './dialog-upload'
import { Navbar } from './navbar'
import { Sidebar } from './sidebar'
export const AdminDashboardLayout = (properties: PropsWithChildren) => {
const { children } = properties
return (
<div className="flex flex-col">
<Navbar />
<div className="flex">
<Sidebar />
<div className="min-h-[calc(100dvh-80px)] flex-1 p-8">{children}</div>
</div>
<DialogUpload />
<DialogProfile />
</div>
)
}
+12
View File
@@ -0,0 +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>
)
}
+106
View File
@@ -0,0 +1,106 @@
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: ComponentType<SVGProps<SVGSVGElement>>
}[]
}
export const MENU: TMenu[] = [
{
group: 'Menu',
items: [
{
title: 'Dasbor',
url: '/lg-admin',
icon: ChartBarSquareIcon,
},
{
title: 'Pengguna',
url: '/lg-admin/users',
icon: UsersIcon,
},
{
title: 'Artikel',
url: '/lg-admin/contents',
icon: NewspaperIcon,
},
{
title: 'Spanduk Iklan',
url: '/lg-admin/advertisements',
icon: MegaphoneIcon,
},
{
title: 'Pelanggan',
url: '/lg-admin/subscriptions',
icon: PresentationChartLineIcon,
},
],
},
{
group: 'Others',
items: [
{
title: 'Kategori',
url: '/lg-admin/categories',
icon: ClipboardDocumentCheckIcon,
},
{
title: 'Tag',
url: '/lg-admin/tags',
icon: TagIcon,
},
{
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',
},
]
+92
View File
@@ -0,0 +1,92 @@
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 { 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 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">
<Link
to="/"
className="h-full"
>
<img
src={APP.logo}
alt={APP.title}
className="h-3/4 w-auto sm:h-full"
/>
</Link>
<div className="flex items-center gap-x-8">
<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>
)
}
+48
View File
@@ -0,0 +1,48 @@
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">
{MENU.map(({ group, items }) => (
<div
className="flex flex-col"
key={group}
>
<div className="px-5 pb-4 text-[#4C5CA0]/50 uppercase">{group}</div>
{items.map(({ title, url, icon: Icon }) => (
<Link
to={url}
key={`${group}-${title}`}
className={twMerge(
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(
path === url ? 'text-[#5363AB]' : 'text-[#A6ABC8]',
'h-[18px] w-[18px] transition-all group-hover/menu:text-[#5363AB]',
)}
/>
<span
className={twMerge(
path === url ? 'text-[#5363AB]' : 'text-[#273240]',
'text-base transition-all group-hover/menu:text-[#5363AB]',
)}
>
{title}
</span>
</Link>
))}
</div>
))}
</div>
)
}
-88
View File
@@ -1,88 +0,0 @@
import { useState } from 'react'
import { Link } from 'react-router'
import { HeaderSearch } from '~/layouts/header-search'
import { MENU } from './menu'
export default function HeaderMenuMobile() {
const [isMenuOpen, setIsMenuOpen] = useState(false)
const handleToggleMenu = (): void => {
setIsMenuOpen(!isMenuOpen)
}
return (
<>
<div className="relative z-50 flex min-h-[65px] bg-[#2E2F7C] font-[sans-serif] tracking-wide text-white sm:hidden sm:px-10">
<div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-center gap-4 align-middle">
{/* Menu */}
<div
className={`z-50 transition-transform duration-300 max-lg:fixed max-lg:top-0 max-lg:left-0 max-lg:h-full max-lg:w-full max-lg:overflow-auto max-lg:bg-[#2E2F7C] max-lg:p-6 max-lg:shadow-md ${
isMenuOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
{/* Tombol Close */}
<button
onClick={handleToggleMenu}
className="fixed top-2 right-4 z-[100] flex h-9 w-9 items-center justify-center lg:hidden"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-3.5 w-3.5 fill-white"
viewBox="0 0 320.591 320.591"
>
<path d="M30.391 318.583a30.37 30.37 0 0 1-21.56-7.288c-11.774-11.844-11.774-30.973 0-42.817L266.643 10.665c12.246-11.459 31.462-10.822 42.921 1.424 10.362 11.074 10.966 28.095 1.414 39.875L51.647 311.295a30.366 30.366 0 0 1-21.256 7.288z" />
<path d="M287.9 318.583a30.37 30.37 0 0 1-21.257-8.806L8.83 51.963C-2.078 39.225-.595 20.055 12.143 9.146c11.369-9.736 28.136-9.736 39.504 0l259.331 257.813c12.243 11.462 12.876 30.679 1.414 42.922-.456.487-.927.958-1.414 1.414a30.368 30.368 0 0 1-23.078 7.288z" />
</svg>
</button>
{/* List Menu */}
<ul className="mx-10 max-lg:space-y-3 lg:ml-14 lg:flex lg:gap-x-5">
{MENU.map((item) => (
<li className="px-3 max-lg:border-b max-lg:py-3">
<Link
key={item.title}
to={item.url}
className={
'flex h-full items-center justify-center border-white px-[35px] sm:border-r'
}
>
{item.title}
</Link>
</li>
))}
<button className="w-full bg-white px-[35px] py-3 text-center text-[#2E2F7C] sm:hidden">
Akun
</button>
</ul>
</div>
{/* Search dan Toggle Button */}
<div className="align-center flex w-full justify-center">
<button
onClick={handleToggleMenu}
className="h-[63px] border border-white px-4 lg:hidden"
>
<svg
className="h-7 w-7"
fill="#fff"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
clipRule="evenodd"
/>
</svg>
</button>
<div className="w-full py-3">
<HeaderSearch></HeaderSearch>
</div>
</div>
</div>
</div>
</>
)
}
-28
View File
@@ -1,28 +0,0 @@
import { Link } from 'react-router'
import HeaderMenuMobile from '~/layouts/header-menu-mobile'
import { HeaderSearch } from './header-search'
import { MENU } from './menu'
export const HeaderMenu = () => {
return (
<>
<div className="hidden h-[60px] items-center justify-between bg-[#2E2F7C] text-xl font-medium text-white sm:flex">
{MENU.map((item) => (
<Link
key={item.title}
to={item.url}
className={
'flex h-full items-center justify-center border-r border-white px-[35px]'
}
>
{item.title}
</Link>
))}
<HeaderSearch />
</div>
<HeaderMenuMobile />
</>
)
}
-42
View File
@@ -1,42 +0,0 @@
import { Link } from 'react-router'
import { Button } from '~/components/ui/button'
import { APP } from '~/data/meta'
export const HeaderTop = () => {
return (
<div className="flex h-[60px] items-center justify-between bg-white px-5 align-middle sm:h-[100px] sm:gap-[15px] sm:px-[50px] sm:py-[20px]">
<Link
to="/news"
className="mt-2 h-full py-2"
>
<img
src={APP.logo}
alt={APP.title}
className="h-3/4 w-auto sm:h-full"
/>
</Link>
<div className="hidden h-full items-center py-1.5 font-light whitespace-pre-line sm:flex">
{APP.description}
</div>
<div className="flex items-center gap-[15px]">
<Button className="h-8 w-auto rounded-none px-3 text-xs sm:h-[50px] sm:w-[150px] sm:text-lg">
About Us
</Button>
<Button
variant="newsSecondary"
className="hidden sm:block"
>
Akun
</Button>
<div className="w-[50px] sm:w-[60px]">
<img
alt="language"
src="/flags/id.svg"
className="shadow-sm"
/>
</div>
</div>
</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>
)
}
+49
View File
@@ -0,0 +1,49 @@
import { type PropsWithChildren } from 'react'
import { Toaster } from 'react-hot-toast'
import { DialogSuccess } from '~/components/dialog/success'
import { useNewsContext } from '~/contexts/news'
import { Banner } from '~/layouts/news/banner'
import { DialogForgotPassword } from '~/layouts/news/dialog-forgot-password'
import { DialogLogin } from '~/layouts/news/dialog-login'
import { DialogRegister } from './dialog-register'
import { DialogSubscribePlan } from './dialog-subscribe-plan'
import { FooterLinks } from './footer-links'
import { FooterNewsletter } from './footer-newsletter'
import { HeaderMenu } from './header-menu'
import { HeaderTop } from './header-top'
export const NewsDefaultLayout = (properties: PropsWithChildren) => {
const { children } = properties
const { isSuccessOpen, setIsSuccessOpen } = useNewsContext()
return (
<main className="relative min-h-dvh bg-[#ECECEC]">
<header>
<HeaderTop />
<HeaderMenu />
</header>
<div className="sm:mx-[50px] sm:my-[25px] sm:grid sm:gap-y-[25px]">
<Banner />
{children}
</div>
<footer className="grid w-full grid-cols-1 gap-6 bg-[#2E2F7C] px-5 py-20 text-white sm:grid-cols-5 sm:gap-16 sm:px-16">
<FooterNewsletter />
<FooterLinks />
</footer>
<Toaster />
<DialogLogin />
<DialogRegister />
<DialogForgotPassword />
<DialogSubscribePlan />
<DialogSuccess
isOpen={isSuccessOpen}
onClose={() => {
setIsSuccessOpen(undefined)
}}
/>
</main>
)
}
@@ -0,0 +1,49 @@
import { useFetcher } from 'react-router'
import { DialogNews } from '~/components/dialog/news'
import { Button } from '~/components/ui/button'
import { useNewsContext } from '~/contexts/news'
export const DialogForgotPassword = () => {
const { isForgetOpen, setIsForgetOpen } = useNewsContext()
const fetcher = useFetcher()
return (
<DialogNews
isOpen={isForgetOpen}
onClose={() => {
if (fetcher.state === 'idle') {
setIsForgetOpen(false)
}
}}
description="Selamat Datang, silakan isi keterangan akun Anda untuk melanjutkan!"
>
<div className="flex flex-col items-center justify-center">
<div className="w-full max-w-md">
<form>
{/* Input Email / No Telepon */}
<div className="mb-4">
<label
htmlFor="email"
className="mb-1 block text-gray-700"
>
Email/No. Telepon
</label>
<input
type="text"
placeholder="Contoh: legal@legalgo.id"
className="focus:inheriten w-full rounded-md border border-[#DFDFDF] p-2"
required
/>
</div>
{/* Tombol Masuk */}
<Button className="mt-5 w-full rounded-md py-2">
Reset Password
</Button>
</form>
</div>
</div>
</DialogNews>
)
}
+131
View File
@@ -0,0 +1,131 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect } 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 { DialogNews } from '~/components/dialog/news'
import { Button } from '~/components/ui/button'
import { Input } from '~/components/ui/input'
import { useNewsContext } from '~/contexts/news'
export const loginSchema = z.object({
email: z.string().email('Email tidak valid'),
password: z.string().min(6, 'Minimal 6 karakter'),
})
export type TLoginSchema = z.infer<typeof loginSchema>
export const DialogLogin = () => {
const {
setIsRegisterOpen,
setIsLoginOpen,
setIsForgetOpen,
setIsSubscribeOpen,
isLoginOpen,
} = useNewsContext()
const fetcher = useFetcher()
const formMethods = useRemixForm<TLoginSchema>({
mode: 'onSubmit',
fetcher,
resolver: zodResolver(loginSchema),
})
const { handleSubmit } = formMethods
useEffect(() => {
if (!fetcher.data?.success && fetcher.data?.message) {
toast.error(fetcher.data.message)
return
}
if (fetcher.data?.success) {
setIsLoginOpen(false)
}
if (fetcher.data?.user.subscribe?.subscribe_plan?.code === 'basic') {
setIsSubscribeOpen(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetcher.data])
return (
<DialogNews
isOpen={isLoginOpen}
onClose={() => {
if (fetcher.state === 'idle') {
setIsLoginOpen(false)
}
}}
description="Selamat Datang, silakan daftarkan akun Anda untuk melanjutkan!"
>
<div className="flex items-center justify-center">
<div className="w-full max-w-md">
<RemixFormProvider {...formMethods}>
<fetcher.Form
method="post"
onSubmit={handleSubmit}
className="space-y-4"
action="/actions/login"
>
<Input
id="email"
label="Email"
placeholder="Contoh: legal@legalgo.id"
name="email"
/>
<Input
id="password"
label="Kata Sandi"
placeholder="Masukkan Kata Sandi"
name="password"
type="password"
/>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">Lupa Kata Sandi?</span>
<Button
onClick={() => {
setIsLoginOpen(false)
setIsForgetOpen(true)
}}
variant="link"
size="fit"
>
Reset Kata Sandi
</Button>
</div>
<Button
isLoading={fetcher.state !== 'idle'}
disabled={fetcher.state !== 'idle'}
type="submit"
className="w-full rounded-md py-2"
>
Masuk
</Button>
</fetcher.Form>
</RemixFormProvider>
{/* Link Daftar */}
<div className="mt-4 text-center text-sm">
Belum punya akun?{' '}
<Button
onClick={() => {
setIsLoginOpen(false)
setIsRegisterOpen(true)
}}
variant="link"
size="fit"
>
Daftar Disini
</Button>
</div>
</div>
</div>
</DialogNews>
)
}
+159
View File
@@ -0,0 +1,159 @@
import { DevTool } from '@hookform/devtools'
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 { DialogNews } from '~/components/dialog/news'
import { Button } from '~/components/ui/button'
import { Combobox } from '~/components/ui/combobox'
import { Input } from '~/components/ui/input'
import { useNewsContext } from '~/contexts/news'
import type { loader } from '~/routes/_news'
export const registerSchema = z
.object({
email: z.string().email('Email tidak valid'),
password: z.string().min(6, 'Minimal 6 karakter'),
rePassword: z.string().min(6, 'Minimal 6 karakter'),
phone: z.string().min(10, 'No telepon tidak valid'),
subscribe_plan: z
.object({
id: z.string(),
code: z.string(),
name: z.string(),
})
.optional()
.nullable()
.refine((data) => !!data, {
message: 'Pilih paket berlangganan',
}),
})
.refine((field) => field.password === field.rePassword, {
message: 'Kata sandi tidak sama',
path: ['rePassword'],
})
export type TRegisterSchema = z.infer<typeof registerSchema>
export const DialogRegister = () => {
const {
setIsLoginOpen,
setIsRegisterOpen,
setIsSuccessOpen,
isRegisterOpen,
} = useNewsContext()
const fetcher = useFetcher()
const loaderData = useRouteLoaderData<typeof loader>('routes/_news')
const { subscribePlanData: subscribePlan } = loaderData || {}
const formMethods = useRemixForm<TRegisterSchema>({
mode: 'onSubmit',
fetcher,
resolver: zodResolver(registerSchema),
})
const { handleSubmit, control } = formMethods
useEffect(() => {
if (!fetcher.data?.success && fetcher.data?.message) {
toast.error(fetcher.data.message)
}
if (fetcher.data?.success) {
setIsRegisterOpen(false)
setIsSuccessOpen('register')
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetcher.data])
return (
<DialogNews
isOpen={isRegisterOpen}
onClose={() => {
if (fetcher.state === 'idle') {
setIsRegisterOpen(false)
}
}}
description="Selamat Datang, silakan isi keterangan akun Anda untuk melanjutkan!"
>
<div className="flex flex-col items-center justify-center">
<div className="w-full max-w-md">
<RemixFormProvider {...formMethods}>
<fetcher.Form
method="post"
onSubmit={handleSubmit}
className="space-y-4"
action="/actions/register"
>
<Input
id="email"
label="Email"
placeholder="Contoh: legal@legalgo.id"
name="email"
/>
<Input
id="password"
label="Kata Sandi"
placeholder="Masukkan Kata Sandi"
name="password"
type="password"
/>
<Input
id="re-password"
label="Ulangi Kata Sandi"
placeholder="Masukkan Kata Sandi"
name="rePassword"
type="password"
/>
<Input
id="phone"
label="No. Telepon"
placeholder="Masukkan No. Telepon"
name="phone"
/>
<Combobox
id="subscribe_plan"
name="subscribe_plan"
label="Paket Berlangganan"
placeholder="Pilih Paket Berlangganan"
options={subscribePlan}
/>
<Button
isLoading={fetcher.state !== 'idle'}
disabled={fetcher.state !== 'idle'}
type="submit"
className="w-full rounded-md py-2"
>
Daftar
</Button>
</fetcher.Form>
</RemixFormProvider>
{/* Link Login */}
<div className="mt-4 text-center text-sm">
Sudah punya akun?{' '}
<Button
onClick={() => {
setIsLoginOpen(true)
setIsRegisterOpen(false)
}}
variant="link"
size="fit"
>
Masuk Disini
</Button>
</div>
</div>
<DevTool control={control} />
</div>
</DialogNews>
)
}
@@ -0,0 +1,96 @@
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 { DialogNews } from '~/components/dialog/news'
import { Button } from '~/components/ui/button'
import { Combobox } from '~/components/ui/combobox'
import { useNewsContext } from '~/contexts/news'
import type { loader } from '~/routes/_news'
export const subscribeSchema = z.object({
subscribe_plan: z
.object({
id: z.string(),
code: z.string(),
name: z.string(),
})
.optional()
.nullable()
.refine((data) => !!data, {
message: 'Silakan pilih paket berlangganan',
}),
})
export type TSubscribeSchema = z.infer<typeof subscribeSchema>
export const DialogSubscribePlan = () => {
const { setIsSubscribeOpen, setIsSuccessOpen, isSubscribeOpen } =
useNewsContext()
const fetcher = useFetcher()
const loaderData = useRouteLoaderData<typeof loader>('routes/_news')
const { subscribePlanData: subscribePlan } = loaderData || {}
const formMethods = useRemixForm<TSubscribeSchema>({
mode: 'onSubmit',
fetcher,
resolver: zodResolver(subscribeSchema),
})
const { handleSubmit } = formMethods
useEffect(() => {
if (!fetcher.data?.success && fetcher.data?.message) {
toast.error(fetcher.data.message)
}
if (fetcher.data?.success) {
setIsSubscribeOpen(false)
setIsSuccessOpen('payment')
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetcher.data])
return (
<DialogNews
isOpen={isSubscribeOpen}
onClose={() => {
if (fetcher.state === 'idle') {
setIsSubscribeOpen(false)
}
}}
description="Selamat Datang, silakan Pilih Paket Berlangganan Anda untuk melanjutkan!"
>
<div className="flex flex-col items-center justify-center">
<RemixFormProvider {...formMethods}>
<fetcher.Form
method="post"
onSubmit={handleSubmit}
className="w-full max-w-md"
action="/actions/subscribe"
>
<Combobox
id="subscribe_plan"
name="subscribe_plan"
label="Paket Berlangganan"
placeholder="Pilih Paket Berlangganan"
options={subscribePlan}
/>
<Button
isLoading={fetcher.state !== 'idle'}
disabled={fetcher.state !== 'idle'}
type="submit"
className="mt-5 w-full rounded-md py-2"
>
Lanjutkan
</Button>
</fetcher.Form>
</RemixFormProvider>
</div>
</DialogNews>
)
}
@@ -1,6 +1,6 @@
import { Link } from 'react-router'
import { APP } from '~/data/meta'
import { APP } from '~/configs/meta'
import { COPYRIGHT_MENU, FOOTER_MENU } from './menu'
@@ -1,7 +1,7 @@
import { Link } from 'react-router'
import { Button } from '~/components/ui/button'
import { APP } from '~/data/meta'
import { APP } from '~/configs/meta'
export const FooterNewsletter = () => {
return (
@@ -9,7 +9,7 @@ export const FooterNewsletter = () => {
<div className="col-span-2 hidden gap-y-6 sm:grid">
<div className="h-[75px] bg-white p-3">
<Link
to="/news"
to="/"
className="h-full"
>
<img
@@ -34,7 +34,7 @@ export const FooterNewsletter = () => {
/>
<Button
type="submit"
variant="newsPrimaryOutline"
variant="primaryOutline"
size="block"
>
Subscribe
@@ -44,7 +44,7 @@ export const FooterNewsletter = () => {
<div className="block sm:hidden">
<div className="h-[60px] bg-white p-3">
<Link
to="/news"
to="/"
className="h-full"
>
<img

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