Compare commits
No commits in common. "9a0c6c1f0b667c51dac2e984d4da21ff90b716f3" and "e7eda086e42016847d9d1c943d6accbcc9e5d15b" have entirely different histories.
9a0c6c1f0b
...
e7eda086e4
@ -1,39 +0,0 @@
|
|||||||
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.string(),
|
|
||||||
auto_renew: z.boolean(),
|
|
||||||
subscribe_plan: subscribePlanResponseSchema,
|
|
||||||
})
|
|
||||||
const userResponseSchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
email: z.string().email(),
|
|
||||||
phone: z.string(),
|
|
||||||
subscribe: subscribeResponseSchema,
|
|
||||||
})
|
|
||||||
const usersResponseSchema = z.object({
|
|
||||||
data: z.array(userResponseSchema),
|
|
||||||
})
|
|
||||||
|
|
||||||
export type TUserResponse = z.infer<typeof userResponseSchema>
|
|
||||||
|
|
||||||
export const getUsers = async (parameters: THttpServer) => {
|
|
||||||
try {
|
|
||||||
const { data } = await HttpServer(parameters).get(`/api/staff/users`)
|
|
||||||
return usersResponseSchema.parse(data)
|
|
||||||
} catch (error) {
|
|
||||||
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
import type { TUploadSchema } from '~/layouts/admin/form-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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
28
app/components/icons/chevron-double.tsx
Normal file
28
app/components/icons/chevron-double.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -21,7 +21,7 @@ export const EditorButton = (properties: TProperties) => {
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={twMerge(
|
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:opacity-50',
|
'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',
|
||||||
isActive ? 'bg-[#2E2F7C]/10' : '',
|
isActive ? 'bg-[#2E2F7C]/10' : '',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -71,7 +71,7 @@ export const UiChartPie = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[300px] w-full items-center justify-center rounded-lg bg-white p-5 text-center">
|
<div className="h-[300px] w-full items-center justify-center rounded-lg bg-white p-5 text-center">
|
||||||
<h2 className="text-xl font-bold">Top 5 Artikel</h2>
|
<h2 className="text-xl font-bold">Top 5 Konten</h2>
|
||||||
<Pie
|
<Pie
|
||||||
height={225}
|
height={225}
|
||||||
width={450}
|
width={450}
|
||||||
|
|||||||
@ -1,93 +0,0 @@
|
|||||||
import { Field, Label, Input as HeadlessInput } from '@headlessui/react'
|
|
||||||
import { CloudArrowUpIcon } from '@heroicons/react/20/solid'
|
|
||||||
import { useEffect, type ComponentProps, type ReactNode } from 'react'
|
|
||||||
import {
|
|
||||||
get,
|
|
||||||
type FieldError,
|
|
||||||
type FieldValues,
|
|
||||||
type Path,
|
|
||||||
type RegisterOptions,
|
|
||||||
} from 'react-hook-form'
|
|
||||||
import { useRemixFormContext } from 'remix-hook-form'
|
|
||||||
import { twMerge } from 'tailwind-merge'
|
|
||||||
|
|
||||||
import { useAdminContext } from '~/contexts/admin'
|
|
||||||
|
|
||||||
import { Button } from './button'
|
|
||||||
|
|
||||||
type TInputProperties<T extends FieldValues> = Omit<
|
|
||||||
ComponentProps<'input'>,
|
|
||||||
'size'
|
|
||||||
> & {
|
|
||||||
id: string
|
|
||||||
label?: ReactNode
|
|
||||||
name: Path<T>
|
|
||||||
rules?: RegisterOptions
|
|
||||||
containerClassName?: string
|
|
||||||
labelClassName?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const InputFile = <TFormValues extends Record<string, unknown>>(
|
|
||||||
properties: TInputProperties<TFormValues>,
|
|
||||||
) => {
|
|
||||||
const {
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
name,
|
|
||||||
rules,
|
|
||||||
placeholder,
|
|
||||||
disabled,
|
|
||||||
className,
|
|
||||||
containerClassName,
|
|
||||||
labelClassName,
|
|
||||||
...restProperties
|
|
||||||
} = properties
|
|
||||||
const { setIsUploadOpen, uploadedFile } = useAdminContext()
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
formState: { errors },
|
|
||||||
setValue,
|
|
||||||
} = useRemixFormContext()
|
|
||||||
|
|
||||||
const error: FieldError = get(errors, name)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (uploadedFile) {
|
|
||||||
setValue('featured_image', uploadedFile)
|
|
||||||
}
|
|
||||||
// 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('featured_image')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CloudArrowUpIcon className="h-4 w-4 text-gray-500/50" />
|
|
||||||
</Button>
|
|
||||||
</Field>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -63,7 +63,7 @@ export const Input = <TFormValues extends Record<string, unknown>>(
|
|||||||
<HeadlessInput
|
<HeadlessInput
|
||||||
type={inputType}
|
type={inputType}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
'h-[42px] w-full rounded-md border border-[#DFDFDF] p-2 pr-8',
|
'h-[42px] w-full rounded-md border border-[#DFDFDF] p-2',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
placeholder={inputType === 'password' ? '******' : placeholder}
|
placeholder={inputType === 'password' ? '******' : placeholder}
|
||||||
@ -75,7 +75,7 @@ export const Input = <TFormValues extends Record<string, unknown>>(
|
|||||||
type="button"
|
type="button"
|
||||||
variant="icon"
|
variant="icon"
|
||||||
size="fit"
|
size="fit"
|
||||||
className="absolute right-3 h-[42px]"
|
className="absolute right-3 h-[42px] text-gray-500"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setInputType(inputType === 'password' ? 'text' : 'password')
|
setInputType(inputType === 'password' ? 'text' : 'password')
|
||||||
}
|
}
|
||||||
|
|||||||
66
app/components/ui/pagination.tsx
Normal file
66
app/components/ui/pagination.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,10 +1,14 @@
|
|||||||
import DT from 'datatables.net-dt'
|
import DT, { type Config, type ConfigColumns } from 'datatables.net-dt'
|
||||||
import DataTable, { type DataTableProps } from 'datatables.net-react'
|
import DataTable from 'datatables.net-react'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
type UiTableProperties = {
|
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
|
title: string
|
||||||
} & DataTableProps
|
}
|
||||||
|
|
||||||
const renderPaginationIcon = (icon: string) => {
|
const renderPaginationIcon = (icon: string) => {
|
||||||
return `<div class="pagination-icon">${icon}</div>`
|
return `<div class="pagination-icon">${icon}</div>`
|
||||||
@ -23,14 +27,11 @@ export const UiTable: React.FC<UiTableProperties> = ({
|
|||||||
<h3 className="py-1 font-semibold text-[#4C5CA0]">{title}</h3>
|
<h3 className="py-1 font-semibold text-[#4C5CA0]">{title}</h3>
|
||||||
<div className="rounded-lg">
|
<div className="rounded-lg">
|
||||||
<DataTable
|
<DataTable
|
||||||
className="cell-border text-sm"
|
className="cell-border"
|
||||||
data={data}
|
data={data}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
slots={slots}
|
slots={slots}
|
||||||
options={{
|
options={{
|
||||||
headerCallback: (thead) => {
|
|
||||||
thead.classList.add('text-left')
|
|
||||||
},
|
|
||||||
paging: true,
|
paging: true,
|
||||||
searching: true,
|
searching: true,
|
||||||
ordering: true,
|
ordering: true,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { MENU as ADMIN_MENU, SUB_MENU } from '~/layouts/admin/menu'
|
import { MENU as ADMIN_MENU } from '~/layouts/admin/menu'
|
||||||
|
|
||||||
export const APP = {
|
export const APP = {
|
||||||
title: 'LegalGo',
|
title: 'LegalGo',
|
||||||
@ -23,5 +23,4 @@ export const META_TITLE_CONFIG: TMetaTitleConfig = [
|
|||||||
...ADMIN_MENU.flatMap((menu) =>
|
...ADMIN_MENU.flatMap((menu) =>
|
||||||
menu.items.map((item) => ({ path: item.url, title: item.title })),
|
menu.items.map((item) => ({ path: item.url, title: item.title })),
|
||||||
),
|
),
|
||||||
...SUB_MENU,
|
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,52 +0,0 @@
|
|||||||
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()
|
|
||||||
|
|
||||||
type TUpload = z.infer<typeof uploadCategorySchema>
|
|
||||||
|
|
||||||
type AdminContextProperties = {
|
|
||||||
isUploadOpen: TUpload
|
|
||||||
setIsUploadOpen: Dispatch<SetStateAction<TUpload>>
|
|
||||||
uploadedFile?: string
|
|
||||||
setUploadedFile: Dispatch<SetStateAction<string | undefined>>
|
|
||||||
}
|
|
||||||
|
|
||||||
const AdminContext = createContext<AdminContextProperties | undefined>(
|
|
||||||
undefined,
|
|
||||||
)
|
|
||||||
|
|
||||||
export const AdminProvider = ({ children }: PropsWithChildren) => {
|
|
||||||
const [isUploadOpen, setIsUploadOpen] = useState<TUpload>()
|
|
||||||
const [uploadedFile, setUploadedFile] = useState<string | undefined>()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminContext.Provider
|
|
||||||
value={{
|
|
||||||
isUploadOpen,
|
|
||||||
setIsUploadOpen,
|
|
||||||
uploadedFile,
|
|
||||||
setUploadedFile,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</AdminContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useAdminContext = (): AdminContextProperties => {
|
|
||||||
const context = useContext(AdminContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useAdminContext must be used within a AdminProvider')
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
@ -1,15 +1,10 @@
|
|||||||
import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'
|
|
||||||
import type { PropsWithChildren } from 'react'
|
import type { PropsWithChildren } from 'react'
|
||||||
|
|
||||||
import { useAdminContext } from '~/contexts/admin'
|
|
||||||
|
|
||||||
import { FormUpload } from './form-upload'
|
|
||||||
import { Navbar } from './navbar'
|
import { Navbar } from './navbar'
|
||||||
import { Sidebar } from './sidebar'
|
import { Sidebar } from './sidebar'
|
||||||
|
|
||||||
export const AdminDashboardLayout = (properties: PropsWithChildren) => {
|
export const AdminDashboardLayout = (properties: PropsWithChildren) => {
|
||||||
const { children } = properties
|
const { children } = properties
|
||||||
const { isUploadOpen, setIsUploadOpen } = useAdminContext()
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
@ -17,28 +12,6 @@ export const AdminDashboardLayout = (properties: PropsWithChildren) => {
|
|||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="min-h-[calc(100dvh-80px)] flex-1 p-8">{children}</div>
|
<div className="min-h-[calc(100dvh-80px)] flex-1 p-8">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={!!isUploadOpen}
|
|
||||||
onClose={() => {
|
|
||||||
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="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"
|
|
||||||
>
|
|
||||||
<FormUpload />
|
|
||||||
</DialogPanel>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,122 +0,0 @@
|
|||||||
import { Button } from '@headlessui/react'
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
|
||||||
import { useEffect, useState, type ChangeEvent } from 'react'
|
|
||||||
import { useFetcher } from 'react-router'
|
|
||||||
import { RemixFormProvider, useRemixForm } from 'remix-hook-form'
|
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
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 FormUpload = () => {
|
|
||||||
const { isUploadOpen, setUploadedFile, setIsUploadOpen } = useAdminContext()
|
|
||||||
const fetcher = useFetcher()
|
|
||||||
const [disabled, setDisabled] = useState(false)
|
|
||||||
const [error, setError] = useState<string>()
|
|
||||||
const maxFileSize = 1024 // 1MB
|
|
||||||
|
|
||||||
const formMethods = useRemixForm<TUploadSchema>({
|
|
||||||
mode: 'onSubmit',
|
|
||||||
fetcher,
|
|
||||||
resolver: zodResolver(uploadSchema),
|
|
||||||
})
|
|
||||||
|
|
||||||
const { handleSubmit, register, setValue } = formMethods
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!fetcher.data?.success) {
|
|
||||||
setError(fetcher.data?.message)
|
|
||||||
setDisabled(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploadedFile(fetcher.data.uploadData.data.file_url)
|
|
||||||
setIsUploadOpen(undefined)
|
|
||||||
|
|
||||||
setDisabled(true)
|
|
||||||
setError(undefined)
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [fetcher])
|
|
||||||
|
|
||||||
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/')) {
|
|
||||||
setError('Please upload an image file.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > maxFileSize * 1024) {
|
|
||||||
setError(`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 (
|
|
||||||
<RemixFormProvider {...formMethods}>
|
|
||||||
<fetcher.Form
|
|
||||||
method="post"
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className="space-y-4"
|
|
||||||
action="/actions/admin/upload"
|
|
||||||
encType="multipart/form-data"
|
|
||||||
>
|
|
||||||
{error && (
|
|
||||||
<div className="text-sm text-red-500 capitalize">{error}</div>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
id="input-file-upload"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
id="input-file-upload-type"
|
|
||||||
value={isUploadOpen}
|
|
||||||
{...register('category')}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
disabled={disabled}
|
|
||||||
type="submit"
|
|
||||||
className="w-full rounded-md bg-[#2E2F7C] py-2 text-white transition hover:bg-blue-800"
|
|
||||||
>
|
|
||||||
Upload
|
|
||||||
</Button>
|
|
||||||
</fetcher.Form>
|
|
||||||
</RemixFormProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -30,7 +30,7 @@ export const MENU: TMenu[] = [
|
|||||||
icon: DocumentIcon,
|
icon: DocumentIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Artikel',
|
title: 'Konten',
|
||||||
url: '/lg-admin/contents',
|
url: '/lg-admin/contents',
|
||||||
icon: ChatIcon,
|
icon: ChatIcon,
|
||||||
},
|
},
|
||||||
@ -59,38 +59,6 @@ export const MENU: TMenu[] = [
|
|||||||
url: '/lg-admin/tags',
|
url: '/lg-admin/tags',
|
||||||
icon: TagIcon,
|
icon: TagIcon,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'Subscribe Plan',
|
|
||||||
url: '/lg-admin/subscribe-plan',
|
|
||||||
icon: TagIcon,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
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,7 +1,5 @@
|
|||||||
import { Field, Input, Label, Select } from '@headlessui/react'
|
import { Field, Input, Label, Select } from '@headlessui/react'
|
||||||
import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'
|
import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'
|
||||||
import type { ConfigColumns } from 'datatables.net-dt'
|
|
||||||
import type { DataTableSlots } from 'datatables.net-react'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { twMerge } from 'tailwind-merge'
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
@ -38,14 +36,14 @@ export const AdvertisementsPage = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dataBanner = BANNER
|
const dataBanner = BANNER
|
||||||
const dataColumns: ConfigColumns[] = [
|
const dataColumns = [
|
||||||
{ title: 'No', data: 'id' },
|
{ title: 'No', data: 'id' },
|
||||||
{ title: 'Banner', data: 'urlImage' },
|
{ title: 'Banner', data: 'urlImage' },
|
||||||
{ title: 'Link', data: 'link' },
|
{ title: 'Link', data: 'link' },
|
||||||
{ title: 'Tgl Create', data: 'createdAt' },
|
{ title: 'Tgl Create', data: 'createdAt' },
|
||||||
{ title: 'Status', data: 'status' },
|
{ title: 'Status', data: 'status' },
|
||||||
]
|
]
|
||||||
const dataSlot: DataTableSlots = {
|
const dataSlot = {
|
||||||
1: (value: string) => {
|
1: (value: string) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import DT, { type Config, type ConfigColumns } from 'datatables.net-dt'
|
import DT from 'datatables.net-dt'
|
||||||
import DataTable, { type DataTableSlots } from 'datatables.net-react'
|
import DataTable from 'datatables.net-react'
|
||||||
import { Link, useRouteLoaderData } from 'react-router'
|
import { Link, useRouteLoaderData } from 'react-router'
|
||||||
|
|
||||||
import type { TCategoryResponse } from '~/apis/common/get-categories'
|
import type { TCategoryResponse } from '~/apis/common/get-categories'
|
||||||
@ -13,18 +13,17 @@ export const CategoriesPage = () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
DataTable.use(DT)
|
DataTable.use(DT)
|
||||||
const dataTable =
|
const dataTable = loaderData?.categoriesData?.sort((a, b) => {
|
||||||
loaderData?.categoriesData?.sort((a, b) => {
|
if (a.sequence === null) return 1
|
||||||
if (a.sequence === null) return 1
|
if (b.sequence === null) return -1
|
||||||
if (b.sequence === null) return -1
|
return a.sequence - b.sequence
|
||||||
return a.sequence - b.sequence
|
})
|
||||||
}) || []
|
const dataColumns = [
|
||||||
const dataColumns: ConfigColumns[] = [
|
|
||||||
{
|
{
|
||||||
title: 'No',
|
title: 'No',
|
||||||
render: (
|
render: (
|
||||||
_data: unknown,
|
data: unknown,
|
||||||
_type: unknown,
|
type: unknown,
|
||||||
row: TCategoryResponse,
|
row: TCategoryResponse,
|
||||||
meta: { row: number },
|
meta: { row: number },
|
||||||
) => {
|
) => {
|
||||||
@ -47,7 +46,7 @@ export const CategoriesPage = () => {
|
|||||||
data: 'id',
|
data: 'id',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const dataSlot: DataTableSlots = {
|
const dataSlot = {
|
||||||
1: (_value: unknown, _type: unknown, data: TCategoryResponse) => (
|
1: (_value: unknown, _type: unknown, data: TCategoryResponse) => (
|
||||||
<div>
|
<div>
|
||||||
<div>{data.name}</div>
|
<div>{data.name}</div>
|
||||||
@ -65,7 +64,7 @@ export const CategoriesPage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
const dataOptions: Config = {
|
const dataOptions = {
|
||||||
paging: true,
|
paging: true,
|
||||||
searching: true,
|
searching: true,
|
||||||
ordering: true,
|
ordering: true,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import DT, { type Config, type ConfigColumns } from 'datatables.net-dt'
|
import DT from 'datatables.net-dt'
|
||||||
import DataTable, { type DataTableSlots } from 'datatables.net-react'
|
import DataTable from 'datatables.net-react'
|
||||||
import { Link, useRouteLoaderData } from 'react-router'
|
import { Link, useRouteLoaderData } from 'react-router'
|
||||||
|
|
||||||
import type { TCategoryResponse } from '~/apis/common/get-categories'
|
import type { TCategoryResponse } from '~/apis/common/get-categories'
|
||||||
@ -17,17 +17,16 @@ export const ContentsPage = () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
DataTable.use(DT)
|
DataTable.use(DT)
|
||||||
const dataTable =
|
const dataTable = loaderData?.newsData?.sort(
|
||||||
loaderData?.newsData?.sort(
|
(a, b) => new Date(b.live_at).getTime() - new Date(a.live_at).getTime(),
|
||||||
(a, b) => new Date(b.live_at).getTime() - new Date(a.live_at).getTime(),
|
)
|
||||||
) || []
|
const dataColumns = [
|
||||||
const dataColumns: ConfigColumns[] = [
|
|
||||||
{
|
{
|
||||||
title: 'No',
|
title: 'No',
|
||||||
render: (
|
render: (
|
||||||
_data: unknown,
|
data: unknown,
|
||||||
_type: unknown,
|
type: unknown,
|
||||||
_row: unknown,
|
row: unknown,
|
||||||
meta: { row: number },
|
meta: { row: number },
|
||||||
) => {
|
) => {
|
||||||
return meta.row + 1
|
return meta.row + 1
|
||||||
@ -55,7 +54,7 @@ export const ContentsPage = () => {
|
|||||||
data: 'slug',
|
data: 'slug',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const dataSlot: DataTableSlots = {
|
const dataSlot = {
|
||||||
1: (value: string) => formatDate(value),
|
1: (value: string) => formatDate(value),
|
||||||
2: (_value: unknown, _type: unknown, data: TNewsResponse) => (
|
2: (_value: unknown, _type: unknown, data: TNewsResponse) => (
|
||||||
<div>
|
<div>
|
||||||
@ -63,7 +62,6 @@ export const ContentsPage = () => {
|
|||||||
<div className="text-sm text-[#7C7C7C]">ID: {data.id.slice(0, 8)}</div>
|
<div className="text-sm text-[#7C7C7C]">ID: {data.id.slice(0, 8)}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
3: (value: string) => <span className="text-sm">{value}</span>,
|
|
||||||
4: (value: TCategoryResponse[]) => (
|
4: (value: TCategoryResponse[]) => (
|
||||||
<div className="text-xs">{value.map((item) => item.name).join(', ')}</div>
|
<div className="text-xs">{value.map((item) => item.name).join(', ')}</div>
|
||||||
),
|
),
|
||||||
@ -91,7 +89,7 @@ export const ContentsPage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
const dataOptions: Config = {
|
const dataOptions = {
|
||||||
paging: true,
|
paging: true,
|
||||||
searching: true,
|
searching: true,
|
||||||
ordering: true,
|
ordering: true,
|
||||||
@ -100,7 +98,7 @@ export const ContentsPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<TitleDashboard title="Artikel" />
|
<TitleDashboard title="Konten" />
|
||||||
<div className="mb-8 flex items-end justify-between gap-5">
|
<div className="mb-8 flex items-end justify-between gap-5">
|
||||||
<div className="flex-1">{/* TODO: Filter */}</div>
|
<div className="flex-1">{/* TODO: Filter */}</div>
|
||||||
<Button
|
<Button
|
||||||
@ -118,7 +116,7 @@ export const ContentsPage = () => {
|
|||||||
columns={dataColumns}
|
columns={dataColumns}
|
||||||
slots={dataSlot}
|
slots={dataSlot}
|
||||||
options={dataOptions}
|
options={dataOptions}
|
||||||
title="Daftar Artikel"
|
title="Daftar Konten"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,83 +0,0 @@
|
|||||||
import DT from 'datatables.net-dt'
|
|
||||||
import DataTable from 'datatables.net-react'
|
|
||||||
import { Link, useRouteLoaderData } from 'react-router'
|
|
||||||
|
|
||||||
import { Button } from '~/components/ui/button'
|
|
||||||
import { UiTable } from '~/components/ui/table'
|
|
||||||
import { TitleDashboard } from '~/components/ui/title-dashboard'
|
|
||||||
import type { loader } from '~/routes/_admin.lg-admin._dashboard.subscribe-plan._index'
|
|
||||||
|
|
||||||
export const SubscribePlanPage = () => {
|
|
||||||
const loaderData = useRouteLoaderData<typeof loader>(
|
|
||||||
'routes/_admin.lg-admin._dashboard.subscribe-plan._index',
|
|
||||||
)
|
|
||||||
|
|
||||||
DataTable.use(DT)
|
|
||||||
const { subscriptionsData: dataTable } = loaderData || {}
|
|
||||||
const dataColumns = [
|
|
||||||
{
|
|
||||||
title: 'No',
|
|
||||||
render: (
|
|
||||||
_data: unknown,
|
|
||||||
_type: unknown,
|
|
||||||
_row: unknown,
|
|
||||||
meta: { row: number },
|
|
||||||
) => {
|
|
||||||
return meta.row + 1
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Nama',
|
|
||||||
data: 'name',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Kode',
|
|
||||||
data: 'code',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Action',
|
|
||||||
data: 'id',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const dataSlot = {
|
|
||||||
3: (value: string) => (
|
|
||||||
<Button
|
|
||||||
as="a"
|
|
||||||
href={`/lg-admin/subscribe-plan/update/${value}`}
|
|
||||||
className="text-md rounded-md"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Update Subscribe Plan
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<TitleDashboard title="Subscribe Plan" />
|
|
||||||
<div className="mb-8 flex items-end justify-between">
|
|
||||||
<div className="flex-1">{/* TODO: Filter */}</div>
|
|
||||||
<Button
|
|
||||||
as={Link}
|
|
||||||
to="/lg-admin/subscribe-plan/create"
|
|
||||||
className="text-md h-[42px] rounded-md"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
Buat Subscribe Plan
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UiTable
|
|
||||||
data={dataTable || []}
|
|
||||||
columns={dataColumns}
|
|
||||||
slots={dataSlot}
|
|
||||||
options={{
|
|
||||||
paging: true,
|
|
||||||
searching: true,
|
|
||||||
ordering: true,
|
|
||||||
info: true,
|
|
||||||
}}
|
|
||||||
title=" Daftar Subscribe Plan"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -8,6 +8,15 @@ type TSubscriptions = {
|
|||||||
status: string
|
status: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TSubSettings = {
|
||||||
|
id: number
|
||||||
|
createdAt: string
|
||||||
|
subscriber: 'Monthly' | 'Yearly' | 'Weekly' | 'Special'
|
||||||
|
price: string
|
||||||
|
length: '30' | '365' | '7' | '1'
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
export const SUBSCRIPTIONS: TSubscriptions[] = [
|
export const SUBSCRIPTIONS: TSubscriptions[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
@ -190,3 +199,86 @@ export const SUBSCRIPTIONS: TSubscriptions[] = [
|
|||||||
status: 'Draft',
|
status: 'Draft',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const SUBSETTINGS: TSubSettings[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
createdAt: '24/10/2024',
|
||||||
|
subscriber: 'Monthly',
|
||||||
|
price: '100',
|
||||||
|
length: '30',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
createdAt: '25/10/2024',
|
||||||
|
subscriber: 'Yearly',
|
||||||
|
price: '1000',
|
||||||
|
length: '365',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
createdAt: '26/10/2024',
|
||||||
|
subscriber: 'Weekly',
|
||||||
|
price: '25',
|
||||||
|
length: '7',
|
||||||
|
status: 'inactive',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
createdAt: '27/10/2024',
|
||||||
|
subscriber: 'Monthly',
|
||||||
|
price: '100',
|
||||||
|
length: '30',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
createdAt: '28/10/2024',
|
||||||
|
subscriber: 'Yearly',
|
||||||
|
price: '1000',
|
||||||
|
length: '365',
|
||||||
|
status: 'inactive',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
createdAt: '29/10/2024',
|
||||||
|
subscriber: 'Weekly',
|
||||||
|
price: '25',
|
||||||
|
length: '7',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
createdAt: '30/10/2024',
|
||||||
|
subscriber: 'Monthly',
|
||||||
|
price: '100',
|
||||||
|
length: '30',
|
||||||
|
status: 'inactive',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
createdAt: '31/10/2024',
|
||||||
|
subscriber: 'Yearly',
|
||||||
|
price: '1000',
|
||||||
|
length: '365',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
createdAt: '01/11/2024',
|
||||||
|
subscriber: 'Weekly',
|
||||||
|
price: '25',
|
||||||
|
length: '7',
|
||||||
|
status: 'inactive',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
createdAt: '02/11/2024',
|
||||||
|
subscriber: 'Monthly',
|
||||||
|
price: '100',
|
||||||
|
length: '30',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|||||||
@ -1,14 +1,18 @@
|
|||||||
import { Field, Input, Label, Select } from '@headlessui/react'
|
import { Field, Input, Label, Select } from '@headlessui/react'
|
||||||
import DT from 'datatables.net-dt'
|
import DT from 'datatables.net-dt'
|
||||||
import DataTable from 'datatables.net-react'
|
import DataTable from 'datatables.net-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
import { SearchIcon } from '~/components/icons/search'
|
import { SearchIcon } from '~/components/icons/search'
|
||||||
|
import { Button } from '~/components/ui/button'
|
||||||
import { UiTable } from '~/components/ui/table'
|
import { UiTable } from '~/components/ui/table'
|
||||||
import { TitleDashboard } from '~/components/ui/title-dashboard'
|
import { TitleDashboard } from '~/components/ui/title-dashboard'
|
||||||
|
|
||||||
import { SUBSCRIPTIONS } from './data'
|
import { SUBSCRIPTIONS, SUBSETTINGS } from './data'
|
||||||
|
|
||||||
export const SubscriptionsPage = () => {
|
export const SubscriptionsPage = () => {
|
||||||
|
const [SubscribtionOpen, setSubscribtionOpen] = useState(true)
|
||||||
|
// const [SubSettingOpen, setSubSettingOpen] = useState(false)
|
||||||
DataTable.use(DT)
|
DataTable.use(DT)
|
||||||
const colTableSubscription = [
|
const colTableSubscription = [
|
||||||
{ title: 'No', data: 'id' },
|
{ title: 'No', data: 'id' },
|
||||||
@ -24,55 +28,158 @@ export const SubscriptionsPage = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
const colTableSubSetting = [
|
||||||
|
{ title: 'No', data: 'id' },
|
||||||
|
{ title: 'Tanggal Pembuatan', data: 'createdAt' },
|
||||||
|
{ title: 'Nama Subscription', data: 'subscriber' },
|
||||||
|
{ title: 'Price', data: 'price' },
|
||||||
|
{ title: 'Length', data: 'length' },
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
data: 'status',
|
||||||
|
render: (value: string) => {
|
||||||
|
return value == 'active'
|
||||||
|
? `<span class="bg-[#DFE5FF] text-[#4C5CA0] px-2 py-1 rounded-md">${value}</span>`
|
||||||
|
: `<span class="bg-[#FFD1D1] text-[#FF4E4E] px-2 py-1 rounded-md">${value}</span>`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const switchView = () => {
|
||||||
|
setSubscribtionOpen(!SubscribtionOpen)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<TitleDashboard title="Subscription" />
|
<TitleDashboard title="Subscription" />
|
||||||
|
|
||||||
<div className="mb-8 flex items-end justify-between">
|
{SubscribtionOpen && (
|
||||||
<div className="flex items-center gap-5 rounded-lg bg-gray-50 text-[#363636]">
|
<>
|
||||||
<div className="w-[400px]">
|
<div className="mb-8 flex items-end justify-between">
|
||||||
<Field>
|
<div className="flex items-center gap-5 rounded-lg bg-gray-50 text-[#363636]">
|
||||||
<Label className="mb-2 block text-sm font-medium">
|
<div className="w-[400px]">
|
||||||
Cari User
|
<Field>
|
||||||
</Label>
|
<Label className="mb-2 block text-sm font-medium">
|
||||||
<div className="relative">
|
Cari User
|
||||||
<Input
|
</Label>
|
||||||
type="text"
|
<div className="relative">
|
||||||
placeholder="Cari Nama"
|
<Input
|
||||||
className="w-full rounded-lg bg-white p-2 pr-10 pl-4 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none"
|
type="text"
|
||||||
/>
|
placeholder="Cari Nama"
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
className="w-full rounded-lg bg-white p-2 pr-10 pl-4 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none"
|
||||||
<SearchIcon className="h-5 w-5" />
|
/>
|
||||||
</div>
|
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||||
|
<SearchIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
|
||||||
|
<div className="w-[235px]">
|
||||||
|
<Field>
|
||||||
|
<Label className="mb-2 block text-sm font-medium">
|
||||||
|
Status
|
||||||
|
</Label>
|
||||||
|
<Select 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>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="rounded-md"
|
||||||
|
size="lg"
|
||||||
|
onClick={switchView}
|
||||||
|
>
|
||||||
|
Subscription Settings
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-[235px]">
|
<UiTable
|
||||||
<Field>
|
data={SUBSCRIPTIONS}
|
||||||
<Label className="mb-2 block text-sm font-medium">Status</Label>
|
columns={colTableSubscription}
|
||||||
<Select className="w-full rounded-lg bg-white p-2 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none">
|
options={{
|
||||||
<option>Pilih Status</option>
|
paging: true,
|
||||||
<option>Aktif</option>
|
searching: true,
|
||||||
<option>Nonaktif</option>
|
ordering: true,
|
||||||
</Select>
|
info: true,
|
||||||
</Field>
|
}}
|
||||||
</div>
|
title="Daftar Subscription"
|
||||||
</div>
|
/>
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<UiTable
|
{!SubscribtionOpen && (
|
||||||
data={SUBSCRIPTIONS}
|
<>
|
||||||
columns={colTableSubscription}
|
<div className="mb-8 flex items-end justify-between">
|
||||||
options={{
|
<div className="flex items-end gap-5 rounded-lg bg-gray-50 text-[#363636]">
|
||||||
paging: true,
|
<div className="w-[300px]">
|
||||||
searching: true,
|
<Field>
|
||||||
ordering: true,
|
<Label className="mb-2 block text-sm font-medium">
|
||||||
info: true,
|
Subscription Name
|
||||||
}}
|
</Label>
|
||||||
title="Daftar Subscription"
|
<div className="relative">
|
||||||
/>
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Subscription Name"
|
||||||
|
className="w-full rounded-lg bg-white p-2 pr-10 pl-4 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="w-[300px]">
|
||||||
|
<Field>
|
||||||
|
<Label className="mb-2 block text-sm font-medium">
|
||||||
|
Subscription Price
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Subscription Price"
|
||||||
|
className="w-full rounded-lg bg-white p-2 pr-10 pl-4 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[300px]">
|
||||||
|
<Field>
|
||||||
|
<Label className="mb-2 block text-sm font-medium">
|
||||||
|
Subscription Length (Days)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Subscription Length (Days)"
|
||||||
|
className="w-full rounded-lg bg-white p-2 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none"
|
||||||
|
></Input>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="rounded-md"
|
||||||
|
size="lg"
|
||||||
|
onClick={switchView}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UiTable
|
||||||
|
data={SUBSETTINGS}
|
||||||
|
columns={colTableSubSetting}
|
||||||
|
options={{
|
||||||
|
paging: true,
|
||||||
|
searching: true,
|
||||||
|
ordering: true,
|
||||||
|
info: true,
|
||||||
|
}}
|
||||||
|
title=" Daftar Subscription"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import DT, { type Config, type ConfigColumns } from 'datatables.net-dt'
|
import DT from 'datatables.net-dt'
|
||||||
import DataTable, { type DataTableSlots } from 'datatables.net-react'
|
import DataTable from 'datatables.net-react'
|
||||||
import { Link, useRouteLoaderData } from 'react-router'
|
import { Link, useRouteLoaderData } from 'react-router'
|
||||||
|
|
||||||
import { Button } from '~/components/ui/button'
|
import { Button } from '~/components/ui/button'
|
||||||
@ -14,13 +14,13 @@ export const TagsPage = () => {
|
|||||||
const { tagsData: dataTable } = loaderData || {}
|
const { tagsData: dataTable } = loaderData || {}
|
||||||
|
|
||||||
DataTable.use(DT)
|
DataTable.use(DT)
|
||||||
const dataColumns: ConfigColumns[] = [
|
const dataColumns = [
|
||||||
{
|
{
|
||||||
title: 'No',
|
title: 'No',
|
||||||
render: (
|
render: (
|
||||||
_data: unknown,
|
data: unknown,
|
||||||
_type: unknown,
|
type: unknown,
|
||||||
_row: unknown,
|
row: unknown,
|
||||||
meta: { row: number },
|
meta: { row: number },
|
||||||
) => {
|
) => {
|
||||||
return meta.row + 1
|
return meta.row + 1
|
||||||
@ -39,7 +39,15 @@ export const TagsPage = () => {
|
|||||||
data: 'id',
|
data: 'id',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const dataSlot: DataTableSlots = {
|
|
||||||
|
const dataOptions = {
|
||||||
|
paging: true,
|
||||||
|
searching: true,
|
||||||
|
ordering: true,
|
||||||
|
info: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataSlot = {
|
||||||
3: (value: string) => (
|
3: (value: string) => (
|
||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
@ -51,13 +59,6 @@ export const TagsPage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
const dataOptions: Config = {
|
|
||||||
paging: true,
|
|
||||||
searching: true,
|
|
||||||
ordering: true,
|
|
||||||
info: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<TitleDashboard title="Tags" />
|
<TitleDashboard title="Tags" />
|
||||||
@ -74,7 +75,7 @@ export const TagsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UiTable
|
<UiTable
|
||||||
data={dataTable || []}
|
data={dataTable}
|
||||||
columns={dataColumns}
|
columns={dataColumns}
|
||||||
options={dataOptions}
|
options={dataOptions}
|
||||||
slots={dataSlot}
|
slots={dataSlot}
|
||||||
|
|||||||
56
app/pages/dashboard-users/data.ts
Normal file
56
app/pages/dashboard-users/data.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
type TUsers = {
|
||||||
|
id: number
|
||||||
|
idTransaction: number
|
||||||
|
date: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
category: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
export const USERS: TUsers[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
idTransaction: 5_512_446_588,
|
||||||
|
date: '24/10/2024',
|
||||||
|
name: 'Ainun Wijaya',
|
||||||
|
email: 'ainun@gmail.com',
|
||||||
|
category: 'Pribadi',
|
||||||
|
status: 'Baru',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
idTransaction: 5_512_446_588,
|
||||||
|
date: '24/10/2024',
|
||||||
|
name: 'Ainun Wijaya',
|
||||||
|
email: 'ainun@gmail.com',
|
||||||
|
category: 'Pribadi',
|
||||||
|
status: 'Premium',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
idTransaction: 5_512_446_588,
|
||||||
|
date: '24/10/2024',
|
||||||
|
name: 'Ainun Wijaya',
|
||||||
|
email: 'ainun@gmail.com',
|
||||||
|
category: 'Pribadi',
|
||||||
|
status: 'Pembayaran',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
idTransaction: 5_512_446_588,
|
||||||
|
date: '24/10/2024',
|
||||||
|
name: 'Ainun Wijaya',
|
||||||
|
email: 'ainun@gmail.com',
|
||||||
|
category: 'Pribadi',
|
||||||
|
status: 'Premium',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
idTransaction: 5_512_446_588,
|
||||||
|
date: '24/10/2024',
|
||||||
|
name: 'Ainun Wijaya',
|
||||||
|
email: 'ainun@gmail.com',
|
||||||
|
category: 'Pribadi',
|
||||||
|
status: 'Baru',
|
||||||
|
},
|
||||||
|
]
|
||||||
@ -1,98 +1,110 @@
|
|||||||
import DT, { type ConfigColumns } from 'datatables.net-dt'
|
import { Field, Input, Label, Select } from '@headlessui/react'
|
||||||
import DataTable, { type DataTableSlots } from 'datatables.net-react'
|
|
||||||
import { useRouteLoaderData } from 'react-router'
|
|
||||||
|
|
||||||
import type { TUserResponse } from '~/apis/admin/get-users'
|
import { SearchIcon } from '~/components/icons/search'
|
||||||
import { UiTable } from '~/components/ui/table'
|
import { Pagination } from '~/components/ui/pagination'
|
||||||
import { TitleDashboard } from '~/components/ui/title-dashboard'
|
import { TitleDashboard } from '~/components/ui/title-dashboard'
|
||||||
import type { loader } from '~/routes/_admin.lg-admin._dashboard.users._index'
|
|
||||||
import { formatDate } from '~/utils/formatter'
|
import { USERS } from './data'
|
||||||
|
|
||||||
type TColorBadge = 'Baru' | 'Premium' | 'Pembayaran'
|
type TColorBadge = 'Baru' | 'Premium' | 'Pembayaran'
|
||||||
|
|
||||||
const getStatusBadge = (status: TColorBadge) => {
|
|
||||||
const statusColors = {
|
|
||||||
Baru: 'bg-[#DFE5FF] text-[#4C5CA0]',
|
|
||||||
Premium: 'bg-[#FFFCAF] text-[#DBCA6E]',
|
|
||||||
Pembayaran: 'bg-[#FEC4FF] text-[#CC6EDB]',
|
|
||||||
}
|
|
||||||
return statusColors[status] || 'bg-gray-200 text-gray-700'
|
|
||||||
}
|
|
||||||
export const UsersPage = () => {
|
export const UsersPage = () => {
|
||||||
const loaderData = useRouteLoaderData<typeof loader>(
|
const getStatusBadge = (status: TColorBadge) => {
|
||||||
'routes/_admin.lg-admin._dashboard.users._index',
|
const statusColors = {
|
||||||
)
|
Baru: 'bg-[#DFE5FF] text-[#4C5CA0]',
|
||||||
|
Premium: 'bg-[#FFFCAF] text-[#DBCA6E]',
|
||||||
DataTable.use(DT)
|
Pembayaran: 'bg-[#FEC4FF] text-[#CC6EDB]',
|
||||||
const dataTable =
|
}
|
||||||
loaderData?.usersData?.sort(
|
return statusColors[status] || 'bg-gray-200 text-gray-700'
|
||||||
(a, b) =>
|
|
||||||
new Date(b.subscribe.start_date).getTime() -
|
|
||||||
new Date(a.subscribe.start_date).getTime(),
|
|
||||||
) || []
|
|
||||||
|
|
||||||
const dataColumns: ConfigColumns[] = [
|
|
||||||
{
|
|
||||||
title: 'No',
|
|
||||||
render: (
|
|
||||||
_data: unknown,
|
|
||||||
_type: unknown,
|
|
||||||
_row: unknown,
|
|
||||||
meta: { row: number },
|
|
||||||
) => {
|
|
||||||
return meta.row + 1
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Tanggal Daftar',
|
|
||||||
data: 'subscribe.start_date',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Nama User',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Email',
|
|
||||||
data: 'email',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Kategori',
|
|
||||||
data: 'subscribe.status',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Status',
|
|
||||||
data: 'subscribe.subscribe_plan.name',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const dataSlot: DataTableSlots = {
|
|
||||||
1: (value: string) => formatDate(value),
|
|
||||||
2: (_value: unknown, _type: unknown, data: TUserResponse) => (
|
|
||||||
<div>
|
|
||||||
<div>{data.phone}</div>
|
|
||||||
<div className="text-sm text-[#7C7C7C]">ID: {data.id.slice(0, 8)}</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
4: (_value: string) => <span className="text-sm">Pribadi</span>,
|
|
||||||
5: (value: TColorBadge) => (
|
|
||||||
<span className={`rounded-lg px-2 text-sm ${getStatusBadge(value)}`}>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<TitleDashboard title="Users" />
|
<TitleDashboard title="Users" />
|
||||||
|
{/* filter section */}
|
||||||
|
|
||||||
<div className="mb-8 flex items-end justify-between gap-5">
|
<div className="mb-8 flex items-center gap-5 rounded-lg bg-gray-50 text-[#363636]">
|
||||||
<div className="flex-1">{/* TODO: Filter */}</div>
|
<div className="w-[400px]">
|
||||||
|
<Field>
|
||||||
|
<Label className="mb-2 block text-sm font-medium">Cari User</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Cari Nama"
|
||||||
|
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">
|
||||||
|
<SearchIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[235px]">
|
||||||
|
<Field>
|
||||||
|
<Label className="mb-2 block text-sm font-medium">Status</Label>
|
||||||
|
<Select 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>
|
</div>
|
||||||
|
|
||||||
<UiTable
|
{/* table */}
|
||||||
data={dataTable || []}
|
<div className="overflow-x-auto">
|
||||||
columns={dataColumns}
|
<div className="rounded-lg bg-white px-6 py-8 shadow-sm">
|
||||||
slots={dataSlot}
|
<h2 className="text-xl font-bold text-[#4C5CA0]">Daftar User</h2>
|
||||||
title="Daftar Users"
|
<table className="min-w-full p-3">
|
||||||
/>
|
<thead className="my-5 border-b-3 border-[#C2C2C2]">
|
||||||
|
<tr>
|
||||||
|
<th className="p-3">No</th>
|
||||||
|
<th>Tanggal Daftar</th>
|
||||||
|
<th>Nama User</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Kategori</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{USERS.map((user, index) => (
|
||||||
|
<tr
|
||||||
|
key={user.id}
|
||||||
|
className="border-b-1 border-gray-200 text-center"
|
||||||
|
>
|
||||||
|
<td className="p-4">{index + 1}</td>
|
||||||
|
<td>{user.date}</td>
|
||||||
|
<td>
|
||||||
|
{user.name}
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
id: {user.idTransaction}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{user.email}</td>
|
||||||
|
<td>{user.category}</td>
|
||||||
|
<td className="">
|
||||||
|
<span
|
||||||
|
className={`inline-block min-w-[100px] rounded-full px-2 py-1 text-sm ${getStatusBadge(user.status as TColorBadge)}`}
|
||||||
|
>
|
||||||
|
{user.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* pagination */}
|
||||||
|
<div className="float-end mt-6">
|
||||||
|
<Pagination
|
||||||
|
currentPage={1}
|
||||||
|
totalPages={5}
|
||||||
|
onPageChange={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import { TextEditor } from '~/components/text-editor'
|
|||||||
import { Button } from '~/components/ui/button'
|
import { Button } from '~/components/ui/button'
|
||||||
import { Combobox } from '~/components/ui/combobox'
|
import { Combobox } from '~/components/ui/combobox'
|
||||||
import { Input } from '~/components/ui/input'
|
import { Input } from '~/components/ui/input'
|
||||||
import { InputFile } from '~/components/ui/input-file'
|
|
||||||
import { Switch } from '~/components/ui/switch'
|
import { Switch } from '~/components/ui/switch'
|
||||||
import { TitleDashboard } from '~/components/ui/title-dashboard'
|
import { TitleDashboard } from '~/components/ui/title-dashboard'
|
||||||
import type { loader } from '~/routes/_admin.lg-admin._dashboard'
|
import type { loader } from '~/routes/_admin.lg-admin._dashboard'
|
||||||
@ -134,10 +133,10 @@ export const FormContentsPage = (properties: TProperties) => {
|
|||||||
containerClassName="flex-1"
|
containerClassName="flex-1"
|
||||||
disabled={!!newsData}
|
disabled={!!newsData}
|
||||||
/>
|
/>
|
||||||
<InputFile
|
<Input
|
||||||
id="featured_image"
|
id="featured_image"
|
||||||
label="Gambar Unggulan"
|
label="Gambar Unggulan"
|
||||||
placeholder="Masukkan Url Gambar Unggulan"
|
placeholder="Masukkan Gambar Unggulan"
|
||||||
name="featured_image"
|
name="featured_image"
|
||||||
className="border-0 bg-white shadow read-only:bg-gray-100 focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none disabled:bg-gray-100"
|
className="border-0 bg-white shadow read-only:bg-gray-100 focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none disabled:bg-gray-100"
|
||||||
labelClassName="text-sm font-medium text-[#363636]"
|
labelClassName="text-sm font-medium text-[#363636]"
|
||||||
@ -208,7 +207,7 @@ export const FormContentsPage = (properties: TProperties) => {
|
|||||||
label="Konten"
|
label="Konten"
|
||||||
placeholder="Masukkan Konten"
|
placeholder="Masukkan Konten"
|
||||||
className="shadow"
|
className="shadow"
|
||||||
inputClassName="bg-white focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none border-0 min-h-[42px]"
|
inputClassName="bg-white focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none border-0"
|
||||||
labelClassName="text-sm font-medium text-[#363636]"
|
labelClassName="text-sm font-medium text-[#363636]"
|
||||||
category="content"
|
category="content"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -29,7 +29,9 @@ export const NewsDetailPage = () => {
|
|||||||
<div className="sm-max:mx-5 relative">
|
<div className="sm-max:mx-5 relative">
|
||||||
<Card>
|
<Card>
|
||||||
<div className="py-5 sm:px-30">
|
<div className="py-5 sm:px-30">
|
||||||
<h2 className="text-xl font-extrabold sm:text-4xl">{title}</h2>
|
<h2 className="text-xl font-extrabold text-[#2E2F7C] sm:text-4xl">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div className="my-5 w-full items-center justify-between gap-2 align-middle sm:flex">
|
<div className="my-5 w-full items-center justify-between gap-2 align-middle sm:flex">
|
||||||
<NewsAuthor
|
<NewsAuthor
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
import { getSubscriptions } from '~/apis/common/get-subscriptions'
|
|
||||||
import { SubscribePlanPage } from '~/pages/dashboard-plan-subscribe'
|
|
||||||
|
|
||||||
import type { Route } from './+types/_admin.lg-admin._dashboard.contents._index'
|
|
||||||
|
|
||||||
export const loader = async ({}: Route.LoaderArgs) => {
|
|
||||||
const { data: subscriptionsData } = await getSubscriptions()
|
|
||||||
return { subscriptionsData }
|
|
||||||
}
|
|
||||||
const DashboardSubscriptionsSettingsLayout = () => <SubscribePlanPage />
|
|
||||||
export default DashboardSubscriptionsSettingsLayout
|
|
||||||
@ -2,7 +2,6 @@ import { Outlet } from 'react-router'
|
|||||||
|
|
||||||
import { getCategories } from '~/apis/common/get-categories'
|
import { getCategories } from '~/apis/common/get-categories'
|
||||||
import { getTags } from '~/apis/common/get-tags'
|
import { getTags } from '~/apis/common/get-tags'
|
||||||
import { AdminProvider } from '~/contexts/admin'
|
|
||||||
import { AdminDashboardLayout } from '~/layouts/admin/dashboard'
|
import { AdminDashboardLayout } from '~/layouts/admin/dashboard'
|
||||||
|
|
||||||
import type { Route } from './+types/_admin.lg-admin._dashboard'
|
import type { Route } from './+types/_admin.lg-admin._dashboard'
|
||||||
@ -19,11 +18,9 @@ export const loader = async ({}: Route.LoaderArgs) => {
|
|||||||
|
|
||||||
const DashboardLayout = () => {
|
const DashboardLayout = () => {
|
||||||
return (
|
return (
|
||||||
<AdminProvider>
|
<AdminDashboardLayout>
|
||||||
<AdminDashboardLayout>
|
<Outlet />
|
||||||
<Outlet />
|
</AdminDashboardLayout>
|
||||||
</AdminDashboardLayout>
|
|
||||||
</AdminProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default DashboardLayout
|
export default DashboardLayout
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
import { getUsers } from '~/apis/admin/get-users'
|
|
||||||
import { handleCookie } from '~/libs/cookies'
|
|
||||||
import { UsersPage } from '~/pages/dashboard-users'
|
|
||||||
|
|
||||||
import type { Route } from './+types/_admin.lg-admin._dashboard.users._index'
|
|
||||||
|
|
||||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
|
||||||
const { staffToken } = await handleCookie(request)
|
|
||||||
const { data: usersData } = await getUsers({
|
|
||||||
accessToken: staffToken,
|
|
||||||
})
|
|
||||||
|
|
||||||
return { usersData }
|
|
||||||
}
|
|
||||||
|
|
||||||
const DashboardUsersLayout = () => <UsersPage />
|
|
||||||
export default DashboardUsersLayout
|
|
||||||
4
app/routes/_admin.lg-admin._dashboard.users.tsx
Normal file
4
app/routes/_admin.lg-admin._dashboard.users.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { UsersPage } from '~/pages/dashboard-users'
|
||||||
|
|
||||||
|
const DashboardUsersLayout = () => <UsersPage />
|
||||||
|
export default DashboardUsersLayout
|
||||||
@ -1,11 +1,9 @@
|
|||||||
import { Outlet, redirect } from 'react-router'
|
import { Outlet, redirect } from 'react-router'
|
||||||
import { XiorError } from 'xior'
|
|
||||||
|
|
||||||
import { getStaff } from '~/apis/admin/get-staff'
|
import { getStaff } from '~/apis/admin/get-staff'
|
||||||
import { AUTH_PAGES } from '~/configs/pages'
|
import { AUTH_PAGES } from '~/configs/pages'
|
||||||
import { AdminDefaultLayout } from '~/layouts/admin/default'
|
import { AdminDefaultLayout } from '~/layouts/admin/default'
|
||||||
import { handleCookie } from '~/libs/cookies'
|
import { handleCookie } from '~/libs/cookies'
|
||||||
import { setStaffLogoutHeaders } from '~/libs/logout-header.server'
|
|
||||||
|
|
||||||
import type { Route } from './+types/_admin.lg-admin'
|
import type { Route } from './+types/_admin.lg-admin'
|
||||||
|
|
||||||
@ -15,19 +13,6 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
|
|||||||
const isAuthPage = AUTH_PAGES.includes(pathname)
|
const isAuthPage = AUTH_PAGES.includes(pathname)
|
||||||
let staffData
|
let staffData
|
||||||
|
|
||||||
if (staffToken) {
|
|
||||||
try {
|
|
||||||
const { data } = await getStaff({
|
|
||||||
accessToken: staffToken,
|
|
||||||
})
|
|
||||||
staffData = data
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof XiorError && error.response?.status === 401) {
|
|
||||||
setStaffLogoutHeaders()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthPage && !staffToken) {
|
if (!isAuthPage && !staffToken) {
|
||||||
throw redirect('/lg-admin/login')
|
throw redirect('/lg-admin/login')
|
||||||
}
|
}
|
||||||
@ -36,6 +21,13 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
|
|||||||
throw redirect('/lg-admin')
|
throw redirect('/lg-admin')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (staffToken) {
|
||||||
|
const { data } = await getStaff({
|
||||||
|
accessToken: staffToken,
|
||||||
|
})
|
||||||
|
staffData = data
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
staffData,
|
staffData,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Outlet } from 'react-router'
|
import { Outlet } from 'react-router'
|
||||||
import { XiorError } from 'xior'
|
|
||||||
|
|
||||||
import { getCategories } from '~/apis/common/get-categories'
|
import { getCategories } from '~/apis/common/get-categories'
|
||||||
import { getSubscriptions } from '~/apis/common/get-subscriptions'
|
import { getSubscriptions } from '~/apis/common/get-subscriptions'
|
||||||
@ -7,7 +6,6 @@ import { getUser } from '~/apis/news/get-user'
|
|||||||
import { NewsProvider } from '~/contexts/news'
|
import { NewsProvider } from '~/contexts/news'
|
||||||
import { NewsDefaultLayout } from '~/layouts/news/default'
|
import { NewsDefaultLayout } from '~/layouts/news/default'
|
||||||
import { handleCookie } from '~/libs/cookies'
|
import { handleCookie } from '~/libs/cookies'
|
||||||
import { setUserLogoutHeaders } from '~/libs/logout-header.server'
|
|
||||||
|
|
||||||
import type { Route } from './+types/_news'
|
import type { Route } from './+types/_news'
|
||||||
|
|
||||||
@ -15,16 +13,10 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
|
|||||||
const { userToken } = await handleCookie(request)
|
const { userToken } = await handleCookie(request)
|
||||||
let userData
|
let userData
|
||||||
if (userToken) {
|
if (userToken) {
|
||||||
try {
|
const { data } = await getUser({
|
||||||
const { data } = await getUser({
|
accessToken: userToken,
|
||||||
accessToken: userToken,
|
})
|
||||||
})
|
userData = data
|
||||||
userData = data
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof XiorError && error.response?.status === 401) {
|
|
||||||
setUserLogoutHeaders()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const { data: subscriptionsData } = await getSubscriptions()
|
const { data: subscriptionsData } = await getSubscriptions()
|
||||||
const { data: categoriesData } = await getCategories()
|
const { data: categoriesData } = await getCategories()
|
||||||
|
|||||||
@ -1,64 +0,0 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
|
||||||
import { data } from 'react-router'
|
|
||||||
import { getValidatedFormData } from 'remix-hook-form'
|
|
||||||
import { XiorError } from 'xior'
|
|
||||||
|
|
||||||
import { uploadFileRequest } from '~/apis/admin/upload-file'
|
|
||||||
import { uploadSchema, type TUploadSchema } from '~/layouts/admin/form-upload'
|
|
||||||
import { handleCookie } from '~/libs/cookies'
|
|
||||||
|
|
||||||
import type { Route } from './+types/actions.register'
|
|
||||||
|
|
||||||
export const action = async ({ request }: Route.ActionArgs) => {
|
|
||||||
const { staffToken } = await handleCookie(request)
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
errors,
|
|
||||||
data: payload,
|
|
||||||
receivedValues: defaultValues,
|
|
||||||
} = await getValidatedFormData<TUploadSchema>(
|
|
||||||
request,
|
|
||||||
zodResolver(uploadSchema),
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (errors) {
|
|
||||||
return data({ success: false, errors, defaultValues }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: uploadData } = await uploadFileRequest({
|
|
||||||
payload,
|
|
||||||
accessToken: staffToken,
|
|
||||||
})
|
|
||||||
|
|
||||||
return data(
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
uploadData,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
statusText: 'OK',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof XiorError) {
|
|
||||||
return data(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
message: error?.response?.data?.error?.message || error.message,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: error?.response?.status || 500,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return data(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
message: 'Internal server error',
|
|
||||||
},
|
|
||||||
{ status: 500 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user