Compare commits

..
178 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
186 changed files with 6292 additions and 2927 deletions
+3 -1
View File
@@ -25,6 +25,8 @@
"labelClassName",
"buttonClassName",
"leftNodeClassName",
"rightNodeClassName"
"rightNodeClassName",
"buttonVariants",
"cva"
]
}
+34
View File
@@ -0,0 +1,34 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TAdsSchema } from '~/pages/form-advertisements'
import { datePayload } from '~/utils/formatter'
const advertisementsResponseSchema = z.object({
data: z.object({
Message: z.string(),
}),
})
type TParameters = {
payload: TAdsSchema
} & THttpServer
export const createAdsRequest = async (parameters: TParameters) => {
const { payload, ...restParameters } = parameters
const transformedPayload = {
...payload,
start_date: datePayload(payload.start_date),
end_date: datePayload(payload.end_date),
}
try {
const { data } = await HttpServer(restParameters).post(
'/api/ads/create',
transformedPayload,
)
return advertisementsResponseSchema.parse(data)
} catch (error) {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.reject(error)
}
}
+4 -5
View File
@@ -1,6 +1,6 @@
import { z } from 'zod'
import { HttpServer } from '~/libs/http-server'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TCategorySchema } from '~/pages/form-category'
const categoryResponseSchema = z.object({
@@ -10,14 +10,13 @@ const categoryResponseSchema = z.object({
})
type TParameters = {
accessToken: string
payload: TCategorySchema
}
} & THttpServer
export const createCategoryRequest = async (parameters: TParameters) => {
const { accessToken, payload } = parameters
const { payload, ...restParameters } = parameters
try {
const { data } = await HttpServer({ accessToken }).post(
const { data } = await HttpServer(restParameters).post(
'/api/category/create',
payload,
)
+8 -8
View File
@@ -1,7 +1,8 @@
import { z } from 'zod'
import { HttpServer } from '~/libs/http-server'
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({
@@ -10,21 +11,20 @@ const newsResponseSchema = z.object({
})
type TParameter = {
accessToken: string
payload: TContentSchema
}
} & THttpServer
export const createNewsRequest = async (parameters: TParameter) => {
const { accessToken, payload } = parameters
try {
const { categories, tags, ...restPayload } = payload
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: new Date(payload?.live_at).toISOString(),
live_at: datePayload(live_at),
}
const { data } = await HttpServer({ accessToken }).post(
try {
const { data } = await HttpServer(restParameters).post(
'/api/news/create',
transformedPayload,
)
+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)
}
}
+5 -11
View File
@@ -1,6 +1,6 @@
import { z } from 'zod'
import { HttpServer } from '~/libs/http-server'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TTagSchema } from '~/pages/form-tag'
const tagsResponseSchema = z.object({
@@ -10,21 +10,15 @@ const tagsResponseSchema = z.object({
})
type TParameters = {
accessToken: string
payload: TTagSchema
}
} & THttpServer
export const createTagsRequest = async (parameters: TParameters) => {
const { accessToken, payload } = parameters
const { payload, ...restParameters } = parameters
try {
const { ...restPayload } = payload
const transformedPayload = {
...restPayload,
}
const { data } = await HttpServer({ accessToken }).post(
const { data } = await HttpServer(restParameters).post(
'/api/tag/create',
transformedPayload,
payload,
)
return tagsResponseSchema.parse(data)
} catch (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)
}
}
@@ -11,7 +11,7 @@ const staffResponseSchema = z.object({
}),
})
export const getStaff = async (parameters: THttpServer) => {
export const getProfile = async (parameters: THttpServer) => {
try {
const { data } = await HttpServer(parameters).get(`/api/staff/profile`)
return staffResponseSchema.parse(data)
+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)
}
}
+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)
}
}
+5 -6
View File
@@ -1,6 +1,6 @@
import { z } from 'zod'
import { HttpServer } from '~/libs/http-server'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TCategorySchema } from '~/pages/form-category'
const categoryResponseSchema = z.object({
@@ -10,15 +10,14 @@ const categoryResponseSchema = z.object({
})
type TParameters = {
accessToken: string
payload: TCategorySchema
}
} & THttpServer
export const updateCategoryRequest = async (parameters: TParameters) => {
const { accessToken, payload } = parameters
try {
const { payload, ...restParameters } = parameters
const { id, ...restPayload } = payload
const { data } = await HttpServer({ accessToken }).put(
try {
const { data } = await HttpServer(restParameters).put(
`/api/category/${id}/update`,
restPayload,
)
+8 -8
View File
@@ -1,7 +1,8 @@
import { z } from 'zod'
import { HttpServer } from '~/libs/http-server'
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({
@@ -10,21 +11,20 @@ const newsResponseSchema = z.object({
})
type TParameter = {
accessToken: string
payload: TContentSchema
}
} & THttpServer
export const updateNewsRequest = async (parameters: TParameter) => {
const { accessToken, payload } = parameters
try {
const { categories, tags, id, ...restPayload } = payload
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: new Date(payload?.live_at).toISOString(),
live_at: datePayload(live_at),
}
const { data } = await HttpServer({ accessToken }).put(
try {
const { data } = await HttpServer(restParameters).put(
`/api/news/${id}/update`,
transformedPayload,
)
+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)
}
}
+5 -6
View File
@@ -1,6 +1,6 @@
import { z } from 'zod'
import { HttpServer } from '~/libs/http-server'
import { HttpServer, type THttpServer } from '~/libs/http-server'
import type { TTagSchema } from '~/pages/form-tag'
const tagResponseSchema = z.object({
@@ -10,15 +10,14 @@ const tagResponseSchema = z.object({
})
type TParameters = {
accessToken: string
payload: TTagSchema
}
} & THttpServer
export const updateTagRequest = async (parameters: TParameters) => {
const { accessToken, payload } = parameters
try {
const { payload, ...restParameters } = parameters
const { id, ...restPayload } = payload
const { data } = await HttpServer({ accessToken }).put(
try {
const { data } = await HttpServer(restParameters).put(
`/api/tag/${id}/update`,
restPayload,
)
+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)
}
}
@@ -25,15 +25,39 @@ export const newsResponseSchema = z.object({
author: authorSchema,
})
const dataResponseSchema = z.object({
data: z.array(newsResponseSchema),
data: z.array(
newsResponseSchema.extend({
views: z.number(),
}),
),
})
export type TNewsResponse = z.infer<typeof newsResponseSchema>
export type TAuthor = z.infer<typeof authorSchema>
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: THttpServer) => {
export const getNews = async (parameters?: TParameters) => {
const { categories, tags, active, limit, page, query, ...restParameters } =
parameters || {}
try {
const { data } = await HttpServer(parameters).get(`/api/news`)
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
+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)
}
}
-23
View File
@@ -1,23 +0,0 @@
import { z } from 'zod'
import { HttpServer, type THttpServer } from '~/libs/http-server'
const subscriptionResponseSchema = z.object({
data: z.array(
z.object({
id: z.string(),
code: z.string(),
name: z.string(),
}),
),
})
export const getSubscriptions = async (parameters?: THttpServer) => {
try {
const { data } = await HttpServer(parameters).get(`/api/subscribe-plan`)
return subscriptionResponseSchema.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)
}
}
@@ -1,6 +1,6 @@
import { z } from 'zod'
import { newsResponseSchema } from '~/apis/admin/get-news'
import { newsResponseSchema } from '~/apis/common/get-news'
import { HttpServer, type THttpServer } from '~/libs/http-server'
const dataResponseSchema = z.object({
@@ -12,9 +12,11 @@ type TParameters = {
} & THttpServer
export const getNewsBySlug = async (parameters: TParameters) => {
const { slug, accessToken } = parameters
const { slug, ...restParameters } = parameters
try {
const { data } = await HttpServer({ accessToken }).get(`/api/news/${slug}`)
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
+2 -2
View File
@@ -11,8 +11,8 @@ const userResponseSchema = z.object({
id: z.string(),
subscribe_plan_id: z.string(),
start_date: z.string(),
end_date: z.string(),
status: z.string(),
end_date: z.string().nullable(),
status: z.number(),
auto_renew: z.boolean(),
subscribe_plan: z.object({
id: z.string(),
+1 -1
View File
@@ -1,6 +1,6 @@
import { z } from 'zod'
import { type TLoginSchema } from '~/layouts/news/form-login'
import { type TLoginSchema } from '~/layouts/news/dialog-login'
import { HttpServer } from '~/libs/http-server'
export const loginResponseSchema = z.object({
+2 -2
View File
@@ -1,15 +1,15 @@
import type { TRegisterSchema } from '~/layouts/news/form-register'
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) => {
try {
const { subscribe_plan, ...restPayload } = payload
const transformedPayload = {
...restPayload,
subscribe_plan_id: subscribe_plan.id,
}
try {
const { data } = await HttpServer().post(
'/api/user/register',
transformedPayload,
+4
View File
@@ -23,6 +23,10 @@ body {
@apply outline-none;
}
.ProseMirror-trailingBreak {
@apply hidden;
}
table.dataTable thead > tr {
border-bottom: 2px solid #c2c2c2;
}
+85
View File
@@ -0,0 +1,85 @@
import {
Description,
Dialog,
DialogBackdrop,
DialogPanel,
DialogTitle,
} from '@headlessui/react'
import { useEffect, type PropsWithChildren } from 'react'
import toast from 'react-hot-toast'
import { useFetcher } from 'react-router'
import { Button } from '~/components/ui/button'
type TProperties = PropsWithChildren & {
selectedId?: string
close: () => void
title: string
fetcherAction: string
}
export const DialogDelete = (properties: TProperties) => {
const { selectedId, close, children, title, fetcherAction } = properties || {}
const fetcher = useFetcher()
useEffect(() => {
if (!fetcher.data?.success && fetcher.data?.message) {
toast.error(fetcher.data.message)
}
if (fetcher.data?.success) {
close()
toast.success(`${title} berhasil dihapus!`)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetcher.data])
return (
<Dialog
open={!!selectedId}
onClose={() => {
if (fetcher.state === 'idle') {
close()
}
}}
className="relative z-50"
transition
>
<DialogBackdrop
className="fixed inset-0 bg-black/50 duration-300 ease-out data-[closed]:opacity-0"
transition
/>
<div className="fixed inset-0 flex w-screen justify-center overflow-y-auto p-0 max-sm:bg-white sm:items-center sm:p-4">
<DialogPanel
transition
className="max-w-lg space-y-6 rounded-lg bg-white p-8 duration-300 ease-out data-[closed]:scale-95 data-[closed]:opacity-0 sm:shadow-lg"
>
<DialogTitle className="relative text-xl font-bold">
<span>Anda akan menghapus</span>{' '}
<span className="lowercase">{title}</span> <span>berikut?</span>
</DialogTitle>
<Description className="space-y-1 text-center text-[#565658]">
{children}
</Description>
<div className="flex justify-end">
<fetcher.Form
method="POST"
action={fetcherAction}
className="grid"
>
<Button
type="submit"
variant="danger"
className="text-md h-[42px] rounded-md"
disabled={fetcher.state !== 'idle'}
isLoading={fetcher.state !== 'idle'}
>
Hapus
</Button>
</fetcher.Form>
</div>
</DialogPanel>
</div>
</Dialog>
)
}
@@ -17,7 +17,7 @@ type ModalProperties = {
description?: string
}
export const PopupModal = ({
export const DialogNews = ({
isOpen,
onClose,
children,
@@ -33,10 +33,10 @@ const DESCRIPTIONS: DescriptionMap = {
error: 'Terjadi kesalahan. Silakan coba lagi.',
}
export const SuccessModal = ({ isOpen, onClose }: ModalProperties) => {
export const DialogSuccess = ({ isOpen, onClose }: ModalProperties) => {
const { setIsLoginOpen, setIsSubscribeOpen } = useNewsContext()
const loaderData = useRouteLoaderData<typeof loader>('routes/_news')
const userData = loaderData?.userData
const { userData } = loaderData || {}
const message = isOpen
? DESCRIPTIONS[isOpen]
@@ -92,7 +92,7 @@ export const SuccessModal = ({ isOpen, onClose }: ModalProperties) => {
/>
<Button
className="mt-5 w-full rounded-md"
variant="newsPrimary"
variant="primary"
as={Link}
to="/"
onClick={onClose}
@@ -111,18 +111,18 @@ export const SuccessModal = ({ isOpen, onClose }: ModalProperties) => {
{userData ? (
<Button
className="mt-5 w-full rounded-md"
variant="newsSecondary"
variant="outline"
onClick={() => {
onClose()
setIsSubscribeOpen(true)
}}
>
Select Subscription
Pilih Paken Berlangganan
</Button>
) : (
<Button
className="mt-5 w-full rounded-md"
variant="newsPrimary"
variant="primary"
onClick={() => {
onClose()
setIsLoginOpen(true)
+10
View File
@@ -0,0 +1,10 @@
import { useAsyncError } from 'react-router'
export const ErrorAwait = () => {
const error = useAsyncError()
return (
<p>
{error instanceof Error ? error.message : 'An unexpected error occurred.'}
</p>
)
}
-23
View File
@@ -1,23 +0,0 @@
import type { JSX, SVGProps } from 'react'
export const ChartIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={18}
height={19}
viewBox="0 0 18 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.498 1.61h7.004c2.55 0 3.99 1.447 3.998 3.998v7.005c0 2.55-1.447 3.997-3.998 3.997H5.498c-2.551 0-3.998-1.447-3.998-3.997V5.608c0-2.55 1.447-3.998 3.998-3.998zm3.539 11.895a.62.62 0 00.623-.562V5.3a.612.612 0 00-.285-.592.63.63 0 00-.96.592v7.643a.632.632 0 00.622.562zm3.45 0c.316 0 .585-.24.623-.562v-2.46a.629.629 0 00-.96-.593.604.604 0 00-.285.593v2.46c.03.322.3.562.623.562zm-6.322-.562a.62.62 0 01-.623.562.62.62 0 01-.622-.562V7.76a.63.63 0 01.293-.592.617.617 0 01.66 0c.202.127.315.36.292.592v5.183z"
fill="currentColor"
/>
</svg>
)
}
-23
View File
@@ -1,23 +0,0 @@
import type { JSX, SVGProps } from 'react'
export const ChatIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={18}
height={19}
viewBox="0 0 18 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M1.5 9.122C1.5 5.17 4.657 1.61 9.015 1.61c4.26 0 7.485 3.493 7.485 7.49 0 4.633-3.78 7.51-7.5 7.51-1.23 0-2.595-.33-3.69-.976-.382-.233-.705-.406-1.117-.27l-1.515.45c-.383.12-.728-.18-.615-.586l.502-1.682a.786.786 0 00-.052-.676C1.868 11.683 1.5 10.384 1.5 9.122zm6.525 0c0 .533.427.961.96.969a.963.963 0 000-1.923.956.956 0 00-.96.954zm3.457.007c0 .526.428.962.96.962a.963.963 0 000-1.923.958.958 0 00-.96.961zm-5.954.962a.967.967 0 01-.96-.962c0-.533.427-.961.96-.961.532 0 .96.428.96.961a.967.967 0 01-.96.962z"
fill="currentColor"
/>
</svg>
)
}
-28
View File
@@ -1,28 +0,0 @@
import type { JSX, SVGProps } from 'react'
/**
* Note: `ChevronDoubleIcon` default mengarah ke kiri.
* Gunakan class `rotate-xx` untuk mengubah arah ikon.
*/
export const ChevronDoubleIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={20}
height={20}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={properties.className}
{...properties}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.512 4.427l-2.984 3.58 2.877 3.575a.667.667 0 01-1.04.836l-3.218-4a.667.667 0 01.007-.844l3.334-4a.667.667 0 011.024.853zm-5.69-.853a.667.667 0 011.023.853l-2.984 3.58 2.877 3.575a.667.667 0 01-1.039.836l-3.218-4a.666.666 0 01.007-.844l3.333-4z"
fill="currentColor"
/>
</svg>
)
}
-25
View File
@@ -1,25 +0,0 @@
import type { JSX, SVGProps } from 'react'
/**
* Note: `ChevronIcon` default mengarah ke bawah.
* Gunakan class `rotate-xx` untuk mengubah arah ikon.
*/
export const ChevronIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={21}
height={21}
viewBox="0 0 21 21"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={properties.className}
{...properties}
>
<path
d="M10.197 13.623l5.008-5.008-1.177-1.18-3.83 3.834-3.831-3.833-1.178 1.178 5.008 5.009z"
fill="currentColor"
/>
</svg>
)
}
-21
View File
@@ -1,21 +0,0 @@
import type { JSX, SVGProps } from 'react'
export const DoctorIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={25}
height={28}
viewBox="0 0 25 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
d="M12.714 14.364a6.821 6.821 0 006.822-6.822A6.821 6.821 0 0012.714.72a6.822 6.822 0 00-6.822 6.822 6.822 6.822 0 006.822 6.822zM6.32 23.318c0 .708.57 1.279 1.279 1.279s1.279-.57 1.279-1.28c0-.708-.57-1.278-1.28-1.278-.708 0-1.278.57-1.278 1.279zM17.83 16.1v2.612a4.27 4.27 0 013.41 4.178v2.223a.855.855 0 01-.687.837l-1.716.34a.424.424 0 01-.501-.335l-.165-.837a.422.422 0 01.336-.5l1.028-.209v-1.519c0-3.347-5.116-3.47-5.116.102v1.423l1.028.207c.23.048.379.272.336.502l-.165.836a.432.432 0 01-.501.336l-1.663-.224a.852.852 0 01-.736-.847V22.89a4.274 4.274 0 013.412-4.178v-2.41c-.118.038-.235.06-.352.102a9.244 9.244 0 01-3.06.522c-1.07 0-2.1-.186-3.059-.522a5.889 5.889 0 00-1.204-.277v4.349a2.974 2.974 0 012.132 2.846 2.987 2.987 0 01-2.985 2.985 2.987 2.987 0 01-2.985-2.985c0-1.348.901-2.478 2.132-2.846v-4.285c-3.39.57-5.974 3.49-5.974 7.04v2.388a2.39 2.39 0 002.387 2.388h19.102a2.39 2.39 0 002.388-2.388v-2.388c0-3.837-3.028-6.944-6.822-7.13z"
fill="currentColor"
/>
</svg>
)
}
-23
View File
@@ -1,23 +0,0 @@
import type { JSX, SVGProps } from 'react'
export const DocumentIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={18}
height={19}
viewBox="0 0 18 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.857 1.61h6.286c2.317 0 3.607 1.335 3.607 3.623v7.747c0 2.325-1.29 3.63-3.607 3.63H5.857c-2.28 0-3.607-1.305-3.607-3.63V5.233c0-2.288 1.328-3.623 3.607-3.623zm.203 3.495v-.007h2.242a.588.588 0 010 1.178H6.06a.585.585 0 010-1.17zm0 4.56h5.88a.586.586 0 000-1.17H6.06a.586.586 0 000 1.17zm0 3.428h5.88c.3-.03.525-.286.525-.585a.588.588 0 00-.525-.593H6.06a.596.596 0 00-.563.908c.12.187.338.3.563.27z"
fill="currentColor"
/>
</svg>
)
}
-21
View File
@@ -1,21 +0,0 @@
import type { JSX, SVGProps } from 'react'
export const GraphIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={29}
height={28}
viewBox="0 0 29 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
d="M19.383 2.499H9.471c-4.306 0-6.872 2.496-6.872 6.682v9.625c0 4.197 2.566 6.693 6.872 6.693h9.9c4.306 0 6.873-2.495 6.873-6.681V9.18c.012-4.186-2.555-6.682-6.86-6.682zM9.258 21.071c0 .472-.402.863-.887.863s-.887-.391-.887-.863v-2.38c0-.471.402-.863.887-.863s.887.392.887.863v2.38zm6.056 0c0 .472-.402.863-.887.863s-.887-.391-.887-.863V16.3c0-.471.402-.862.887-.862s.887.39.887.862v4.773zm6.057 0c0 .472-.403.863-.888.863s-.887-.391-.887-.863v-7.153c0-.471.402-.862.887-.862s.888.391.888.863v7.152zm0-10.787c0 .472-.403.863-.888.863s-.887-.391-.887-.863V9.17a23.266 23.266 0 01-11.012 6.164c-.071.023-.142.023-.213.023a.891.891 0 01-.864-.655.859.859 0 01.651-1.047 21.515 21.515 0 0010.35-5.876H17.03c-.485 0-.887-.391-.887-.863 0-.471.402-.862.887-.862h3.466c.048 0 .083.023.13.023.06.011.119.011.178.034.059.023.106.058.165.092.036.023.071.035.107.058.012.011.012.023.024.023.047.046.082.092.118.138.035.046.07.08.083.126.023.046.023.092.035.15.012.057.036.115.036.184 0 .011.011.023.011.034v3.37h-.011z"
fill="currentColor"
/>
</svg>
)
}
+19
View File
@@ -0,0 +1,19 @@
import type { JSX, SVGProps } from 'react'
export const ImageSkeletonIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={40}
height={40}
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 20 18"
{...properties}
>
<path d="M18 0H2a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2Zm-5.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm4.376 10.481A1 1 0 0 1 16 15H4a1 1 0 0 1-.895-1.447l3.5-7A1 1 0 0 1 7.468 6a.965.965 0 0 1 .9.5l2.775 4.757 1.546-1.887a1 1 0 0 1 1.618.1l2.541 4a1 1 0 0 1 .028 1.011Z" />
</svg>
)
}
-21
View File
@@ -1,21 +0,0 @@
import type { JSX, SVGProps } from 'react'
export const MedicalNotesIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={12}
height={16}
viewBox="0 0 12 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
d="M9.844 1.985H7.5C7.5.951 6.66.11 5.625.11 4.591.11 3.75.951 3.75 1.985H1.406C.63 1.985 0 2.615 0 3.392v10.312c0 .776.63 1.406 1.406 1.406h8.438c.776 0 1.406-.63 1.406-1.406V3.392c0-.777-.63-1.407-1.406-1.407zm-4.219-.703c.39 0 .703.314.703.703 0 .39-.313.703-.703.703a.701.701 0 01-.703-.703c0-.39.313-.703.703-.703zm2.813 8.906a.235.235 0 01-.235.235h-1.64v1.64a.235.235 0 01-.235.235H4.922a.235.235 0 01-.234-.235v-1.64H3.046a.235.235 0 01-.235-.235V8.782c0-.129.106-.234.235-.234h1.64v-1.64c0-.13.106-.235.235-.235h1.406c.129 0 .234.105.234.234v1.64h1.641c.129 0 .235.106.235.235v1.406zm0-5.625a.235.235 0 01-.235.235H3.047a.235.235 0 01-.235-.235v-.468c0-.13.106-.235.235-.235h5.156c.129 0 .235.106.235.235v.468z"
fill="currentColor"
/>
</svg>
)
}
-20
View File
@@ -1,20 +0,0 @@
import type { JSX, SVGProps } from 'react'
export const PlusIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={14}
height={14}
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
d="M6 8H1a.965.965 0 01-.712-.288A.972.972 0 010 7c0-.283.095-.52.288-.712A.97.97 0 011 6h5V1c0-.283.096-.52.288-.712A.972.972 0 017 0c.283 0 .52.095.713.288A.96.96 0 018 1v5h5c.283 0 .521.096.713.288.192.192.288.43.287.712 0 .283-.097.52-.288.713A.957.957 0 0113 8H8v5a.968.968 0 01-.288.713A.964.964 0 017 14a.973.973 0 01-.712-.288A.965.965 0 016 13V8z"
fill="currentColor"
/>
</svg>
)
}
-23
View File
@@ -1,23 +0,0 @@
import type { JSX, SVGProps } from 'react'
export const ProfileIcon = (
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) => {
return (
<svg
width={18}
height={19}
viewBox="0 0 18 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...properties}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.97 5.82A3.956 3.956 0 019 9.789a3.956 3.956 0 01-3.97-3.97A3.955 3.955 0 019 1.853a3.955 3.955 0 013.97 3.968zM9 16.852c-3.253 0-6-.53-6-2.57s2.764-2.55 6-2.55c3.254 0 6 .529 6 2.569s-2.764 2.55-6 2.55z"
fill="currentColor"
/>
</svg>
)
}
+4 -3
View File
@@ -1,3 +1,4 @@
import { Button } from '@headlessui/react'
import type { CSSProperties, MouseEventHandler, ReactNode } from 'react'
import { twMerge } from 'tailwind-merge'
@@ -16,12 +17,12 @@ export const EditorButton = (properties: TProperties) => {
properties
return (
<button
<Button
type="button"
onClick={onClick}
disabled={disabled}
className={twMerge(
'flex h-6 w-8 cursor-pointer items-center justify-center rounded-md p-2 hover:bg-[#2E2F7C] hover:text-white disabled:cursor-not-allowed disabled:bg-[#2E2F7C]/50 disabled:text-white disabled:hover:bg-[#2E2F7C]/50',
'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,
)}
@@ -29,6 +30,6 @@ export const EditorButton = (properties: TProperties) => {
title={title}
>
{children}
</button>
</Button>
)
}
+128 -117
View File
@@ -1,11 +1,12 @@
import { Input } from '@headlessui/react'
import {
ArrowUturnLeftIcon,
ArrowUturnRightIcon,
Bars3BottomLeftIcon,
Bars3BottomRightIcon,
Bars3Icon,
Bars4Icon,
BoldIcon,
CloudArrowUpIcon,
CodeBracketIcon,
DocumentTextIcon,
H1Icon,
@@ -19,7 +20,12 @@ import {
PhotoIcon,
StrikethroughIcon,
SwatchIcon,
} from '@heroicons/react/20/solid'
XCircleIcon,
} from '@heroicons/react/24/solid'
import {
Bars3BottomCenterIcon,
QuotationMarkIcon,
} from '@sidekickicons/react/24/solid'
import type { Editor } from '@tiptap/react'
import {
type SetStateAction,
@@ -27,9 +33,12 @@ import {
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'
@@ -43,16 +52,30 @@ type TProperties = {
}
export const EditorMenuBar = (properties: TProperties) => {
const {
editor,
setIsPlainHTML,
// category,
disabled = false,
} = properties
// const [isOpenImage, setIsOpenImage] = useState(false)
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), [])
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)
@@ -96,20 +119,7 @@ export const EditorMenuBar = (properties: TProperties) => {
}
}
const uploadEnabled = false
const toggleUpload = () => {
if (uploadEnabled) {
// setIsOpenImage(true)
} else {
const urlImage = globalThis.prompt('URL')
if (urlImage) {
addImage(urlImage)
}
}
}
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">
@@ -121,7 +131,7 @@ export const EditorMenuBar = (properties: TProperties) => {
isActive={editor.isActive('bold')}
title="Bold"
>
<BoldIcon />
<BoldIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().toggleItalic().run()}
@@ -131,7 +141,7 @@ export const EditorMenuBar = (properties: TProperties) => {
isActive={editor.isActive('italic')}
title="Italic"
>
<ItalicIcon />
<ItalicIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().toggleStrike().run()}
@@ -141,7 +151,7 @@ export const EditorMenuBar = (properties: TProperties) => {
isActive={editor.isActive('strike')}
title="Strike"
>
<StrikethroughIcon />
<StrikethroughIcon className="size-4" />
</EditorButton>
<div className="relative">
<EditorButton
@@ -153,7 +163,7 @@ export const EditorMenuBar = (properties: TProperties) => {
isActive={true}
disabled={disabled}
>
<SwatchIcon />
<SwatchIcon className="size-4" />
</EditorButton>
{isOpenColor && (
<div
@@ -161,7 +171,7 @@ export const EditorMenuBar = (properties: TProperties) => {
ref={popover}
>
<HexColorPicker
className="z-10"
className="z-10 rounded-lg shadow"
color={rgbColor}
onChange={handleChangeColor}
/>
@@ -170,7 +180,7 @@ export const EditorMenuBar = (properties: TProperties) => {
color={rgbColor}
onChange={handleChangeColor}
prefixed
className="relative z-10 mt-1 flex w-full rounded-lg border px-3 py-2 text-sm focus:ring-0 focus-visible:outline-0"
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>
@@ -185,12 +195,10 @@ export const EditorMenuBar = (properties: TProperties) => {
isActive={editor.isActive({ textAlign: 'left' })}
title="Align Left"
>
<Bars3BottomLeftIcon />
<Bars3BottomLeftIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() =>
editor.chain().focus().setTextAlign('center').run()
}
onClick={() => editor.chain().focus().setTextAlign('center').run()}
disabled={
disabled ||
!editor.can().chain().focus().setTextAlign('center').run()
@@ -198,7 +206,7 @@ export const EditorMenuBar = (properties: TProperties) => {
isActive={editor.isActive({ textAlign: 'center' })}
title="Align Center"
>
<Bars3Icon />
<Bars3BottomCenterIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().setTextAlign('right').run()}
@@ -209,12 +217,10 @@ export const EditorMenuBar = (properties: TProperties) => {
isActive={editor.isActive({ textAlign: 'right' })}
title="Align Right"
>
<Bars3BottomRightIcon />
<Bars3BottomRightIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() =>
editor.chain().focus().setTextAlign('justify').run()
}
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
disabled={
disabled ||
!editor.can().chain().focus().setTextAlign('justify').run()
@@ -222,7 +228,7 @@ export const EditorMenuBar = (properties: TProperties) => {
isActive={editor.isActive({ textAlign: 'justify' })}
title="Align Justify"
>
<Bars4Icon />
<Bars3Icon className="size-4" />
</EditorButton>
</div>
<div className="flex max-w-[150px] flex-wrap items-start gap-1 px-1">
@@ -234,7 +240,7 @@ export const EditorMenuBar = (properties: TProperties) => {
title="Heading 1"
disabled={disabled}
>
<H1Icon />
<H1Icon className="size-4" />
</EditorButton>
<EditorButton
onClick={() =>
@@ -244,7 +250,7 @@ export const EditorMenuBar = (properties: TProperties) => {
title="Heading 2"
disabled={disabled}
>
<H2Icon />
<H2Icon className="size-4" />
</EditorButton>
<EditorButton
onClick={() =>
@@ -254,31 +260,7 @@ export const EditorMenuBar = (properties: TProperties) => {
title="Heading 3"
disabled={disabled}
>
<H3Icon />
</EditorButton>
{/* <EditorButton
onClick={() => editor.chain().focus().setParagraph().run()}
isActive={editor.isActive('paragraph')}
title="Paragraph"
disabled={disabled}
>
<RiParagraph />
</EditorButton> */}
<EditorButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
title="Bullet List"
disabled={disabled}
>
<ListBulletIcon />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
title="Ordered List"
disabled={disabled}
>
<NumberedListIcon />
<H3Icon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
@@ -286,33 +268,39 @@ export const EditorMenuBar = (properties: TProperties) => {
title="Code Block"
disabled={disabled}
>
<CodeBracketIcon />
<CodeBracketIcon className="size-4" />
</EditorButton>
</div>
{/* <div className="flex items-start gap-1 px-1">
<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}
>
<RiDoubleQuotesL />
</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 />
<QuotationMarkIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => {
@@ -322,17 +310,63 @@ export const EditorMenuBar = (properties: TProperties) => {
title="Clear Format"
disabled={disabled}
>
<RiFormatClear />
<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={() => toggleUpload()}
onClick={() => setIsOpenImage(true)}
title="Insert Image"
disabled={disabled}
>
<PhotoIcon />
<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={
@@ -348,14 +382,14 @@ export const EditorMenuBar = (properties: TProperties) => {
isActive={editor.isActive('link')}
title="Set Link"
>
<LinkIcon />
<LinkIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().unsetLink().run()}
disabled={disabled || !editor.isActive('link')}
title="Unset Link"
>
<LinkSlashIcon />
<LinkSlashIcon className="size-4" />
</EditorButton>
</div>
<div className="flex items-start gap-1 px-1">
@@ -364,14 +398,14 @@ export const EditorMenuBar = (properties: TProperties) => {
disabled={disabled || !editor.can().chain().focus().undo().run()}
title="Undo"
>
<ArrowUturnLeftIcon />
<ArrowUturnLeftIcon className="size-4" />
</EditorButton>
<EditorButton
onClick={() => editor.chain().focus().redo().run()}
disabled={disabled || !editor.can().chain().focus().redo().run()}
title="Redo"
>
<ArrowUturnRightIcon />
<ArrowUturnRightIcon className="size-4" />
</EditorButton>
</div>
</div>
@@ -381,33 +415,10 @@ export const EditorMenuBar = (properties: TProperties) => {
onClick={() => setIsPlainHTML(true)}
title="Switch to Plain Text"
>
<DocumentTextIcon />
<DocumentTextIcon className="size-4" />
</EditorButton>
</div>
</div>
</div>
{/* <Dialog
isOpen={isOpenImage}
setIsOpen={setIsOpenImage}
title="Insert Image"
showCloseButton={true}
>
<UploadProvider
data={{
onCancel: () => setIsOpenImage(false),
onSave: (file) => {
addImage(file)
setIsOpenImage(false)
},
category: category,
maxFileSize: 300,
selectedFile: '',
}}
>
<Upload />
</UploadProvider>
</Dialog> */}
</>
)
}
@@ -1,4 +1,4 @@
import { CodeBracketSquareIcon } from '@heroicons/react/20/solid'
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'
+13 -15
View File
@@ -8,22 +8,16 @@ 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 FieldValues,
type Path,
type RegisterOptions,
} from 'react-hook-form'
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<TFormValues extends FieldValues> = {
type TProperties = {
id?: string
name: Path<TFormValues>
name: string
label?: string
placeholder?: string
labelClassName?: string
@@ -36,9 +30,7 @@ type TProperties<TFormValues extends FieldValues> = {
category: string
}
export const TextEditor = <TFormValues extends Record<string, unknown>>(
properties: TProperties<TFormValues>,
) => {
export const TextEditor = (properties: TProperties) => {
const {
id,
label,
@@ -67,7 +59,13 @@ export const TextEditor = <TFormValues extends Record<string, unknown>>(
const editor = useEditor({
editable: !disabled,
extensions: [
StarterKit,
StarterKit.configure({
paragraph: {
HTMLAttributes: {
class: 'min-h-1',
},
},
}),
Highlight,
Image.configure({
inline: true,
@@ -86,7 +84,7 @@ export const TextEditor = <TFormValues extends Record<string, unknown>>(
immediatelyRender: false,
content: watchContent,
onUpdate: ({ editor }) => {
setValue(name, editor.getHTML() as any) // eslint-disable-line @typescript-eslint/no-explicit-any
setValue(name, editor.getHTML() as string)
},
})
useEffect(() => {
@@ -134,7 +132,7 @@ export const TextEditor = <TFormValues extends Record<string, unknown>>(
editor={editor}
id={id ?? generatedId}
className={twMerge(
'prose prose-headings:my-0 prose-p:my-0 max-h-96 max-w-none cursor-text overflow-y-auto rounded-b-md p-2',
'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()}
-39
View File
@@ -1,39 +0,0 @@
import useEmblaCarousel from 'embla-carousel-react'
import { Link } from 'react-router'
import { BANNER } from '~/data/contents'
export const Banner = () => {
const [emblaReference] = useEmblaCarousel({ loop: false })
return (
<div className="">
<div className="relative">
<div
className="embla overflow-hidden"
ref={emblaReference}
>
<div className="embla__container flex">
{BANNER.map(({ urlImage, alt, link }, index) => (
<div
key={index}
className="embla__slide max-h-[100px] min-h-[65px] w-full min-w-0 flex-none"
>
<Link
to={link}
className="mt-2 h-full py-2"
>
<img
src={urlImage}
alt={alt}
className="h-[70px] w-[100%] content-center sm:h-full"
/>
</Link>
</div>
))}
</div>
</div>
</div>
</div>
)
}
+26 -10
View File
@@ -1,4 +1,5 @@
import { Button as HeadlessButton } from '@headlessui/react'
import { ArrowPathIcon } from '@heroicons/react/24/solid'
import { cva, type VariantProps } from 'class-variance-authority'
import type { ReactNode, ElementType, ComponentPropsWithoutRef } from 'react'
import { twMerge } from 'tailwind-merge'
@@ -8,23 +9,30 @@ const buttonVariants = cva(
{
variants: {
variant: {
newsPrimary: 'bg-[#2E2F7C] text-white text-lg',
newsPrimaryOutline: 'border-[3px] border-white text-white text-lg',
newsSecondary: 'border-[3px] border-[#2E2F7C] text-[#2E2F7C] text-lg',
primary:
'bg-[#2E2F7C] text-white text-lg hover:bg-[#4C5CA0] hover:shadow transition active:bg-[#6970B4]',
danger:
'bg-[#EF4444] text-white text-lg hover:shadow transition active:bg-[#FEE2E2] hover:bg-[#FCA5A5]',
primaryOutline:
'border-[3px] bg-[#2E2F7C] border-white text-white text-lg hover:bg-[#4C5CA0] hover:shadow-lg active:shadow-2xl transition active:bg-[#6970B4]',
outline:
'border-[3px] bg-white hover:shadow-lg active:shadow-2xl border-[#2E2F7C] text-[#2E2F7C] hover:text-[#4C5CA0] active:text-[#6970B4] text-lg hover:border-[#4C5CA0] transition active:border-[#6970B4]',
icon: '',
link: '',
link: 'font-semibold text-[#2E2F7C] hover:text-[#4C5CA0] active:text-[#6970B4] transition',
secondary:
'hover:bg-[#707FDD]/10 active:bg-[#707FDD]/20 hover:text-[#707FDD] text-[#273240]',
},
size: {
default: 'h-[50px] w-[150px]',
block: 'h-[50px] w-full',
icon: 'h-9 w-9',
icon: 'size-9 rounded-full',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
fit: 'w-fit',
},
},
defaultVariants: {
variant: 'newsPrimary',
variant: 'primary',
size: 'default',
},
},
@@ -35,6 +43,8 @@ type ButtonBaseProperties = {
variant?: VariantProps<typeof buttonVariants>['variant']
size?: VariantProps<typeof buttonVariants>['size']
className?: string
isLoading?: boolean
icon?: ReactNode
}
type PolymorphicReference<C extends ElementType> =
@@ -45,22 +55,28 @@ type ButtonProperties<C extends ElementType> = ButtonBaseProperties & {
ref?: PolymorphicReference<C>
} & Omit<ComponentPropsWithoutRef<C>, keyof ButtonBaseProperties>
export const Button = <C extends ElementType = 'button'>({
export const Button = <C extends ElementType = 'button'>(
properties: ButtonProperties<C>,
) => {
const {
as,
children,
variant,
size,
className,
...properties
}: ButtonProperties<C>) => {
isLoading = false,
icon,
...restProperties
} = properties
const Component = as || HeadlessButton
const classes = twMerge(buttonVariants({ variant, size, className }))
return (
<Component
className={classes}
{...properties}
{...restProperties}
>
{isLoading ? <ArrowPathIcon className="size-5 animate-spin" /> : icon}
{children}
</Component>
)
+4 -12
View File
@@ -1,4 +1,4 @@
import type { JSX } from 'react'
import type { ComponentType, SVGProps } from 'react'
import { formatNumberWithPeriods } from '~/utils/formatter'
@@ -6,17 +6,14 @@ type CardReportProperty = {
title: string
amount: number
currency?: string
icon: (
properties: React.JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>,
) => JSX.Element
icon: ComponentType<SVGProps<SVGSVGElement>>
url?: string
counter?: number[]
}
export const CardReport = (properties: CardReportProperty) => {
const { title, amount, icon: Icon, counter, currency } = properties
const { title, amount, icon: Icon, currency } = properties
return (
<div className="rounded bg-white px-4 py-6 shadow-sm">
<div className="rounded-xl bg-white px-4 py-6 shadow-sm">
<div className="flex items-center">
<Icon
className="ml-2 rounded-xl bg-[#2E2F7C] p-2 text-white"
@@ -32,11 +29,6 @@ export const CardReport = (properties: CardReportProperty) => {
</p>
</div>
</div>
{counter?.length && (
<div className="flex items-center pt-2">
Pribadi: {counter[0]} | Perusahaan: {counter[1]}
</div>
)}
</div>
)
}
+2 -2
View File
@@ -3,11 +3,11 @@ import type { ComponentProps, FC, PropsWithChildren } from 'react'
type TProperties = PropsWithChildren<ComponentProps<'div'>>
export const Card: FC<TProperties> = (properties) => {
const { children, ...rest } = properties
const { children, ...restProperties } = properties
return (
<div
className="border-[.2px] border-black/20 bg-white p-[30px]"
{...rest}
{...restProperties}
>
{children}
</div>
+58 -14
View File
@@ -1,18 +1,22 @@
import useEmblaCarousel from 'embla-carousel-react'
import { useCallback, useEffect, useState } from 'react'
import { useRouteLoaderData } from 'react-router'
import { Suspense, useCallback, useEffect, useState } from 'react'
import { Await, useRouteLoaderData } from 'react-router'
import { stripHtml } from 'string-strip-html'
import { Button } from '~/components/ui/button'
import { ErrorAwait } from '~/components/error/await'
import { ImageSkeletonIcon } from '~/components/icons/image-skeleton'
import { CarouselButton } from '~/components/ui/button-slide'
import { useNewsContext } from '~/contexts/news'
import type { loader } from '~/routes/_news'
import type { TNews } from '~/types/news'
import { getPremiumAttribute } from '~/utils/render'
import { Button } from './button'
export const CarouselHero = (properties: TNews) => {
const { setIsSuccessOpen } = useNewsContext()
const loaderData = useRouteLoaderData<typeof loader>('routes/_news')
const userData = loaderData?.userData
const { userData } = loaderData || {}
const { title, description, items } = properties
const [emblaReference, emblaApi] = useEmblaCarousel({ loop: false })
@@ -71,15 +75,52 @@ export const CarouselHero = (properties: TNews) => {
ref={emblaReference}
>
<div className="embla__container hero flex sm:gap-x-8">
{items.map(({ featured, title, content, slug, isPremium }, index) => (
<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"
className="embla__slide hero w-full min-w-0 flex-none max-sm:mt-2 sm:flex"
key={index}
>
<div className="max-sm:mt-2 sm:flex">
<img
className="col-span-2 aspect-[174/100] object-cover"
src={featured}
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">
@@ -87,14 +128,14 @@ export const CarouselHero = (properties: TNews) => {
<h3 className="mt-2 w-full text-2xl font-bold sm:mt-0 sm:text-4xl">
{title}
</h3>
<p className="text-md mt-5 text-[#777777] sm:text-xl">
{content}
<p className="text-md mt-5 line-clamp-10 text-[#777777] sm:text-xl">
{stripHtml(content).result}
</p>
</div>
<Button
size="block"
{...getPremiumAttribute({
isPremium,
isPremium: is_premium,
slug,
onClick: () => setIsSuccessOpen('warning'),
userData,
@@ -104,8 +145,11 @@ export const CarouselHero = (properties: TNews) => {
</Button>
</div>
</div>
</div>
))}
),
)
}
</Await>
</Suspense>
</div>
</div>
</div>
+60 -34
View File
@@ -1,18 +1,23 @@
import useEmblaCarousel from 'embla-carousel-react'
import { useCallback, useEffect, useState } from 'react'
import { useRouteLoaderData } from 'react-router'
import { Suspense, useCallback, useEffect, useState } from 'react'
import { Await, useRouteLoaderData } from 'react-router'
import { stripHtml } from 'string-strip-html'
import { Button } from '~/components/ui/button'
import { ErrorAwait } from '~/components/error/await'
import { ImageSkeletonIcon } from '~/components/icons/image-skeleton'
import { CarouselButton } from '~/components/ui/button-slide'
import { useNewsContext } from '~/contexts/news'
import type { loader } from '~/routes/_news'
import type { TNews } from '~/types/news'
import { getPremiumAttribute } from '~/utils/render'
import { Button } from './button'
import { Tags } from './tags'
export const CarouselSection = (properties: TNews) => {
const { setIsSuccessOpen } = useNewsContext()
const loaderData = useRouteLoaderData<typeof loader>('routes/_news')
const userData = loaderData?.userData
const { userData } = loaderData || {}
const { title, description, items } = properties
const [emblaReference, emblaApi] = useEmblaCarousel({
loop: false,
@@ -76,60 +81,81 @@ export const CarouselSection = (properties: TNews) => {
ref={emblaReference}
>
<div className="embla__container col-span-3 flex max-h-[586px] sm:gap-x-8">
{items.map(
({ featured, title, content, tags, slug, isPremium }, index) => (
<Suspense
fallback={Array.from({ length: 3 }).map((_, index) => (
<div
className="embla__slide w-full min-w-0 flex-none sm:w-1/3"
key={index}
className="embla__slide flex w-full min-w-0 flex-none animate-pulse flex-col justify-between gap-3 sm:w-1/3"
>
<div className="flex 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}
>
<div className="flex flex-col justify-between">
<img
className="aspect-[174/100] max-h-[280px] w-full rounded-md object-cover sm:aspect-[5/4]"
src={featured}
src={featured_image}
alt={title}
/>
<div className={'flex flex-col justify-between gap-4'}>
<div className={'flex text-sm uppercase'}>
{tags?.map((item) => (
<span
key={index}
className="my-3 mr-2 inline-block rounded bg-[#F4F4F4] px-3 py-1 font-bold text-[#777777]"
>
{item}
</span>
))}
{isPremium && (
<span className="my-3 mr-2 inline-block rounded bg-[#D1C675] px-3 py-1 font-bold text-[#9D761D]">
Premium Content
</span>
)}
</div>
<div>
<h3 className="mt-2 w-full text-xl font-bold sm:text-2xl lg:mt-0">
<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>
<p className="text-md mt-5 text-[#777777] sm:text-xl">
{content}
</p>
</div>
<p className="line-clamp-3 text-base text-[#777777] sm:text-xl">
{stripHtml(content).result}
</p>
<Button
size="block"
{...getPremiumAttribute({
isPremium,
isPremium: is_premium,
slug,
onClick: () => setIsSuccessOpen('warning'),
userData,
})}
className="mb-5"
>
View More
</Button>
</div>
</div>
</div>
),
)}
)
}
</Await>
</Suspense>
</div>
</div>
</div>
+58 -33
View File
@@ -1,18 +1,24 @@
import { useRouteLoaderData } from 'react-router'
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?.userData
const { userData } = loaderData || {}
const { title, description, items } = properties
@@ -38,39 +44,55 @@ export const CategorySection = (properties: TNews) => {
</div>
<div className="grid sm:grid-cols-3 sm:gap-x-8">
{items.map(
({ featured, title, content, tags, slug, isPremium }, index) => (
<Suspense
fallback={Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className={twMerge('grid sm:gap-x-8')}
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={twMerge(
'aspect-[174/100] w-full rounded-md object-cover sm:aspect-[5/4]',
)}
src={featured}
className="aspect-[174/100] w-full rounded-md object-cover sm:aspect-[5/4]"
src={featured_image}
alt={title}
/>
<div className={twMerge('flex flex-col justify-between gap-4')}>
<div
className={twMerge(
'my-3 flex gap-2 uppercase max-sm:text-sm',
)}
>
{tags?.map((item) => (
<span
key={index}
className="inline-block rounded bg-[#F4F4F4] px-3 py-1 font-bold text-[#777777]"
>
{item}
</span>
))}
{isPremium && (
<span className="inline-block rounded bg-[#D1C675] px-3 py-1 font-bold text-[#9D761D]">
Premium Content
</span>
)}
</div>
<div className="flex flex-col justify-between gap-4">
<Tags
tags={tags}
is_premium={is_premium}
/>
<div>
<h3
@@ -80,14 +102,14 @@ export const CategorySection = (properties: TNews) => {
>
{title}
</h3>
<p className="text-md mt-5 text-[#777777] sm:text-xl">
{content}
<p className="text-md mt-5 line-clamp-3 text-[#777777] sm:text-xl">
{stripHtml(content).result}
</p>
</div>
<Button
size="block"
{...getPremiumAttribute({
isPremium,
isPremium: is_premium,
slug,
onClick: () => setIsSuccessOpen('warning'),
userData,
@@ -99,7 +121,10 @@ export const CategorySection = (properties: TNews) => {
</div>
</div>
),
)}
)
}
</Await>
</Suspense>
</div>
<div className="my-5 mt-5 flex flex-row-reverse">
+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'
}
+4 -4
View File
@@ -7,7 +7,7 @@ import {
ComboboxOptions,
ComboboxOption,
} from '@headlessui/react'
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/20/solid'
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/solid'
import { useState, type ComponentProps, type ReactNode } from 'react'
import {
get,
@@ -53,7 +53,7 @@ export const Combobox = <TFormValues extends Record<string, unknown>>(
className,
labelClassName,
containerClassName,
...rest
...restProperties
} = properties
const {
control,
@@ -88,7 +88,7 @@ export const Combobox = <TFormValues extends Record<string, unknown>>(
onChange={field.onChange}
disabled={disabled}
immediate
{...rest}
{...restProperties}
>
<div className="relative">
<ComboboxInput
@@ -96,7 +96,7 @@ export const Combobox = <TFormValues extends Record<string, unknown>>(
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',
'focus:inheriten h-[42px] w-full rounded-md border border-[#DFDFDF] p-2 placeholder:text-inherit',
className,
)}
/>
+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>
)
}
+5 -4
View File
@@ -40,7 +40,7 @@ export const Input = <TFormValues extends Record<string, unknown>>(
className,
containerClassName,
labelClassName,
...rest
...restProperties
} = properties
const [inputType, setInputType] = useState(type)
@@ -65,24 +65,25 @@ export const Input = <TFormValues extends Record<string, unknown>>(
className={twMerge(
'h-[42px] w-full rounded-md border border-[#DFDFDF] p-2',
className,
type === 'password' ? 'pr-8' : '',
)}
placeholder={inputType === 'password' ? '******' : placeholder}
{...register(name, rules)}
{...rest}
{...restProperties}
/>
{type === 'password' && (
<Button
type="button"
variant="icon"
size="fit"
className="absolute right-3 h-[42px] text-gray-500"
className="absolute right-3 h-[42px]"
onClick={() =>
setInputType(inputType === 'password' ? 'text' : 'password')
}
>
<EyeIcon
className={twMerge(
'h-4 w-4',
'size-4',
inputType === 'password' ? 'text-gray-500/50' : 'text-gray-500',
)}
/>
+7 -9
View File
@@ -1,9 +1,8 @@
import type { TAuthor } from '~/apis/admin/get-news'
import { ProfileIcon } from '~/components/icons/profile'
import type { TAuthorResponse } from '~/apis/common/get-news'
import { formatDate } from '~/utils/formatter'
type TDetailNewsAuthor = {
author?: TAuthor
author?: TAuthorResponse
live_at?: string
text?: string
}
@@ -11,15 +10,14 @@ type TDetailNewsAuthor = {
export const NewsAuthor = ({ author, live_at, text }: TDetailNewsAuthor) => {
return (
<div className="mb-2 flex items-center gap-2 align-middle">
{author?.profile_picture ? (
<img
src={author?.profile_picture}
src={author?.profile_picture || '/images/profile-placeholder.svg'}
onError={(event) => {
event.currentTarget.src = '/images/profile-placeholder.svg'
}}
alt={author?.name}
className="h-12 w-12 rounded-full bg-[#C4C4C4] object-cover"
className="size-12 rounded-full bg-[#C4C4C4] object-cover"
/>
) : (
<ProfileIcon className="h-12 w-12 rounded-full bg-[#C4C4C4]" />
)}
<div>
<h4 className="text-md">{author?.name}</h4>
+1 -1
View File
@@ -45,7 +45,7 @@ export const Newsletter = (property: NewsletterProperties) => {
/>
<Button
type="submit"
variant="newsPrimary"
variant="primary"
size="block"
>
Subscribe
-66
View File
@@ -1,66 +0,0 @@
import React from 'react'
import { ChevronIcon } from '~/components/icons/chevron'
import { ChevronDoubleIcon } from '~/components/icons/chevron-double'
type PaginationProperties = {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
}
export const Pagination: React.FC<PaginationProperties> = ({
currentPage = 1,
totalPages,
onPageChange,
}) => {
const renderPageNumbers = () => {
const pages = []
for (let index = 1; index <= totalPages; index++) {
pages.push(
<button
key={index}
onClick={() => onPageChange(index)}
className={`rounded-lg px-3 py-1 ${
currentPage === index ? 'bg-[#2E2F7C] text-white' : 'text-gray-500'
}`}
>
{index}
</button>,
)
}
return pages
}
return (
<div className="flex items-center justify-center space-x-2 text-[#2E2F7C]">
<button
onClick={() => onPageChange(1)}
disabled={currentPage === 1}
>
<ChevronDoubleIcon />
</button>
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronIcon className="rotate-90" />
</button>
{renderPageNumbers()}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
<ChevronIcon className="rotate-270" />
</button>
<button
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}
>
<ChevronDoubleIcon className="rotate-180" />
</button>
</div>
)
}
+98
View File
@@ -0,0 +1,98 @@
import { Field, Label, Select as HeadlessSelect } from '@headlessui/react'
import { type ComponentProps, type ReactNode } from 'react'
import {
get,
type FieldError,
type FieldValues,
type Path,
type RegisterOptions,
Controller,
} from 'react-hook-form'
import { useRemixFormContext } from 'remix-hook-form'
import { twMerge } from 'tailwind-merge'
type TSelectProperties<T extends FieldValues> = Omit<
ComponentProps<'select'>,
'size'
> & {
id: string
label?: ReactNode
name: Path<T>
rules?: RegisterOptions
containerClassName?: string
labelClassName?: string
placeholder?: string
options?: {
name: string
value: string | number
}[]
}
export const Select = <TFormValues extends Record<string, unknown>>(
properties: TSelectProperties<TFormValues>,
) => {
const {
id,
label,
name,
rules,
disabled,
placeholder,
options,
className,
labelClassName,
containerClassName,
...restProperties
} = properties
const {
control,
formState: { errors },
} = useRemixFormContext()
const error: FieldError = get(errors, name)
return (
<Field
className={twMerge('relative', containerClassName)}
disabled={disabled}
id={id}
>
<Label className={twMerge('mb-1 block text-gray-700', labelClassName)}>
{label} {error && <span className="text-red-500">{error.message}</span>}
</Label>
<Controller
name={name}
control={control}
rules={rules}
render={({ field }) => (
<HeadlessSelect
value={field.value}
onChange={field.onChange}
disabled={disabled}
className={twMerge(
'h-[42px] w-full rounded-md border border-[#DFDFDF] p-2',
className,
)}
{...restProperties}
>
<option
value=""
disabled
selected={!field.value}
>
{placeholder}
</option>
{options?.map(({ value, name }) => (
<option
key={value}
value={value}
>
{name}
</option>
))}
</HeadlessSelect>
)}
/>
</Field>
)
}
+16 -9
View File
@@ -1,4 +1,4 @@
import { LinkIcon } from '@heroicons/react/20/solid'
import { LinkIcon } from '@heroicons/react/24/solid'
import { useState } from 'react'
import {
FacebookShareButton,
@@ -35,37 +35,44 @@ export const SocialShareButtons = ({
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-0 rounded-lg border-2 border-gray-400 bg-white p-2 shadow-lg">
<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 onClick={handleCopyLink}>
<LinkIcon className="h-8 w-8 rounded-full bg-gray-400 p-2 sm:h-10 sm:w-10" />
</button>
<FacebookShareButton
url={url}
title={title}
>
<FacebookIcon className="h-8 w-8 rounded-full bg-gray-400 p-2 sm:h-10 sm:w-10" />
<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="h-8 w-8 rounded-full bg-gray-400 p-2 sm:h-10 sm:w-10" />
<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="h-8 w-8 rounded-full bg-gray-400 p-2 sm:h-10 sm:w-10" />
<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}>
<InstagramIcon className="h-8 w-8 rounded-full bg-gray-400 p-2 sm:h-10 sm:w-10" />
<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>
)
+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>
)
}
+9 -10
View File
@@ -1,14 +1,10 @@
import DT, { type Config, type ConfigColumns } from 'datatables.net-dt'
import DataTable from 'datatables.net-react'
import DT from 'datatables.net-dt'
import DataTable, { type DataTableProps } from 'datatables.net-react'
import React from 'react'
type UiTableProperties = {
data: any // eslint-disable-line @typescript-eslint/no-explicit-any
columns: ConfigColumns[]
slots?: any // eslint-disable-line @typescript-eslint/no-explicit-any
options?: Config
title: string
}
} & DataTableProps
const renderPaginationIcon = (icon: string) => {
return `<div class="pagination-icon">${icon}</div>`
@@ -27,15 +23,18 @@ export const UiTable: React.FC<UiTableProperties> = ({
<h3 className="py-1 font-semibold text-[#4C5CA0]">{title}</h3>
<div className="rounded-lg">
<DataTable
className="cell-border"
className="cell-border text-sm"
data={data}
columns={columns}
slots={slots}
options={{
headerCallback: (thead) => {
thead.classList.add('text-left')
},
paging: true,
searching: true,
searching: false,
ordering: true,
info: true,
info: false,
language: {
paginate: {
first: renderPaginationIcon(
+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>
)
}
+2 -1
View File
@@ -1,4 +1,4 @@
import { MENU as ADMIN_MENU } from '~/layouts/admin/menu'
import { MENU as ADMIN_MENU, SUB_MENU } from '~/layouts/admin/menu'
export const APP = {
title: 'LegalGo',
@@ -23,4 +23,5 @@ export const META_TITLE_CONFIG: TMetaTitleConfig = [
...ADMIN_MENU.flatMap((menu) =>
menu.items.map((item) => ({ path: item.url, title: item.title })),
),
...SUB_MENU,
]
+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
}
+1 -1
View File
@@ -7,7 +7,7 @@ import {
type SetStateAction,
} from 'react'
import type { ModalProperties } from '~/components/popup/success-modal'
import type { ModalProperties } from '~/components/dialog/success'
type NewsContextProperties = {
isLoginOpen: boolean
-139
View File
@@ -1,139 +0,0 @@
import type { TNews } from '~/types/news'
type TBanner = {
id: number
urlImage: string
alt: string
link: string
status: 'active' | 'draft' | 'inactive'
createdAt?: string
}
export const DUMMY_DESCRIPTION = 'Berita Terhangat hari ini'
export const SPOTLIGHT: TNews = {
title: 'SPOTLIGHT',
description: DUMMY_DESCRIPTION,
items: [
{
title: '01 Hotman Paris Membuka Perpustakaan di tengah Diskotik',
content:
'Pengacara Kondang, Hotman Paris Hutapea, membuka sebuah perpustakaan baru di dalam diskotik nya yang berlokasi di daerah Jakarta Pusat, Hotman berkata Perpustakaan ini dibuka dengan harapan untuk meningkatkan gairah membaca masyarakat Indonesia, namun sayangnya..',
featured: '/images/news-1.jpg',
slug: 'hotman-paris-membuka-perpustakaan-di-tengah-diskotik',
},
{
title: '02 Travelling as a way of self-discovery and progress',
content:
'Pengacara Kondang, Hotman Paris Hutapea, membuka sebuah perpustakaan baru di dalam diskotik nya yang berlokasi di daerah Jakarta Pusat, Hotman berkata Perpustakaan ini dibuka dengan harapan untuk meningkatkan gairah membaca masyarakat Indonesia, namun sayangnya..',
featured: 'https://placehold.co/600x400.png',
slug: 'hotman-paris-membuka-perpustakaan-di-tengah-diskotik',
},
{
title: '03 Travelling as a way of self-discovery and progress',
content:
'Pengacara Kondang, Hotman Paris Hutapea, membuka sebuah perpustakaan baru di dalam diskotik nya yang berlokasi di daerah Jakarta Pusat, Hotman berkata Perpustakaan ini dibuka dengan harapan untuk meningkatkan gairah membaca masyarakat Indonesia, namun sayangnya..',
featured: '/images/news-1.jpg',
slug: 'hotman-paris-membuka-perpustakaan-di-tengah-diskotik',
},
],
}
export const BERITA: TNews = {
title: 'BERITA',
description: DUMMY_DESCRIPTION,
items: [
{
title: '01 Travelling as a way of self-discovery and progress ',
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
featured: '/images/news-2.jpg',
tags: ['Hukum Property'],
slug: 'travelling-as-a-way-of-self-discovery-and-progress',
},
{
title: '02 How does writing influence your personal brand?',
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
featured: '/images/news-3.jpg',
tags: ['Hukum'],
slug: 'how-does-writing-influence-your-personal-brand',
},
{
title: '03 Helping a local business reinvent itself',
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
featured: '/images/news-4.jpg',
tags: ['Hukum Property'],
isPremium: true,
slug: 'helping-a-local-business-reinvent-itself',
},
{
title: 'Travelling as a way of self-discovery and progress',
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
featured: 'https://placehold.co/600x400.png',
tags: ['Hukum Property'],
slug: 'travelling-as-a-way-of-self-discovery-and-progress',
},
{
title: 'How does writing influence your personal brand?',
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
featured: '/images/news-3.jpg',
tags: ['Hukum'],
slug: 'how-does-writing-influence-your-personal-brand',
},
{
title: 'Helping a local business reinvent itself',
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros.',
featured: '/images/news-4.jpg',
tags: ['Hukum Property'],
isPremium: true,
slug: 'helping-a-local-business-reinvent-itself',
},
],
}
export const BANNER: TBanner[] = [
{
id: 1,
urlImage: '/images/banner.png',
alt: 'banner',
link: '/category/spotlight',
status: 'active',
createdAt: '2021-08-01',
},
{
id: 2,
urlImage: 'https://placehold.co/1000x65.png',
alt: 'banner',
status: 'draft',
link: '/#',
createdAt: '2021-08-01',
},
{
id: 3,
urlImage: 'https://placehold.co/1000x65.png',
alt: 'banner',
status: 'draft',
link: '/#',
createdAt: '2021-08-01',
},
{
id: 4,
urlImage: '/images/banner.png',
alt: 'banner',
link: '/#',
status: 'active',
createdAt: '2021-08-01',
},
{
id: 5,
urlImage: '/images/banner.png',
alt: 'banner',
link: '/#',
status: 'inactive',
createdAt: '2021-08-01',
},
]
+5
View File
@@ -1,5 +1,7 @@
import type { PropsWithChildren } from 'react'
import { DialogProfile } from './dialog-profile'
import { DialogUpload } from './dialog-upload'
import { Navbar } from './navbar'
import { Sidebar } from './sidebar'
@@ -12,6 +14,9 @@ export const AdminDashboardLayout = (properties: PropsWithChildren) => {
<Sidebar />
<div className="min-h-[calc(100dvh-80px)] flex-1 p-8">{children}</div>
</div>
<DialogUpload />
<DialogProfile />
</div>
)
}
+2
View File
@@ -1,10 +1,12 @@
import type { PropsWithChildren } from 'react'
import { Toaster } from 'react-hot-toast'
export const AdminDefaultLayout = (properties: PropsWithChildren) => {
const { children } = properties
return (
<main className="font-admin relative min-h-dvh bg-[#F7F8FC]">
{children}
<Toaster />
</main>
)
}
+126
View File
@@ -0,0 +1,126 @@
import {
Dialog,
DialogBackdrop,
DialogPanel,
DialogTitle,
} from '@headlessui/react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect } from 'react'
import toast from 'react-hot-toast'
import { useFetcher, useRouteLoaderData } from 'react-router'
import { RemixFormProvider, useRemixForm } from 'remix-hook-form'
import { z } from 'zod'
import { Button } from '~/components/ui/button'
import { Input } from '~/components/ui/input'
import { InputFile } from '~/components/ui/input-file'
import { useAdminContext } from '~/contexts/admin'
import type { loader } from '~/routes/_admin.lg-admin'
export const profileSchema = z.object({
name: z.string().min(1, 'Wajib diisi'),
email: z.string().email('Email tidak valid'),
profile_picture: z.string().url({
message: 'URL tidak valid',
}),
})
export type TProfileSchema = z.infer<typeof profileSchema>
export const DialogProfile = () => {
const { editProfile, setEditProfile } = useAdminContext()
const loaderData = useRouteLoaderData<typeof loader>('routes/_admin.lg-admin')
const { staffData } = loaderData || {}
const fetcher = useFetcher()
const formMethods = useRemixForm<TProfileSchema>({
mode: 'onSubmit',
fetcher,
resolver: zodResolver(profileSchema),
values: {
name: staffData?.name || '',
email: staffData?.email || '',
profile_picture: staffData?.profile_picture || '',
},
})
const { handleSubmit } = formMethods
useEffect(() => {
if (!fetcher.data?.success && fetcher.data?.message) {
toast.error(fetcher.data.message)
}
if (fetcher.data?.success) {
setEditProfile(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetcher.data])
return (
<Dialog
open={editProfile}
onClose={() => {
if (fetcher.state === 'idle') {
setEditProfile(false)
}
}}
className="relative z-50"
transition
>
<DialogBackdrop
className="fixed inset-0 bg-black/50 duration-300 ease-out data-[closed]:opacity-0"
transition
/>
<div className="fixed inset-0 flex w-screen justify-center overflow-y-auto p-0 max-sm:bg-white sm:items-center sm:p-4">
<DialogPanel
transition
className="w-full max-w-lg space-y-6 rounded-lg bg-white p-8 duration-300 ease-out data-[closed]:scale-95 data-[closed]:opacity-0 sm:shadow-lg"
>
<DialogTitle
as="h3"
className="text-xl font-bold"
>
Update Profile
</DialogTitle>
<RemixFormProvider {...formMethods}>
<fetcher.Form
method="post"
onSubmit={handleSubmit}
className="space-y-4"
action="/actions/admin/profile"
>
<Input
name="name"
id="name"
label="Nama"
placeholder="Enter your name"
/>
<Input
id="email"
label="Email"
placeholder="Contoh: legal@legalgo.id"
name="email"
/>
<InputFile
name="profile_picture"
id="profile_picture"
label="Gambar Profil"
placeholder="Unggah gambar profil Anda"
category="profile_picture"
/>
<Button
disabled={fetcher.state !== 'idle'}
isLoading={fetcher.state !== 'idle'}
type="submit"
className="w-full rounded-md py-2"
>
Simpan
</Button>
</fetcher.Form>
</RemixFormProvider>
</DialogPanel>
</div>
</Dialog>
)
}
+139
View File
@@ -0,0 +1,139 @@
import { Dialog, DialogBackdrop, DialogPanel, Input } from '@headlessui/react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect, type ChangeEvent } from 'react'
import toast from 'react-hot-toast'
import { useFetcher } from 'react-router'
import { RemixFormProvider, useRemixForm } from 'remix-hook-form'
import { z } from 'zod'
import { Button } from '~/components/ui/button'
import { uploadCategorySchema, useAdminContext } from '~/contexts/admin'
export const uploadSchema = z.object({
file: z.instanceof(File),
category: uploadCategorySchema,
})
export type TUploadSchema = z.infer<typeof uploadSchema>
export const DialogUpload = () => {
const { isUploadOpen, setUploadedFile, setIsUploadOpen } = useAdminContext()
const fetcher = useFetcher()
const maxFileSize = 10 * 1024 // 10MB
const formMethods = useRemixForm<TUploadSchema>({
mode: 'onSubmit',
fetcher,
resolver: zodResolver(uploadSchema),
})
const { handleSubmit, register, setValue } = formMethods
useEffect(() => {
if (!fetcher.data?.success && fetcher.data?.message) {
toast.error(fetcher.data.message)
}
if (fetcher.data?.success) {
setUploadedFile(fetcher.data.uploadData.data.file_url)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetcher.data])
const handleChange = async function (event: ChangeEvent<HTMLInputElement>) {
event.preventDefault()
if (event.target.files && event.target.files[0]) {
const files: File[] = [...event.target.files]
onChange(files, event)
}
}
const onChange = async function (
files: File[],
event: ChangeEvent<HTMLInputElement>,
) {
const file = files[0]
const img = new Image()
if (!file.type.startsWith('image/')) {
toast.error('Please upload an image file.')
return
}
if (file.size > maxFileSize * 1024) {
toast.error(`File size is too big!`)
return
}
img.addEventListener('load', () => {
handleFiles(event)
})
img.src = URL.createObjectURL(file)
}
const handleFiles = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files
if (files && files.length > 0) {
const file = files[0]
setValue('file', file)
}
}
return (
<Dialog
open={!!isUploadOpen}
onClose={() => {
if (fetcher.state === 'idle') {
setIsUploadOpen(undefined)
}
}}
className="relative z-50"
transition
>
<DialogBackdrop
className="fixed inset-0 bg-black/50 duration-300 ease-out data-[closed]:opacity-0"
transition
/>
<div className="fixed inset-0 flex w-screen justify-center overflow-y-auto p-0 max-sm:bg-white sm:items-center sm:p-4">
<DialogPanel
transition
className="w-full max-w-lg space-y-6 rounded-lg bg-white p-8 duration-300 ease-out data-[closed]:scale-95 data-[closed]:opacity-0 sm:shadow-lg"
>
<RemixFormProvider {...formMethods}>
<fetcher.Form
method="post"
onSubmit={handleSubmit}
className="space-y-4"
action="/actions/admin/upload"
encType="multipart/form-data"
>
<Input
type="file"
id="input-file-upload"
accept="image/*"
className="h-[42px] w-full cursor-pointer rounded-md border border-[#DFDFDF] p-2"
onChange={handleChange}
/>
<input
type="hidden"
id="input-file-upload-type"
value={isUploadOpen}
{...register('category')}
/>
<Button
disabled={fetcher.state !== 'idle'}
isLoading={fetcher.state !== 'idle'}
type="submit"
className="w-full rounded-md py-2"
>
Upload
</Button>
</fetcher.Form>
</RemixFormProvider>
</DialogPanel>
</div>
</Dialog>
)
}
+60 -18
View File
@@ -1,17 +1,22 @@
import { ClipboardDocumentCheckIcon, TagIcon } from '@heroicons/react/20/solid'
import type { SVGProps } from 'react'
import { ChartIcon } from '~/components/icons/chart'
import { ChatIcon } from '~/components/icons/chat'
import { DocumentIcon } from '~/components/icons/document'
import { MedicalNotesIcon } from '~/components/icons/medical-notes'
import {
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: React.ComponentType<SVGProps<SVGSVGElement>>
icon: ComponentType<SVGProps<SVGSVGElement>>
}[]
}
@@ -20,29 +25,29 @@ export const MENU: TMenu[] = [
group: 'Menu',
items: [
{
title: 'Dashboard',
title: 'Dasbor',
url: '/lg-admin',
icon: ChartIcon,
icon: ChartBarSquareIcon,
},
{
title: 'User',
title: 'Pengguna',
url: '/lg-admin/users',
icon: DocumentIcon,
icon: UsersIcon,
},
{
title: 'Konten',
title: 'Artikel',
url: '/lg-admin/contents',
icon: ChatIcon,
icon: NewspaperIcon,
},
{
title: 'Advertisement',
title: 'Spanduk Iklan',
url: '/lg-admin/advertisements',
icon: MedicalNotesIcon,
icon: MegaphoneIcon,
},
{
title: 'Subscription',
title: 'Pelanggan',
url: '/lg-admin/subscriptions',
icon: ChartIcon,
icon: PresentationChartLineIcon,
},
],
},
@@ -59,6 +64,43 @@ export const MENU: TMenu[] = [
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',
},
]
+42 -16
View File
@@ -1,15 +1,21 @@
import { Button, Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
import {
ArrowRightStartOnRectangleIcon,
UserIcon,
} from '@heroicons/react/24/outline'
import { ChevronDownIcon } from '@heroicons/react/24/solid'
import { Link, useFetcher, useRouteLoaderData } from 'react-router'
import { ChevronIcon } from '~/components/icons/chevron'
import { ProfileIcon } from '~/components/icons/profile'
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?.staffData
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">
@@ -25,39 +31,59 @@ export const Navbar = () => {
</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-2 ring-[#707FDD]/10 hover:shadow focus:outline-none">
<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">
{staffData?.profile_picture ? (
<img
src={staffData?.profile_picture}
src={
staffData?.profile_picture ||
'/images/profile-placeholder.svg'
}
onError={(event) => {
event.currentTarget.src = '/images/profile-placeholder.svg'
}}
alt={staffData?.name}
className="h-8 w-8 rounded-full bg-[#C4C4C4] object-cover"
className="size-8 rounded-full bg-[#C4C4C4] object-cover"
/>
) : (
<ProfileIcon className="h-8 w-8 rounded-full bg-[#C4C4C4]" />
)}
<span className="text-xs">{staffData?.name}</span>
<span className="text-sm">{staffData?.name}</span>
</div>
<ChevronIcon className="opacity-50" />
<ChevronDownIcon className="size-4 opacity-50" />
</PopoverButton>
<PopoverPanel
anchor={{ to: 'bottom', gap: '8px' }}
transition
className="flex w-3xs flex-col rounded-xl border border-[#ECECEC] bg-white p-3 transition duration-200 ease-in-out data-[closed]:-translate-y-1 data-[closed]:opacity-0"
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="cursor-pointer rounded p-1 hover:bg-[#707FDD]/10 hover:text-[#5363AB]"
className="w-full justify-start rounded p-1 px-3 text-base font-bold"
variant="secondary"
icon={<ArrowRightStartOnRectangleIcon className="size-5" />}
>
Logout
<span>Logout</span>
</Button>
</fetcher.Form>
</div>
</PopoverPanel>
</Popover>
</div>
+1 -1
View File
@@ -22,7 +22,7 @@ export const Sidebar = () => {
key={`${group}-${title}`}
className={twMerge(
path === url ? 'bg-[#707FDD]/10 font-bold' : '',
'group/menu flex h-[42px] w-[200px] items-center gap-x-3 rounded-md px-5 transition-all hover:bg-[#707FDD]/10',
'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
+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>
)
}
+14 -52
View File
@@ -1,33 +1,22 @@
import { type PropsWithChildren } from 'react'
import { Toaster } from 'react-hot-toast'
import { PopupModal } from '~/components/popup/modal'
import { SuccessModal } from '~/components/popup/success-modal'
import { Banner } from '~/components/ui/banner'
import { DialogSuccess } from '~/components/dialog/success'
import { useNewsContext } from '~/contexts/news'
import { FormForgotPassword } from '~/layouts/news/form-forgot-password'
import { FormLogin } from '~/layouts/news/form-login'
import { FormRegister } from '~/layouts/news/form-register'
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 FormSubscription from './form-subscription'
import { HeaderMenu } from './header-menu'
import { HeaderTop } from './header-top'
export const NewsDefaultLayout = (properties: PropsWithChildren) => {
const { children } = properties
const {
isLoginOpen,
setIsLoginOpen,
isRegisterOpen,
setIsRegisterOpen,
isForgetOpen,
setIsForgetOpen,
isSuccessOpen,
setIsSuccessOpen,
isSubscribeOpen,
setIsSubscribeOpen,
} = useNewsContext()
const { isSuccessOpen, setIsSuccessOpen } = useNewsContext()
return (
<main className="relative min-h-dvh bg-[#ECECEC]">
<header>
@@ -44,39 +33,12 @@ export const NewsDefaultLayout = (properties: PropsWithChildren) => {
<FooterLinks />
</footer>
<PopupModal
isOpen={isLoginOpen}
onClose={() => setIsLoginOpen(false)}
description="Selamat Datang, silakan daftarkan akun Anda untuk melanjutkan!"
>
<FormLogin />
</PopupModal>
<PopupModal
isOpen={isRegisterOpen}
onClose={() => setIsRegisterOpen(false)}
description="Selamat Datang, silakan isi keterangan akun Anda untuk melanjutkan!"
>
<FormRegister />
</PopupModal>
<PopupModal
isOpen={isForgetOpen}
onClose={() => setIsForgetOpen(false)}
description="Selamat Datang, silakan isi keterangan akun Anda untuk melanjutkan!"
>
<FormForgotPassword />
</PopupModal>
<PopupModal
isOpen={isSubscribeOpen}
onClose={() => setIsSubscribeOpen(false)}
description="Selamat Datang, silakan Pilih Subscription Anda untuk melanjutkan!"
>
<FormSubscription />
</PopupModal>
<SuccessModal
<Toaster />
<DialogLogin />
<DialogRegister />
<DialogForgotPassword />
<DialogSubscribePlan />
<DialogSuccess
isOpen={isSuccessOpen}
onClose={() => {
setIsSuccessOpen(undefined)
@@ -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 -1
View File
@@ -34,7 +34,7 @@ export const FooterNewsletter = () => {
/>
<Button
type="submit"
variant="newsPrimaryOutline"
variant="primaryOutline"
size="block"
>
Subscribe
-32
View File
@@ -1,32 +0,0 @@
import { Button } from '~/components/ui/button'
export const FormForgotPassword = () => {
return (
<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 bg-[#2E2F7C] py-2 text-white transition hover:bg-blue-800">
Reset Password
</Button>
</form>
</div>
</div>
)
}
-126
View File
@@ -1,126 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect, useState } from 'react'
import { useFetcher } 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 { useNewsContext } from '~/contexts/news'
export const loginSchema = z.object({
email: z.string().email('Email tidak valid'),
password: z.string().min(6, 'Kata sandi minimal 6 karakter'),
})
export type TLoginSchema = z.infer<typeof loginSchema>
export const FormLogin = () => {
const {
setIsRegisterOpen,
setIsLoginOpen,
setIsForgetOpen,
setIsSubscribeOpen,
} = useNewsContext()
const fetcher = useFetcher()
const [error, setError] = useState<string>()
const [disabled, setDisabled] = useState(false)
const formMethods = useRemixForm<TLoginSchema>({
mode: 'onSubmit',
fetcher,
resolver: zodResolver(loginSchema),
})
const { handleSubmit } = formMethods
useEffect(() => {
if (!fetcher.data?.success) {
setError(fetcher.data?.message)
setDisabled(false)
return
}
setDisabled(true)
setError(undefined)
setIsLoginOpen(false)
if (fetcher.data?.user.subscribe_plan_code === 'basic') {
setIsSubscribeOpen(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetcher])
return (
<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"
/>
{error && (
<div className="text-sm text-red-500 capitalize">{error}</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">Lupa Kata Sandi?</span>
<Button
onClick={() => {
setIsLoginOpen(false)
setIsForgetOpen(true)
}}
className="font-semibold text-[#2E2F7C]"
variant="link"
size="fit"
>
Reset Kata Sandi
</Button>
</div>
<Button
disabled={disabled}
type="submit"
className="w-full rounded-md bg-[#2E2F7C] py-2 text-white transition hover:bg-blue-800"
>
Masuk
</Button>
</fetcher.Form>
</RemixFormProvider>
{/* Link Daftar */}
<div className="mt-4 text-center text-sm">
Belum punya akun?{' '}
<Button
onClick={() => {
setIsLoginOpen(false)
setIsRegisterOpen(true)
}}
className="font-semibold text-[#2E2F7C]"
variant="link"
size="fit"
>
Daftar Disini
</Button>
</div>
</div>
</div>
)
}
-151
View File
@@ -1,151 +0,0 @@
import { DevTool } from '@hookform/devtools'
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect, useState } from 'react'
import { useFetcher, useRouteLoaderData } from 'react-router'
import { RemixFormProvider, useRemixForm } from 'remix-hook-form'
import { z } from 'zod'
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, 'Kata sandi minimal 6 karakter'),
rePassword: z.string().min(6, 'Kata sandi 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: 'Please select a subscription',
}),
})
.refine((field) => field.password === field.rePassword, {
message: 'Kata sandi tidak sama',
path: ['rePassword'],
})
export type TRegisterSchema = z.infer<typeof registerSchema>
export const FormRegister = () => {
const { setIsLoginOpen, setIsRegisterOpen, setIsSuccessOpen } =
useNewsContext()
const [error, setError] = useState<string>()
const [disabled, setDisabled] = useState(false)
const fetcher = useFetcher()
const loaderData = useRouteLoaderData<typeof loader>('routes/_news')
const subscriptions = loaderData?.subscriptionsData
const formMethods = useRemixForm<TRegisterSchema>({
mode: 'onSubmit',
fetcher,
resolver: zodResolver(registerSchema),
})
const { handleSubmit, control } = formMethods
useEffect(() => {
if (!fetcher.data?.success) {
setError(fetcher.data?.message)
setDisabled(false)
return
}
setDisabled(true)
setError(undefined)
setIsRegisterOpen(false)
setIsSuccessOpen('register')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetcher])
return (
<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="Subscription"
placeholder="Pilih Subscription"
options={subscriptions}
/>
{error && (
<div className="text-sm text-red-500 capitalize">{error}</div>
)}
<Button
disabled={disabled}
type="submit"
className="w-full rounded-md bg-[#2E2F7C] py-2 text-white transition hover:bg-blue-800"
>
Daftar
</Button>
</fetcher.Form>
</RemixFormProvider>
{/* Link Login */}
<div className="mt-4 text-center text-sm">
Sudah punya akun?{' '}
<Button
onClick={() => {
setIsLoginOpen(true)
setIsRegisterOpen(false)
}}
className="font-semibold text-[#2E2F7C]"
variant="link"
size="fit"
>
Masuk Disini
</Button>
</div>
</div>
<DevTool control={control} />
</div>
)
}
-90
View File
@@ -1,90 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect, useState } from 'react'
import { useFetcher, useRouteLoaderData } from 'react-router'
import { RemixFormProvider, useRemixForm } from 'remix-hook-form'
import { z } from 'zod'
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: 'Please select a subscription',
}),
})
export type TSubscribeSchema = z.infer<typeof subscribeSchema>
export default function FormSubscription() {
const { setIsSubscribeOpen, setIsSuccessOpen } = useNewsContext()
const fetcher = useFetcher()
const [error, setError] = useState<string>()
const [disabled, setDisabled] = useState(false)
const loaderData = useRouteLoaderData<typeof loader>('routes/_news')
const subscriptions = loaderData?.subscriptionsData
const formMethods = useRemixForm<TSubscribeSchema>({
mode: 'onSubmit',
fetcher,
resolver: zodResolver(subscribeSchema),
})
const { handleSubmit } = formMethods
useEffect(() => {
if (!fetcher.data?.success) {
setError(fetcher.data?.message)
setDisabled(false)
return
}
setDisabled(true)
setError(undefined)
setIsSubscribeOpen(false)
setIsSuccessOpen('payment')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetcher])
return (
<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="Subscription"
placeholder="Pilih Subscription"
options={subscriptions}
/>
{error && (
<div className="text-sm text-red-500 capitalize">{error}</div>
)}
<Button
disabled={disabled}
type="submit"
className="mt-5 w-full rounded-md bg-[#2E2F7C] py-2 text-white transition hover:bg-blue-800"
>
Lanjutkan
</Button>
</fetcher.Form>
</RemixFormProvider>
</div>
)
}
+7 -7
View File
@@ -13,12 +13,12 @@ type THeaderMenuMobile = {
menu?: TCategoriesResponse['data']
}
export default function HeaderMenuMobile(properties: THeaderMenuMobile) {
export const HeaderMenuMobile = (properties: THeaderMenuMobile) => {
const { menu } = properties
const [isMenuOpen, setIsMenuOpen] = useState(false)
const { setIsLoginOpen } = useNewsContext()
const loaderData = useRouteLoaderData<typeof loader>('routes/_news')
const userData = loaderData?.userData
const { userData } = loaderData || {}
const fetcher = useFetcher()
const handleToggleMenu = (): void => {
@@ -37,7 +37,7 @@ export default function HeaderMenuMobile(properties: THeaderMenuMobile) {
{/* Tombol Close */}
<button
onClick={handleToggleMenu}
className="fixed top-5 right-5 z-20 flex h-9 w-9 items-center justify-center lg:hidden"
className="fixed top-5 right-5 z-20 flex size-9 items-center justify-center lg:hidden"
>
<CloseIcon
width={50}
@@ -70,8 +70,8 @@ export default function HeaderMenuMobile(properties: THeaderMenuMobile) {
action="/actions/logout"
>
<Button
variant="newsSecondary"
className="w-full bg-white px-[35px] py-3 text-center text-[#2E2F7C] sm:hidden"
variant="outline"
className="w-full px-[35px] py-3 text-center sm:hidden"
type="submit"
>
Logout
@@ -79,8 +79,8 @@ export default function HeaderMenuMobile(properties: THeaderMenuMobile) {
</fetcher.Form>
) : (
<Button
variant="newsSecondary"
className="w-full bg-white px-[35px] py-3 text-center text-[#2E2F7C] sm:hidden"
variant="outline"
className="w-full px-[35px] py-3 text-center sm:hidden"
onClick={() => {
setIsMenuOpen(false)
setIsLoginOpen(true)
+7 -4
View File
@@ -1,6 +1,7 @@
import { Link, useRouteLoaderData } from 'react-router'
import HeaderMenuMobile from '~/layouts/news/header-menu-mobile'
import { Button } from '~/components/ui/button'
import { HeaderMenuMobile } from '~/layouts/news/header-menu-mobile'
import type { loader } from '~/routes/_news'
import { HeaderSearch } from './header-search'
@@ -17,15 +18,17 @@ export const HeaderMenu = () => {
<>
<div className="hidden h-[60px] items-center justify-between bg-[#2E2F7C] text-xl font-medium text-white sm:flex">
{menu?.map((item) => (
<Link
<Button
as={Link}
key={item.id}
to={`/category/${item.code}`}
size="fit"
className={
'flex h-full items-center justify-center border-r border-white px-[35px]'
'flex h-full items-center justify-center border-r border-white px-[35px] text-xl'
}
>
{item.name}
</Link>
</Button>
))}
<HeaderSearch />
</div>
+6 -1
View File
@@ -3,11 +3,16 @@ import { Button } from '~/components/ui/button'
export const HeaderSearch = () => {
return (
<form className="flex flex-1 justify-between gap-[15px] px-[35px]">
<form
className="flex flex-1 justify-between gap-[15px] px-[35px]"
method="get"
action="/search"
>
<input
placeholder="Cari..."
className="flex-1 text-xl placeholder:text-white focus:ring-0 focus:outline-none"
size={1}
name="q"
/>
<Button
type="submit"
+8 -6
View File
@@ -8,7 +8,7 @@ import type { loader } from '~/routes/_news'
export const HeaderTop = () => {
const { setIsLoginOpen } = useNewsContext()
const loaderData = useRouteLoaderData<typeof loader>('routes/_news')
const userData = loaderData?.userData
const { userData } = loaderData || {}
const fetcher = useFetcher()
return (
@@ -23,9 +23,9 @@ export const HeaderTop = () => {
className="h-3/4 w-auto sm:h-full"
/>
</Link>
<div className="hidden h-full items-center py-1.5 text-2xl font-light whitespace-pre-line sm:flex">
<h1 className="hidden h-full items-center py-1.5 text-4xl font-extrabold whitespace-pre-line text-[#2E2F7C] uppercase sm:flex">
{APP.description}
</div>
</h1>
<div className="flex items-center gap-[15px]">
{userData ? (
<fetcher.Form
@@ -33,16 +33,18 @@ export const HeaderTop = () => {
action="/actions/logout"
>
<Button
variant="newsSecondary"
className="hidden sm:block"
variant="outline"
className="hidden sm:flex"
type="submit"
disabled={fetcher.state !== 'idle'}
isLoading={fetcher.state !== 'idle'}
>
Logout
</Button>
</fetcher.Form>
) : (
<Button
variant="newsSecondary"
variant="outline"
className="hidden sm:block"
onClick={() => setIsLoginOpen(true)}
>
+1 -1
View File
@@ -39,7 +39,7 @@ export const FOOTER_MENU: TFooterMenu[] = [
url: '/support',
},
{
title: 'Rquest Topic',
title: 'Request Topic',
url: '/request-topic',
},
],
+8 -2
View File
@@ -2,10 +2,14 @@ import xior, { merge } from 'xior'
const baseURL = import.meta.env.VITE_API_URL
export type THttpServer = { accessToken?: string }
export type THttpServer = {
accessToken?: string
ipAddress?: string | null
userAgent?: string | null
}
export const HttpServer = (parameters?: THttpServer) => {
const { accessToken } = parameters || {}
const { accessToken, ipAddress, userAgent } = parameters || {}
const instance = xior.create({
baseURL,
})
@@ -16,6 +20,8 @@ export const HttpServer = (parameters?: THttpServer) => {
return merge(config, {
headers: {
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
...(ipAddress && { 'X-Ip-Address': ipAddress }),
...(userAgent && { 'X-User-Agent': userAgent }),
},
})
})

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