Compare commits
191
Commits
fc67298a85
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b4ea17a16 | ||
|
|
eedb155880 | ||
|
|
84451f1fd4 | ||
|
|
dce745f53d | ||
|
|
57e23adf3c | ||
|
|
35c02d1643 | ||
|
|
1f2f15d204 | ||
|
|
39b7720186 | ||
|
|
de1802c597 | ||
|
|
724a14d741 | ||
|
|
fa5f7fbe92 | ||
|
|
4ff1e23d25 | ||
|
|
9ab67c615a | ||
|
|
65f7bbe0aa | ||
|
|
1c33eba834 | ||
|
|
1885eab4c3 | ||
|
|
f2f49de86b | ||
|
|
e81dad4ec5 | ||
|
|
63bbf70bd6 | ||
|
|
33096ab7c1 | ||
|
|
ab5b545625 | ||
|
|
06672714b7 | ||
|
|
2f8b5dbe0f | ||
|
|
09f8669553 | ||
|
|
731b36ef70 | ||
|
|
305c2cf140 | ||
|
|
6edca07fa6 | ||
|
|
d65aed6828 | ||
|
|
ab3f748195 | ||
|
|
3847fd1896 | ||
|
|
2dc84c582d | ||
|
|
b8378d37ad | ||
|
|
bb3e1bb139 | ||
|
|
66de3dcf04 | ||
|
|
9c9d127dfb | ||
|
|
9745fff853 | ||
|
|
deeefda8bc | ||
|
|
abf7e829ba | ||
|
|
ba4e380023 | ||
|
|
4ca2d8cf84 | ||
|
|
1585830184 | ||
|
|
22918b8bdb | ||
|
|
d32eb2e7ed | ||
|
|
87616ef6bd | ||
|
|
0f332fde79 | ||
|
|
c633397d7b | ||
|
|
680a0a88a2 | ||
|
|
d91dffb013 | ||
|
|
00bb1a46dc | ||
|
|
fe38a63f1c | ||
|
|
978d74d226 | ||
|
|
d767055bdb | ||
|
|
7b840ce5cd | ||
|
|
e7ef7177ca | ||
|
|
4c3a143338 | ||
|
|
cc5331284b | ||
|
|
c7195b7428 | ||
|
|
6d99a37f20 | ||
|
|
405e57b92d | ||
|
|
cbfb8e72cc | ||
|
|
3ddc657cfb | ||
|
|
52085ea25e | ||
|
|
84d10ac983 | ||
|
|
19a5e6ab88 | ||
|
|
d538c56d26 | ||
|
|
deb2004039 | ||
|
|
5c20cc48ab | ||
|
|
bfa46cdca4 | ||
|
|
fc23f45854 | ||
|
|
29baa39a94 | ||
|
|
f04ad1a8f8 | ||
|
|
273add293e | ||
|
|
01f9cf06ca | ||
|
|
ff51941647 | ||
|
|
8a9cacf7b4 | ||
|
|
c89731e124 | ||
|
|
798896e4ee | ||
|
|
b00adf89ec | ||
|
|
2c703de8e5 | ||
|
|
eadfccfc0e | ||
|
|
d77ea01c59 | ||
|
|
6878da0db2 | ||
|
|
f40f2dadde | ||
|
|
cd76ded632 | ||
|
|
0f64b4600b | ||
|
|
0680fb4dc8 | ||
|
|
474c6dc750 | ||
|
|
86ee6abef7 | ||
|
|
86e87998bd | ||
|
|
d63884dde1 | ||
|
|
850f42e99d | ||
|
|
df3b14a212 | ||
|
|
7ab83a4f66 | ||
|
|
36b22d3f4a | ||
|
|
338f4a72c4 | ||
|
|
ac5c095ecc | ||
|
|
1ce5a2130b | ||
|
|
f423f3e1c0 | ||
|
|
c0318fac4c | ||
|
|
0545190497 | ||
|
|
634073342b | ||
|
|
eb7cc04256 | ||
|
|
f026277a88 | ||
|
|
4a21b7d331 | ||
|
|
930d4b8459 | ||
|
|
56c31d7a20 | ||
|
|
70cbb134d8 | ||
|
|
90e2042a91 | ||
|
|
c5b611a300 | ||
|
|
b9fb1112ae | ||
|
|
56d9081b4f | ||
|
|
2c90d32a10 | ||
|
|
8a19529230 | ||
|
|
d1b828bba1 | ||
|
|
2b253633a5 | ||
|
|
ea6462f3ea | ||
|
|
6f0a21dde6 | ||
|
|
07cced218e | ||
|
|
9a2d64eee4 | ||
|
|
dfd1a46694 | ||
|
|
4a188afc8f | ||
|
|
6ec8e5f2a2 | ||
|
|
66bc731b07 | ||
|
|
dbeec8acf3 | ||
|
|
8cddc82031 | ||
|
|
a45a6fb87e | ||
|
|
438d1d40db | ||
|
|
9a5bddef6a | ||
|
|
f97c79aaa9 | ||
|
|
d3f0dac69f | ||
|
|
7f7526783f | ||
|
|
0f5b26c2b8 | ||
|
|
60d11fbfa2 | ||
|
|
54a8a97c09 | ||
|
|
169f0166b8 | ||
|
|
e93c0a8464 | ||
|
|
ba83a6a025 | ||
|
|
c70aa86be6 | ||
|
|
2e13e11673 | ||
|
|
aae10cf3bc | ||
|
|
9a0c6c1f0b | ||
|
|
8c2298ff61 | ||
|
|
061f95d4e5 | ||
|
|
de92703fbe | ||
|
|
a9a350fdb2 | ||
|
|
4bc185434b | ||
|
|
4bb7a23795 | ||
|
|
f291290f55 | ||
|
|
e84c05d0a0 | ||
|
|
c7583413bb | ||
|
|
5b83c3f8dd | ||
|
|
0f414f6963 | ||
|
|
2940057cb1 | ||
|
|
9dca781d6d | ||
|
|
f17d64eb9b | ||
|
|
64bb369c59 | ||
|
|
422c9cbfe2 | ||
|
|
3d13707359 | ||
|
|
9f6c07c3de | ||
|
|
e7eda086e4 | ||
|
|
550b16cfd3 | ||
|
|
0b3217407c | ||
|
|
7afabdaa03 | ||
|
|
9f82779456 | ||
|
|
ee816b8db7 | ||
|
|
1b6321f2bd | ||
|
|
4847ef2be3 | ||
|
|
bad5294030 | ||
|
|
9976626f97 | ||
|
|
d38bf3a705 | ||
|
|
687e3c8d01 | ||
|
|
4554cca392 | ||
|
|
05b3af8718 | ||
|
|
e2e3095c33 | ||
|
|
5b94c6df91 | ||
|
|
634c809489 | ||
|
|
0da2006e78 | ||
|
|
09e5f84b8b | ||
|
|
ecd1900acc | ||
|
|
0b6327e2fb | ||
|
|
06608c7b5d | ||
|
|
333fa32eda | ||
|
|
1881032ed2 | ||
|
|
aad67720c1 | ||
|
|
f3be8138c4 | ||
|
|
46e009f888 | ||
|
|
6e858f1860 | ||
|
|
be63337024 | ||
|
|
4b61fd9501 | ||
|
|
609866beef | ||
|
|
1c6bf68b1e |
@@ -1,5 +1,7 @@
|
||||
.DS_Store
|
||||
/node_modules/
|
||||
.vscode/cspell.json
|
||||
.vscode/.cspell/
|
||||
.env
|
||||
|
||||
# React Router
|
||||
|
||||
Vendored
+4
-3
@@ -25,7 +25,8 @@
|
||||
"labelClassName",
|
||||
"buttonClassName",
|
||||
"leftNodeClassName",
|
||||
"rightNodeClassName"
|
||||
],
|
||||
"cSpell.words": []
|
||||
"rightNodeClassName",
|
||||
"buttonVariants",
|
||||
"cva"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
const { payload, ...restParameters } = parameters
|
||||
const { categories, tags, live_at, ...restPayload } = payload
|
||||
const transformedPayload = {
|
||||
...restPayload,
|
||||
categories: categories.map((category) => category?.id),
|
||||
tags: tags?.map((tag) => tag?.id) || [],
|
||||
live_at: datePayload(live_at),
|
||||
}
|
||||
try {
|
||||
const { categories, tags, ...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(),
|
||||
}
|
||||
const { data } = await HttpServer({ accessToken }).post(
|
||||
const { data } = await HttpServer(restParameters).post(
|
||||
'/api/news/create',
|
||||
transformedPayload,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
const { payload, ...restParameters } = parameters
|
||||
const { id, ...restPayload } = payload
|
||||
try {
|
||||
const { id, ...restPayload } = payload
|
||||
const { data } = await HttpServer({ accessToken }).put(
|
||||
const { data } = await HttpServer(restParameters).put(
|
||||
`/api/category/${id}/update`,
|
||||
restPayload,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
const { payload, ...restParameters } = parameters
|
||||
const { categories, tags, id, live_at, ...restPayload } = payload
|
||||
const transformedPayload = {
|
||||
...restPayload,
|
||||
categories: categories.map((category) => category?.id),
|
||||
tags: tags?.map((tag) => tag?.id) || [],
|
||||
live_at: datePayload(live_at),
|
||||
}
|
||||
try {
|
||||
const { categories, tags, id, ...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(),
|
||||
}
|
||||
const { data } = await HttpServer({ accessToken }).put(
|
||||
const { data } = await HttpServer(restParameters).put(
|
||||
`/api/news/${id}/update`,
|
||||
transformedPayload,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
const { payload, ...restParameters } = parameters
|
||||
const { id, ...restPayload } = payload
|
||||
try {
|
||||
const { id, ...restPayload } = payload
|
||||
const { data } = await HttpServer({ accessToken }).put(
|
||||
const { data } = await HttpServer(restParameters).put(
|
||||
`/api/tag/${id}/update`,
|
||||
restPayload,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,14 +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 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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
@@ -11,12 +11,12 @@ type TParameters = {
|
||||
slug: string
|
||||
} & THttpServer
|
||||
|
||||
export type TNewDetailResponse = z.infer<typeof dataResponseSchema>
|
||||
|
||||
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
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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) => {
|
||||
const { subscribe_plan, ...restPayload } = payload
|
||||
const transformedPayload = {
|
||||
...restPayload,
|
||||
subscribe_plan_id: subscribe_plan.id,
|
||||
}
|
||||
try {
|
||||
const { subscribe_plan, ...restPayload } = payload
|
||||
const transformedPayload = {
|
||||
...restPayload,
|
||||
subscribe_plan_id: subscribe_plan.id,
|
||||
}
|
||||
const { data } = await HttpServer().post(
|
||||
'/api/user/register',
|
||||
transformedPayload,
|
||||
|
||||
@@ -23,6 +23,10 @@ body {
|
||||
@apply outline-none;
|
||||
}
|
||||
|
||||
.ProseMirror-trailingBreak {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
table.dataTable thead > tr {
|
||||
border-bottom: 2px solid #c2c2c2;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { JSX, SVGProps } from 'react'
|
||||
|
||||
export const LinkIcon = (
|
||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
x="0px"
|
||||
y="0px"
|
||||
width={95}
|
||||
height={95}
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
strokeWidth="0"
|
||||
viewBox="0 0 1024 1024"
|
||||
{...properties}
|
||||
>
|
||||
<path d="M574 665.4a8.03 8.03 0 0 0-11.3 0L446.5 781.6c-53.8 53.8-144.6 59.5-204 0-59.5-59.5-53.8-150.2 0-204l116.2-116.2c3.1-3.1 3.1-8.2 0-11.3l-39.8-39.8a8.03 8.03 0 0 0-11.3 0L191.4 526.5c-84.6 84.6-84.6 221.5 0 306s221.5 84.6 306 0l116.2-116.2c3.1-3.1 3.1-8.2 0-11.3L574 665.4zm258.6-474c-84.6-84.6-221.5-84.6-306 0L410.3 307.6a8.03 8.03 0 0 0 0 11.3l39.7 39.7c3.1 3.1 8.2 3.1 11.3 0l116.2-116.2c53.8-53.8 144.6-59.5 204 0 59.5 59.5 53.8 150.2 0 204L665.3 562.6a8.03 8.03 0 0 0 0 11.3l39.8 39.8c3.1 3.1 8.2 3.1 11.3 0l116.2-116.2c84.5-84.6 84.5-221.5 0-306.1zM610.1 372.3a8.03 8.03 0 0 0-11.3 0L372.3 598.7a8.03 8.03 0 0 0 0 11.3l39.6 39.6c3.1 3.1 8.2 3.1 11.3 0l226.4-226.4c3.1-3.1 3.1-8.2 0-11.3l-39.5-39.6z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,217 +119,208 @@ 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">
|
||||
<div className="flex items-start justify-between gap-4 px-4 py-3">
|
||||
<div className="flex divide-x">
|
||||
<div className="flex max-w-[150px] flex-wrap items-start gap-1 px-1">
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
disabled={
|
||||
disabled || !editor.can().chain().focus().toggleBold().run()
|
||||
}
|
||||
isActive={editor.isActive('bold')}
|
||||
title="Bold"
|
||||
>
|
||||
<BoldIcon className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
disabled={
|
||||
disabled || !editor.can().chain().focus().toggleItalic().run()
|
||||
}
|
||||
isActive={editor.isActive('italic')}
|
||||
title="Italic"
|
||||
>
|
||||
<ItalicIcon className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
disabled={
|
||||
disabled || !editor.can().chain().focus().toggleStrike().run()
|
||||
}
|
||||
isActive={editor.isActive('strike')}
|
||||
title="Strike"
|
||||
>
|
||||
<StrikethroughIcon className="size-4" />
|
||||
</EditorButton>
|
||||
<div className="relative">
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
disabled={
|
||||
disabled || !editor.can().chain().focus().toggleBold().run()
|
||||
}
|
||||
isActive={editor.isActive('bold')}
|
||||
title="Bold"
|
||||
onClick={() => setIsOpenColor(true)}
|
||||
title="Text Color"
|
||||
style={{
|
||||
color: rgbColor,
|
||||
}}
|
||||
isActive={true}
|
||||
disabled={disabled}
|
||||
>
|
||||
<BoldIcon />
|
||||
<SwatchIcon className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
disabled={
|
||||
disabled || !editor.can().chain().focus().toggleItalic().run()
|
||||
}
|
||||
isActive={editor.isActive('italic')}
|
||||
title="Italic"
|
||||
>
|
||||
<ItalicIcon />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
disabled={
|
||||
disabled || !editor.can().chain().focus().toggleStrike().run()
|
||||
}
|
||||
isActive={editor.isActive('strike')}
|
||||
title="Strike"
|
||||
>
|
||||
<StrikethroughIcon />
|
||||
</EditorButton>
|
||||
<div className="relative">
|
||||
<EditorButton
|
||||
onClick={() => setIsOpenColor(true)}
|
||||
title="Text Color"
|
||||
style={{
|
||||
color: rgbColor,
|
||||
}}
|
||||
isActive={true}
|
||||
disabled={disabled}
|
||||
{isOpenColor && (
|
||||
<div
|
||||
className="border-md absolute top-8 left-0"
|
||||
ref={popover}
|
||||
>
|
||||
<SwatchIcon />
|
||||
</EditorButton>
|
||||
{isOpenColor && (
|
||||
<div
|
||||
className="border-md absolute top-8 left-0"
|
||||
ref={popover}
|
||||
>
|
||||
<HexColorPicker
|
||||
className="z-10"
|
||||
<HexColorPicker
|
||||
className="z-10 rounded-lg shadow"
|
||||
color={rgbColor}
|
||||
onChange={handleChangeColor}
|
||||
/>
|
||||
<div className="">
|
||||
<HexColorInput
|
||||
color={rgbColor}
|
||||
onChange={handleChangeColor}
|
||||
prefixed
|
||||
className="relative z-10 mt-1 flex w-full rounded-lg border-0 bg-white px-3 py-2 text-sm shadow read-only:bg-gray-100 focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none focus-visible:outline-0 disabled:bg-gray-100"
|
||||
/>
|
||||
<div className="">
|
||||
<HexColorInput
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||
disabled={
|
||||
disabled ||
|
||||
!editor.can().chain().focus().setTextAlign('left').run()
|
||||
}
|
||||
isActive={editor.isActive({ textAlign: 'left' })}
|
||||
title="Align Left"
|
||||
>
|
||||
<Bars3BottomLeftIcon />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().setTextAlign('center').run()
|
||||
}
|
||||
disabled={
|
||||
disabled ||
|
||||
!editor.can().chain().focus().setTextAlign('center').run()
|
||||
}
|
||||
isActive={editor.isActive({ textAlign: 'center' })}
|
||||
title="Align Center"
|
||||
>
|
||||
<Bars3Icon />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||
disabled={
|
||||
disabled ||
|
||||
!editor.can().chain().focus().setTextAlign('right').run()
|
||||
}
|
||||
isActive={editor.isActive({ textAlign: 'right' })}
|
||||
title="Align Right"
|
||||
>
|
||||
<Bars3BottomRightIcon />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().setTextAlign('justify').run()
|
||||
}
|
||||
disabled={
|
||||
disabled ||
|
||||
!editor.can().chain().focus().setTextAlign('justify').run()
|
||||
}
|
||||
isActive={editor.isActive({ textAlign: 'justify' })}
|
||||
title="Align Justify"
|
||||
>
|
||||
<Bars4Icon />
|
||||
</EditorButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex max-w-[150px] flex-wrap items-start gap-1 px-1">
|
||||
<EditorButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 1 }).run()
|
||||
}
|
||||
isActive={editor.isActive('heading', { level: 1 })}
|
||||
title="Heading 1"
|
||||
disabled={disabled}
|
||||
>
|
||||
<H1Icon />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||||
}
|
||||
isActive={editor.isActive('heading', { level: 2 })}
|
||||
title="Heading 2"
|
||||
disabled={disabled}
|
||||
>
|
||||
<H2Icon />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 3 }).run()
|
||||
}
|
||||
isActive={editor.isActive('heading', { level: 3 })}
|
||||
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 />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
isActive={editor.isActive('codeBlock')}
|
||||
title="Code Block"
|
||||
disabled={disabled}
|
||||
>
|
||||
<CodeBracketIcon />
|
||||
</EditorButton>
|
||||
</div>
|
||||
{/* <div className="flex items-start gap-1 px-1">
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
isActive={editor.isActive('blockquote')}
|
||||
title="Blockquote"
|
||||
disabled={disabled}
|
||||
>
|
||||
<RiDoubleQuotesL />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||
disabled={
|
||||
disabled ||
|
||||
!editor.can().chain().focus().setTextAlign('left').run()
|
||||
}
|
||||
isActive={editor.isActive({ textAlign: 'left' })}
|
||||
title="Align Left"
|
||||
>
|
||||
<Bars3BottomLeftIcon className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||
disabled={
|
||||
disabled ||
|
||||
!editor.can().chain().focus().setTextAlign('center').run()
|
||||
}
|
||||
isActive={editor.isActive({ textAlign: 'center' })}
|
||||
title="Align Center"
|
||||
>
|
||||
<Bars3BottomCenterIcon className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||
disabled={
|
||||
disabled ||
|
||||
!editor.can().chain().focus().setTextAlign('right').run()
|
||||
}
|
||||
isActive={editor.isActive({ textAlign: 'right' })}
|
||||
title="Align Right"
|
||||
>
|
||||
<Bars3BottomRightIcon className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
|
||||
disabled={
|
||||
disabled ||
|
||||
!editor.can().chain().focus().setTextAlign('justify').run()
|
||||
}
|
||||
isActive={editor.isActive({ textAlign: 'justify' })}
|
||||
title="Align Justify"
|
||||
>
|
||||
<Bars3Icon className="size-4" />
|
||||
</EditorButton>
|
||||
</div>
|
||||
<div className="flex max-w-[150px] flex-wrap items-start gap-1 px-1">
|
||||
<EditorButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 1 }).run()
|
||||
}
|
||||
isActive={editor.isActive('heading', { level: 1 })}
|
||||
title="Heading 1"
|
||||
disabled={disabled}
|
||||
>
|
||||
<H1Icon className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||||
}
|
||||
isActive={editor.isActive('heading', { level: 2 })}
|
||||
title="Heading 2"
|
||||
disabled={disabled}
|
||||
>
|
||||
<H2Icon className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 3 }).run()
|
||||
}
|
||||
isActive={editor.isActive('heading', { level: 3 })}
|
||||
title="Heading 3"
|
||||
disabled={disabled}
|
||||
>
|
||||
<H3Icon className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
isActive={editor.isActive('codeBlock')}
|
||||
title="Code Block"
|
||||
disabled={disabled}
|
||||
>
|
||||
<CodeBracketIcon className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
isActive={editor.isActive('bulletList')}
|
||||
title="Bullet List"
|
||||
disabled={disabled}
|
||||
>
|
||||
<ListBulletIcon className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
isActive={editor.isActive('orderedList')}
|
||||
title="Ordered List"
|
||||
disabled={disabled}
|
||||
>
|
||||
<NumberedListIcon className="size-4" />
|
||||
</EditorButton>
|
||||
{/* <EditorButton
|
||||
onClick={() => editor.chain().focus().setParagraph().run()}
|
||||
isActive={editor.isActive('paragraph')}
|
||||
title="Paragraph"
|
||||
disabled={disabled}
|
||||
>
|
||||
<PilcrowIcon className="size-4" />
|
||||
</EditorButton> */}
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
isActive={editor.isActive('blockquote')}
|
||||
title="Blockquote"
|
||||
disabled={disabled}
|
||||
>
|
||||
<QuotationMarkIcon className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => {
|
||||
editor.chain().focus().unsetAllMarks().run()
|
||||
editor.chain().focus().clearNodes().run()
|
||||
}}
|
||||
title="Clear Format"
|
||||
disabled={disabled}
|
||||
>
|
||||
<XCircleIcon className="size-4" />
|
||||
</EditorButton>
|
||||
{/* <EditorButton
|
||||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||
title="Horizontal Rule"
|
||||
disabled={disabled}
|
||||
>
|
||||
<RiSeparator />
|
||||
</EditorButton>
|
||||
</div> */}
|
||||
{/* <div className="flex items-start gap-1 px-1">
|
||||
</EditorButton> */}
|
||||
</div>
|
||||
{/* <div className="flex items-start gap-1 px-1">
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().setHardBreak().run()}
|
||||
title="Hard Break"
|
||||
@@ -314,100 +328,97 @@ export const EditorMenuBar = (properties: TProperties) => {
|
||||
>
|
||||
<RiTextWrap />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => {
|
||||
editor.chain().focus().unsetAllMarks().run()
|
||||
editor.chain().focus().clearNodes().run()
|
||||
}}
|
||||
title="Clear Format"
|
||||
disabled={disabled}
|
||||
>
|
||||
<RiFormatClear />
|
||||
</EditorButton>
|
||||
</div> */}
|
||||
<div className="flex items-start gap-1 px-1">
|
||||
<div className="flex items-start gap-1 px-1">
|
||||
<div className="relative">
|
||||
<EditorButton
|
||||
onClick={() => toggleUpload()}
|
||||
onClick={() => setIsOpenImage(true)}
|
||||
title="Insert Image"
|
||||
disabled={disabled}
|
||||
>
|
||||
<PhotoIcon />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => setLink()}
|
||||
disabled={
|
||||
disabled ||
|
||||
!editor
|
||||
.can()
|
||||
.chain()
|
||||
.focus()
|
||||
.extendMarkRange('link')
|
||||
.setLink({ href: '' })
|
||||
.run()
|
||||
}
|
||||
isActive={editor.isActive('link')}
|
||||
title="Set Link"
|
||||
>
|
||||
<LinkIcon />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().unsetLink().run()}
|
||||
disabled={disabled || !editor.isActive('link')}
|
||||
title="Unset Link"
|
||||
>
|
||||
<LinkSlashIcon />
|
||||
</EditorButton>
|
||||
</div>
|
||||
<div className="flex items-start gap-1 px-1">
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={disabled || !editor.can().chain().focus().undo().run()}
|
||||
title="Undo"
|
||||
>
|
||||
<ArrowUturnLeftIcon />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={disabled || !editor.can().chain().focus().redo().run()}
|
||||
title="Redo"
|
||||
>
|
||||
<ArrowUturnRightIcon />
|
||||
<PhotoIcon className="size-4" />
|
||||
</EditorButton>
|
||||
{isOpenImage && (
|
||||
<div
|
||||
className="border-md absolute top-8 left-0 w-50"
|
||||
ref={popover}
|
||||
>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder="Masukkan URL Gambar"
|
||||
color={rgbColor}
|
||||
onChange={(event) => {
|
||||
setImageUrl(event.target.value)
|
||||
}}
|
||||
className="z-10 flex h-[42px] w-full rounded-lg border-0 bg-white p-2 pr-8 text-sm shadow read-only:bg-gray-100 focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none focus-visible:outline-0 disabled:bg-gray-100"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
size="fit"
|
||||
className="absolute top-0 right-3 h-[42px]"
|
||||
onClick={() => {
|
||||
setIsUploadOpen('content')
|
||||
}}
|
||||
>
|
||||
<CloudArrowUpIcon className="size-4 text-gray-500/50" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<EditorButton
|
||||
onClick={() => setLink()}
|
||||
disabled={
|
||||
disabled ||
|
||||
!editor
|
||||
.can()
|
||||
.chain()
|
||||
.focus()
|
||||
.extendMarkRange('link')
|
||||
.setLink({ href: '' })
|
||||
.run()
|
||||
}
|
||||
isActive={editor.isActive('link')}
|
||||
title="Set Link"
|
||||
>
|
||||
<LinkIcon className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().unsetLink().run()}
|
||||
disabled={disabled || !editor.isActive('link')}
|
||||
title="Unset Link"
|
||||
>
|
||||
<LinkSlashIcon className="size-4" />
|
||||
</EditorButton>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex gap-1 px-1">
|
||||
<EditorButton
|
||||
onClick={() => setIsPlainHTML(true)}
|
||||
title="Switch to Plain Text"
|
||||
>
|
||||
<DocumentTextIcon />
|
||||
</EditorButton>
|
||||
</div>
|
||||
<div className="flex items-start gap-1 px-1">
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={disabled || !editor.can().chain().focus().undo().run()}
|
||||
title="Undo"
|
||||
>
|
||||
<ArrowUturnLeftIcon className="size-4" />
|
||||
</EditorButton>
|
||||
<EditorButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={disabled || !editor.can().chain().focus().redo().run()}
|
||||
title="Redo"
|
||||
>
|
||||
<ArrowUturnRightIcon className="size-4" />
|
||||
</EditorButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <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> */}
|
||||
</>
|
||||
<div>
|
||||
<div className="flex gap-1 px-1">
|
||||
<EditorButton
|
||||
onClick={() => setIsPlainHTML(true)}
|
||||
title="Switch to Plain Text"
|
||||
>
|
||||
<DocumentTextIcon className="size-4" />
|
||||
</EditorButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Link } from 'react-router'
|
||||
|
||||
type BreadcrumbProperty = {
|
||||
slug: string
|
||||
}
|
||||
|
||||
export const Breadcrumb = (property: BreadcrumbProperty) => {
|
||||
const { slug } = property
|
||||
return (
|
||||
<div className="mb-5 flex items-center gap-2">
|
||||
<Link to={'#'}>Blog</Link>
|
||||
<div>{'>'}</div>
|
||||
<Link
|
||||
to={'#'}
|
||||
className="text-ellipsis md:text-clip ..."
|
||||
>
|
||||
{slug.slice(0, 20) + '...'}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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'>({
|
||||
as,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
className,
|
||||
...properties
|
||||
}: ButtonProperties<C>) => {
|
||||
export const Button = <C extends ElementType = 'button'>(
|
||||
properties: ButtonProperties<C>,
|
||||
) => {
|
||||
const {
|
||||
as,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
className,
|
||||
isLoading = false,
|
||||
icon,
|
||||
...restProperties
|
||||
} = properties
|
||||
const Component = as || HeadlessButton
|
||||
const classes = twMerge(buttonVariants({ variant, size, className }))
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={classes}
|
||||
{...properties}
|
||||
{...restProperties}
|
||||
>
|
||||
{isLoading ? <ArrowPathIcon className="size-5 animate-spin" /> : icon}
|
||||
{children}
|
||||
</Component>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,41 +75,81 @@ export const CarouselHero = (properties: TNews) => {
|
||||
ref={emblaReference}
|
||||
>
|
||||
<div className="embla__container hero flex sm:gap-x-8">
|
||||
{items.map(({ featured, title, content, slug, isPremium }, index) => (
|
||||
<div
|
||||
className="embla__slide hero w-full min-w-0 flex-none"
|
||||
key={index}
|
||||
>
|
||||
<div className="max-sm:mt-2 sm:flex">
|
||||
<img
|
||||
className="col-span-2 aspect-[174/100] object-cover"
|
||||
src={featured}
|
||||
alt={title}
|
||||
/>
|
||||
<div className="flex h-full flex-col justify-between gap-7 sm:px-5">
|
||||
<div>
|
||||
<h3 className="mt-2 w-full text-2xl font-bold sm:mt-0 sm:text-4xl">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-md mt-5 text-[#777777] sm:text-xl">
|
||||
{content}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="block"
|
||||
{...getPremiumAttribute({
|
||||
isPremium,
|
||||
slug,
|
||||
onClick: () => setIsSuccessOpen('warning'),
|
||||
userData,
|
||||
})}
|
||||
>
|
||||
View More
|
||||
</Button>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="embla__slide hero flex w-full min-w-0 flex-none animate-pulse justify-between gap-3 max-sm:mt-2 sm:flex">
|
||||
<div className="flex aspect-[174/100] h-full items-center justify-center rounded-md bg-gray-300 dark:bg-gray-700">
|
||||
<ImageSkeletonIcon />
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-7">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="h-6 w-full rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-6 w-[20%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="h-5 rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 max-w-[90%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 max-w-[90%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 max-w-[90%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 max-w-[90%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 max-w-[50%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<div className="h-[50px] w-full bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
}
|
||||
>
|
||||
<Await
|
||||
resolve={items}
|
||||
errorElement={<ErrorAwait />}
|
||||
>
|
||||
{(value) =>
|
||||
value.data.map(
|
||||
(
|
||||
{ featured_image, title, content, slug, is_premium },
|
||||
index,
|
||||
) => (
|
||||
<div
|
||||
className="embla__slide hero w-full min-w-0 flex-none max-sm:mt-2 sm:flex"
|
||||
key={index}
|
||||
>
|
||||
<img
|
||||
className="aspect-[174/100] h-full rounded-md object-cover"
|
||||
src={featured_image}
|
||||
alt={title}
|
||||
/>
|
||||
<div className="flex h-full flex-col justify-between gap-7 sm:px-5">
|
||||
<div>
|
||||
<h3 className="mt-2 w-full text-2xl font-bold sm:mt-0 sm:text-4xl">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-md mt-5 line-clamp-10 text-[#777777] sm:text-xl">
|
||||
{stripHtml(content).result}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="block"
|
||||
{...getPremiumAttribute({
|
||||
isPremium: is_premium,
|
||||
slug,
|
||||
onClick: () => setIsSuccessOpen('warning'),
|
||||
userData,
|
||||
})}
|
||||
>
|
||||
View More
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 flex-col justify-between">
|
||||
<img
|
||||
className="aspect-[5/4] max-h-[280px] w-full rounded-md object-cover"
|
||||
src={featured}
|
||||
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">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-md mt-5 text-[#777777] sm:text-xl">
|
||||
{content}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="block"
|
||||
{...getPremiumAttribute({
|
||||
isPremium,
|
||||
slug,
|
||||
onClick: () => setIsSuccessOpen('warning'),
|
||||
userData,
|
||||
})}
|
||||
className="mb-5"
|
||||
>
|
||||
View More
|
||||
</Button>
|
||||
<div className="flex h-[280px] w-full items-center justify-center rounded-md bg-gray-300 dark:bg-gray-700">
|
||||
<ImageSkeletonIcon />
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="h-6 w-full rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-6 w-[20%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="h-5 max-w-[80%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 max-w-[50%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[50px] w-full bg-gray-200 dark:bg-gray-700" />
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
))}
|
||||
>
|
||||
<Await
|
||||
resolve={items}
|
||||
errorElement={<ErrorAwait />}
|
||||
>
|
||||
{(value) =>
|
||||
value.data.map(
|
||||
(
|
||||
{ featured_image, title, content, tags, slug, is_premium },
|
||||
index,
|
||||
) => (
|
||||
<div
|
||||
className="embla__slide flex w-full min-w-0 flex-none flex-col justify-between gap-3 sm:w-1/3"
|
||||
key={index}
|
||||
>
|
||||
<img
|
||||
className="aspect-[174/100] max-h-[280px] w-full rounded-md object-cover sm:aspect-[5/4]"
|
||||
src={featured_image}
|
||||
alt={title}
|
||||
/>
|
||||
<div className={'flex flex-col justify-between gap-4'}>
|
||||
<div className="flex h-28 flex-col items-start justify-center gap-4">
|
||||
<Tags
|
||||
tags={tags || []}
|
||||
is_premium={is_premium}
|
||||
/>
|
||||
<h3 className="mt-2 line-clamp-2 w-full text-xl font-bold sm:text-2xl lg:mt-0">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="line-clamp-3 text-base text-[#777777] sm:text-xl">
|
||||
{stripHtml(content).result}
|
||||
</p>
|
||||
<Button
|
||||
size="block"
|
||||
{...getPremiumAttribute({
|
||||
isPremium: is_premium,
|
||||
slug,
|
||||
onClick: () => setIsSuccessOpen('warning'),
|
||||
userData,
|
||||
})}
|
||||
>
|
||||
View More
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,68 +44,87 @@ 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"
|
||||
>
|
||||
<img
|
||||
className={twMerge(
|
||||
'aspect-[5/4] w-full rounded-md object-cover',
|
||||
)}
|
||||
src={featured}
|
||||
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>
|
||||
<h3
|
||||
className={twMerge(
|
||||
'mt-2 w-full text-xl font-bold sm:mt-0 sm:text-2xl',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-md mt-5 text-[#777777] sm:text-xl">
|
||||
{content}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="block"
|
||||
{...getPremiumAttribute({
|
||||
isPremium,
|
||||
slug,
|
||||
onClick: () => setIsSuccessOpen('warning'),
|
||||
userData,
|
||||
})}
|
||||
className="mb-5"
|
||||
>
|
||||
View More
|
||||
</Button>
|
||||
<div className="flex h-[280px] w-full items-center justify-center rounded-md bg-gray-300 dark:bg-gray-700">
|
||||
<ImageSkeletonIcon />
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="h-6 w-full rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-6 w-[20%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="h-5 max-w-[80%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-5 max-w-[50%] rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[50px] w-full bg-gray-200 dark:bg-gray-700" />
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
))}
|
||||
>
|
||||
<Await
|
||||
resolve={items}
|
||||
errorElement={<ErrorAwait />}
|
||||
>
|
||||
{(value) =>
|
||||
value.data.map(
|
||||
(
|
||||
{ featured_image, title, content, tags, slug, is_premium },
|
||||
index,
|
||||
) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid gap-3 sm:gap-x-8"
|
||||
>
|
||||
<img
|
||||
className="aspect-[174/100] w-full rounded-md object-cover sm:aspect-[5/4]"
|
||||
src={featured_image}
|
||||
alt={title}
|
||||
/>
|
||||
<div className="flex flex-col justify-between gap-4">
|
||||
<Tags
|
||||
tags={tags}
|
||||
is_premium={is_premium}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h3
|
||||
className={twMerge(
|
||||
'mt-2 w-full text-xl font-bold sm:mt-0 sm:text-2xl',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-md mt-5 line-clamp-3 text-[#777777] sm:text-xl">
|
||||
{stripHtml(content).result}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="block"
|
||||
{...getPremiumAttribute({
|
||||
isPremium: is_premium,
|
||||
slug,
|
||||
onClick: () => setIsSuccessOpen('warning'),
|
||||
userData,
|
||||
})}
|
||||
className="mb-5"
|
||||
>
|
||||
View More
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="my-5 mt-5 flex flex-row-reverse">
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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,
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { TAuthorResponse } from '~/apis/common/get-news'
|
||||
import { formatDate } from '~/utils/formatter'
|
||||
|
||||
type TDetailNewsAuthor = {
|
||||
author?: TAuthorResponse
|
||||
live_at?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
export const NewsAuthor = ({ author, live_at, text }: TDetailNewsAuthor) => {
|
||||
return (
|
||||
<div className="mb-2 flex items-center gap-2 align-middle">
|
||||
<img
|
||||
src={author?.profile_picture || '/images/profile-placeholder.svg'}
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = '/images/profile-placeholder.svg'
|
||||
}}
|
||||
alt={author?.name}
|
||||
className="size-12 rounded-full bg-[#C4C4C4] object-cover"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h4 className="text-md">{author?.name}</h4>
|
||||
<p className="flex gap-1 text-sm">
|
||||
<span>{live_at && `${formatDate(live_at)}`}</span>
|
||||
<span>·</span>
|
||||
<span>{text}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export const Newsletter = (property: NewsletterProperties) => {
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="newsPrimary"
|
||||
variant="primary"
|
||||
size="block"
|
||||
>
|
||||
Subscribe
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,59 +1,79 @@
|
||||
import type { FC } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { LinkIcon } from '@heroicons/react/24/solid'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
FacebookShareButton,
|
||||
LinkedinShareButton,
|
||||
TwitterShareButton,
|
||||
} from 'react-share'
|
||||
|
||||
import { FacebookIcon } from '~/components/icons/facebook'
|
||||
import { InstagramIcon } from '~/components/icons/instagram'
|
||||
import { LinkIcon } from '~/components/icons/link-icon'
|
||||
import { LinkedinIcon } from '~/components/icons/linkedin'
|
||||
import { XIcon } from '~/components/icons/x'
|
||||
|
||||
type SocialMediaProperties = {
|
||||
className?: string
|
||||
slug?: string
|
||||
type SocialShareButtonsProperties = {
|
||||
url: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const dataSocialMedia = [
|
||||
{
|
||||
type: 'link',
|
||||
url: 'post-id/',
|
||||
icon: LinkIcon,
|
||||
},
|
||||
{
|
||||
type: 'facebook',
|
||||
url: 'https://facebook.com/',
|
||||
icon: FacebookIcon,
|
||||
},
|
||||
{
|
||||
type: 'linkedin',
|
||||
url: 'https://linkedin.com/',
|
||||
icon: LinkedinIcon,
|
||||
},
|
||||
{
|
||||
type: 'x',
|
||||
url: 'https://x.com/',
|
||||
icon: XIcon,
|
||||
},
|
||||
{
|
||||
type: 'instagram',
|
||||
url: 'https://instagram.com/',
|
||||
icon: InstagramIcon,
|
||||
},
|
||||
]
|
||||
export const SocialShareButtons = ({
|
||||
url,
|
||||
title,
|
||||
}: SocialShareButtonsProperties) => {
|
||||
const [showPopup, setShowPopup] = useState(false)
|
||||
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard.writeText(url)
|
||||
setShowPopup(true)
|
||||
setTimeout(() => setShowPopup(false), 2000)
|
||||
}
|
||||
|
||||
const handleInstagramShare = () => {
|
||||
const instagramUrl = `https://www.instagram.com/direct/new/?text=${encodeURIComponent(title + ' ' + url)}`
|
||||
window.open(instagramUrl, '_blank')
|
||||
}
|
||||
|
||||
export const IconsSocial: FC<SocialMediaProperties> = ({ className }) => {
|
||||
return (
|
||||
<div className={twMerge('flex gap-2', className)}>
|
||||
{dataSocialMedia.map(({ url, icon: Icon }, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
to={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon className="h-8 w-8 rounded-full bg-gray-400 p-2 sm:h-10 sm:w-10" />
|
||||
</Link>
|
||||
))}
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className="relative cursor-pointer"
|
||||
>
|
||||
<LinkIcon className="size-8 rounded-full bg-[#F4F4F4] p-2 transition hover:bg-gray-200 hover:shadow active:bg-gray-300 sm:h-10 sm:w-10" />
|
||||
{showPopup && (
|
||||
<div className="absolute top-12 w-48 rounded-lg border-2 border-gray-400 bg-white p-2 shadow-lg">
|
||||
Link berhasil disalin!
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<FacebookShareButton
|
||||
url={url}
|
||||
title={title}
|
||||
>
|
||||
<FacebookIcon className="size-8 rounded-full bg-[#F4F4F4] p-2 transition hover:bg-gray-200 hover:shadow active:bg-gray-300 sm:h-10 sm:w-10" />
|
||||
</FacebookShareButton>
|
||||
|
||||
<LinkedinShareButton
|
||||
url={url}
|
||||
title={title}
|
||||
>
|
||||
<LinkedinIcon className="size-8 rounded-full bg-[#F4F4F4] p-2 transition hover:bg-gray-200 hover:shadow active:bg-gray-300 sm:h-10 sm:w-10" />
|
||||
</LinkedinShareButton>
|
||||
|
||||
<TwitterShareButton
|
||||
url={url}
|
||||
title={title}
|
||||
>
|
||||
<XIcon className="size-8 rounded-full bg-[#F4F4F4] p-2 transition hover:bg-gray-200 hover:shadow active:bg-gray-300 sm:h-10 sm:w-10" />
|
||||
</TwitterShareButton>
|
||||
|
||||
<button
|
||||
onClick={handleInstagramShare}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<InstagramIcon className="size-8 rounded-full bg-[#F4F4F4] p-2 transition hover:bg-gray-200 hover:shadow active:bg-gray-300 sm:h-10 sm:w-10" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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 focus:outline-none">
|
||||
<div className="flex items-center">
|
||||
{staffData?.profile_picture === '' ? (
|
||||
<ProfileIcon className="mr-3 h-8 w-8 rounded-full bg-[#C4C4C4]" />
|
||||
) : (
|
||||
<img
|
||||
src={staffData?.profile_picture}
|
||||
alt={staffData?.name}
|
||||
className="mr-3 h-8 w-8 rounded-full bg-[#C4C4C4] object-cover"
|
||||
/>
|
||||
)}
|
||||
<PopoverButton className="flex w-3xs cursor-pointer items-center justify-between rounded-xl p-2 ring-1 ring-[#707FDD]/10 hover:shadow focus:outline-none">
|
||||
<div className="flex items-center space-x-3">
|
||||
<img
|
||||
src={
|
||||
staffData?.profile_picture ||
|
||||
'/images/profile-placeholder.svg'
|
||||
}
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = '/images/profile-placeholder.svg'
|
||||
}}
|
||||
alt={staffData?.name}
|
||||
className="size-8 rounded-full bg-[#C4C4C4] object-cover"
|
||||
/>
|
||||
|
||||
<span className="text-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"
|
||||
>
|
||||
<fetcher.Form
|
||||
method="POST"
|
||||
action="/actions/admin/logout"
|
||||
className="grid"
|
||||
>
|
||||
<div className="p-2">
|
||||
<Button
|
||||
type="submit"
|
||||
className="cursor-pointer rounded p-1 hover:bg-[#707FDD]/10 hover:text-[#5363AB]"
|
||||
variant="secondary"
|
||||
className="w-full justify-start rounded p-1 px-3 text-base font-bold"
|
||||
onClick={() => {
|
||||
setEditProfile(true)
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
<UserIcon className="size-5" />
|
||||
<span>Profile</span>
|
||||
</Button>
|
||||
</fetcher.Form>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<fetcher.Form
|
||||
method="POST"
|
||||
action="/actions/admin/logout"
|
||||
className="grid"
|
||||
>
|
||||
<Button
|
||||
disabled={fetcher.state !== 'idle'}
|
||||
isLoading={fetcher.state !== 'idle'}
|
||||
type="submit"
|
||||
className="w-full justify-start rounded p-1 px-3 text-base font-bold"
|
||||
variant="secondary"
|
||||
icon={<ArrowRightStartOnRectangleIcon className="size-5" />}
|
||||
>
|
||||
<span>Logout</span>
|
||||
</Button>
|
||||
</fetcher.Form>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export const FooterNewsletter = () => {
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="newsPrimaryOutline"
|
||||
variant="primaryOutline"
|
||||
size="block"
|
||||
>
|
||||
Subscribe
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user