Compare commits
19 Commits
e7eda086e4
...
9a0c6c1f0b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a0c6c1f0b | ||
|
|
8c2298ff61 | ||
|
|
061f95d4e5 | ||
|
|
de92703fbe | ||
|
|
a9a350fdb2 | ||
|
|
4bc185434b | ||
|
|
4bb7a23795 | ||
|
|
f291290f55 | ||
|
|
e84c05d0a0 | ||
|
|
c7583413bb | ||
|
|
5b83c3f8dd | ||
|
|
0f414f6963 | ||
|
|
2940057cb1 | ||
|
|
9dca781d6d | ||
|
|
f17d64eb9b | ||
|
|
64bb369c59 | ||
|
|
422c9cbfe2 | ||
|
|
3d13707359 | ||
|
|
9f6c07c3de |
39
app/apis/admin/get-users.ts
Normal file
39
app/apis/admin/get-users.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/apis/admin/upload-file.ts
Normal file
37
app/apis/admin/upload-file.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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:hover:bg-[#2E2F7C]/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:opacity-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 Konten</h2>
|
<h2 className="text-xl font-bold">Top 5 Artikel</h2>
|
||||||
<Pie
|
<Pie
|
||||||
height={225}
|
height={225}
|
||||||
width={450}
|
width={450}
|
||||||
|
|||||||
93
app/components/ui/input-file.tsx
Normal file
93
app/components/ui/input-file.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
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',
|
'h-[42px] w-full rounded-md border border-[#DFDFDF] p-2 pr-8',
|
||||||
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] text-gray-500"
|
className="absolute right-3 h-[42px]"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setInputType(inputType === 'password' ? 'text' : 'password')
|
setInputType(inputType === 'password' ? 'text' : 'password')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,14 +1,10 @@
|
|||||||
import DT, { type Config, type ConfigColumns } from 'datatables.net-dt'
|
import DT from 'datatables.net-dt'
|
||||||
import DataTable from 'datatables.net-react'
|
import DataTable, { type DataTableProps } 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>`
|
||||||
@ -27,11 +23,14 @@ 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"
|
className="cell-border text-sm"
|
||||||
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 } from '~/layouts/admin/menu'
|
import { MENU as ADMIN_MENU, SUB_MENU } from '~/layouts/admin/menu'
|
||||||
|
|
||||||
export const APP = {
|
export const APP = {
|
||||||
title: 'LegalGo',
|
title: 'LegalGo',
|
||||||
@ -23,4 +23,5 @@ 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,
|
||||||
]
|
]
|
||||||
|
|||||||
52
app/contexts/admin.tsx
Normal file
52
app/contexts/admin.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
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,10 +1,15 @@
|
|||||||
|
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 />
|
||||||
@ -12,6 +17,28 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
122
app/layouts/admin/form-upload.tsx
Normal file
122
app/layouts/admin/form-upload.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
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: 'Konten',
|
title: 'Artikel',
|
||||||
url: '/lg-admin/contents',
|
url: '/lg-admin/contents',
|
||||||
icon: ChatIcon,
|
icon: ChatIcon,
|
||||||
},
|
},
|
||||||
@ -59,6 +59,38 @@ 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,5 +1,7 @@
|
|||||||
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'
|
||||||
|
|
||||||
@ -36,14 +38,14 @@ export const AdvertisementsPage = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dataBanner = BANNER
|
const dataBanner = BANNER
|
||||||
const dataColumns = [
|
const dataColumns: ConfigColumns[] = [
|
||||||
{ 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 = {
|
const dataSlot: DataTableSlots = {
|
||||||
1: (value: string) => {
|
1: (value: string) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import DT from 'datatables.net-dt'
|
import DT, { type Config, type ConfigColumns } from 'datatables.net-dt'
|
||||||
import DataTable from 'datatables.net-react'
|
import DataTable, { type DataTableSlots } 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,17 +13,18 @@ export const CategoriesPage = () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
DataTable.use(DT)
|
DataTable.use(DT)
|
||||||
const dataTable = loaderData?.categoriesData?.sort((a, b) => {
|
const dataTable =
|
||||||
if (a.sequence === null) return 1
|
loaderData?.categoriesData?.sort((a, b) => {
|
||||||
if (b.sequence === null) return -1
|
if (a.sequence === null) return 1
|
||||||
return a.sequence - b.sequence
|
if (b.sequence === null) return -1
|
||||||
})
|
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 },
|
||||||
) => {
|
) => {
|
||||||
@ -46,7 +47,7 @@ export const CategoriesPage = () => {
|
|||||||
data: 'id',
|
data: 'id',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const dataSlot = {
|
const dataSlot: DataTableSlots = {
|
||||||
1: (_value: unknown, _type: unknown, data: TCategoryResponse) => (
|
1: (_value: unknown, _type: unknown, data: TCategoryResponse) => (
|
||||||
<div>
|
<div>
|
||||||
<div>{data.name}</div>
|
<div>{data.name}</div>
|
||||||
@ -64,7 +65,7 @@ export const CategoriesPage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
const dataOptions = {
|
const dataOptions: Config = {
|
||||||
paging: true,
|
paging: true,
|
||||||
searching: true,
|
searching: true,
|
||||||
ordering: true,
|
ordering: true,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import DT from 'datatables.net-dt'
|
import DT, { type Config, type ConfigColumns } from 'datatables.net-dt'
|
||||||
import DataTable from 'datatables.net-react'
|
import DataTable, { type DataTableSlots } 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,16 +17,17 @@ export const ContentsPage = () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
DataTable.use(DT)
|
DataTable.use(DT)
|
||||||
const dataTable = loaderData?.newsData?.sort(
|
const dataTable =
|
||||||
(a, b) => new Date(b.live_at).getTime() - new Date(a.live_at).getTime(),
|
loaderData?.newsData?.sort(
|
||||||
)
|
(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
|
||||||
@ -54,7 +55,7 @@ export const ContentsPage = () => {
|
|||||||
data: 'slug',
|
data: 'slug',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const dataSlot = {
|
const dataSlot: DataTableSlots = {
|
||||||
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>
|
||||||
@ -62,6 +63,7 @@ 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>
|
||||||
),
|
),
|
||||||
@ -89,7 +91,7 @@ export const ContentsPage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
const dataOptions = {
|
const dataOptions: Config = {
|
||||||
paging: true,
|
paging: true,
|
||||||
searching: true,
|
searching: true,
|
||||||
ordering: true,
|
ordering: true,
|
||||||
@ -98,7 +100,7 @@ export const ContentsPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<TitleDashboard title="Konten" />
|
<TitleDashboard title="Artikel" />
|
||||||
<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
|
||||||
@ -116,7 +118,7 @@ export const ContentsPage = () => {
|
|||||||
columns={dataColumns}
|
columns={dataColumns}
|
||||||
slots={dataSlot}
|
slots={dataSlot}
|
||||||
options={dataOptions}
|
options={dataOptions}
|
||||||
title="Daftar Konten"
|
title="Daftar Artikel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
83
app/pages/dashboard-plan-subscribe/index.tsx
Normal file
83
app/pages/dashboard-plan-subscribe/index.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
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,15 +8,6 @@ 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,
|
||||||
@ -199,86 +190,3 @@ 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,18 +1,14 @@
|
|||||||
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, SUBSETTINGS } from './data'
|
import { SUBSCRIPTIONS } 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' },
|
||||||
@ -28,158 +24,55 @@ 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" />
|
||||||
|
|
||||||
{SubscribtionOpen && (
|
<div className="mb-8 flex items-end justify-between">
|
||||||
<>
|
<div className="flex items-center gap-5 rounded-lg bg-gray-50 text-[#363636]">
|
||||||
<div className="mb-8 flex items-end justify-between">
|
<div className="w-[400px]">
|
||||||
<div className="flex items-center gap-5 rounded-lg bg-gray-50 text-[#363636]">
|
<Field>
|
||||||
<div className="w-[400px]">
|
<Label className="mb-2 block text-sm font-medium">
|
||||||
<Field>
|
Cari User
|
||||||
<Label className="mb-2 block text-sm font-medium">
|
</Label>
|
||||||
Cari User
|
<div className="relative">
|
||||||
</Label>
|
<Input
|
||||||
<div className="relative">
|
type="text"
|
||||||
<Input
|
placeholder="Cari Nama"
|
||||||
type="text"
|
className="w-full rounded-lg bg-white p-2 pr-10 pl-4 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none"
|
||||||
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 className="absolute inset-y-0 right-0 flex items-center pr-3">
|
</div>
|
||||||
<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>
|
||||||
|
|
||||||
<UiTable
|
<div className="w-[235px]">
|
||||||
data={SUBSCRIPTIONS}
|
<Field>
|
||||||
columns={colTableSubscription}
|
<Label className="mb-2 block text-sm font-medium">Status</Label>
|
||||||
options={{
|
<Select className="w-full rounded-lg bg-white p-2 shadow focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none">
|
||||||
paging: true,
|
<option>Pilih Status</option>
|
||||||
searching: true,
|
<option>Aktif</option>
|
||||||
ordering: true,
|
<option>Nonaktif</option>
|
||||||
info: true,
|
</Select>
|
||||||
}}
|
</Field>
|
||||||
title="Daftar Subscription"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!SubscribtionOpen && (
|
|
||||||
<>
|
|
||||||
<div className="mb-8 flex items-end justify-between">
|
|
||||||
<div className="flex items-end gap-5 rounded-lg bg-gray-50 text-[#363636]">
|
|
||||||
<div className="w-[300px]">
|
|
||||||
<Field>
|
|
||||||
<Label className="mb-2 block text-sm font-medium">
|
|
||||||
Subscription Name
|
|
||||||
</Label>
|
|
||||||
<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>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<UiTable
|
<UiTable
|
||||||
data={SUBSETTINGS}
|
data={SUBSCRIPTIONS}
|
||||||
columns={colTableSubSetting}
|
columns={colTableSubscription}
|
||||||
options={{
|
options={{
|
||||||
paging: true,
|
paging: true,
|
||||||
searching: true,
|
searching: true,
|
||||||
ordering: true,
|
ordering: true,
|
||||||
info: true,
|
info: true,
|
||||||
}}
|
}}
|
||||||
title=" Daftar Subscription"
|
title="Daftar Subscription"
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import DT from 'datatables.net-dt'
|
import DT, { type Config, type ConfigColumns } from 'datatables.net-dt'
|
||||||
import DataTable from 'datatables.net-react'
|
import DataTable, { type DataTableSlots } 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 = [
|
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
|
||||||
@ -39,15 +39,7 @@ 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"
|
||||||
@ -59,6 +51,13 @@ 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" />
|
||||||
@ -75,7 +74,7 @@ export const TagsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UiTable
|
<UiTable
|
||||||
data={dataTable}
|
data={dataTable || []}
|
||||||
columns={dataColumns}
|
columns={dataColumns}
|
||||||
options={dataOptions}
|
options={dataOptions}
|
||||||
slots={dataSlot}
|
slots={dataSlot}
|
||||||
|
|||||||
@ -1,56 +0,0 @@
|
|||||||
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,110 +1,98 @@
|
|||||||
import { Field, Input, Label, Select } from '@headlessui/react'
|
import DT, { type ConfigColumns } from 'datatables.net-dt'
|
||||||
|
import DataTable, { type DataTableSlots } from 'datatables.net-react'
|
||||||
|
import { useRouteLoaderData } from 'react-router'
|
||||||
|
|
||||||
import { SearchIcon } from '~/components/icons/search'
|
import type { TUserResponse } from '~/apis/admin/get-users'
|
||||||
import { Pagination } from '~/components/ui/pagination'
|
import { UiTable } from '~/components/ui/table'
|
||||||
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 { USERS } from './data'
|
import { formatDate } from '~/utils/formatter'
|
||||||
|
|
||||||
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 getStatusBadge = (status: TColorBadge) => {
|
const loaderData = useRouteLoaderData<typeof loader>(
|
||||||
const statusColors = {
|
'routes/_admin.lg-admin._dashboard.users._index',
|
||||||
Baru: 'bg-[#DFE5FF] text-[#4C5CA0]',
|
)
|
||||||
Premium: 'bg-[#FFFCAF] text-[#DBCA6E]',
|
|
||||||
Pembayaran: 'bg-[#FEC4FF] text-[#CC6EDB]',
|
DataTable.use(DT)
|
||||||
}
|
const dataTable =
|
||||||
return statusColors[status] || 'bg-gray-200 text-gray-700'
|
loaderData?.usersData?.sort(
|
||||||
|
(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-center gap-5 rounded-lg bg-gray-50 text-[#363636]">
|
<div className="mb-8 flex items-end justify-between gap-5">
|
||||||
<div className="w-[400px]">
|
<div className="flex-1">{/* TODO: Filter */}</div>
|
||||||
<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>
|
||||||
|
|
||||||
{/* table */}
|
<UiTable
|
||||||
<div className="overflow-x-auto">
|
data={dataTable || []}
|
||||||
<div className="rounded-lg bg-white px-6 py-8 shadow-sm">
|
columns={dataColumns}
|
||||||
<h2 className="text-xl font-bold text-[#4C5CA0]">Daftar User</h2>
|
slots={dataSlot}
|
||||||
<table className="min-w-full p-3">
|
title="Daftar Users"
|
||||||
<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,6 +10,7 @@ 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'
|
||||||
@ -133,10 +134,10 @@ export const FormContentsPage = (properties: TProperties) => {
|
|||||||
containerClassName="flex-1"
|
containerClassName="flex-1"
|
||||||
disabled={!!newsData}
|
disabled={!!newsData}
|
||||||
/>
|
/>
|
||||||
<Input
|
<InputFile
|
||||||
id="featured_image"
|
id="featured_image"
|
||||||
label="Gambar Unggulan"
|
label="Gambar Unggulan"
|
||||||
placeholder="Masukkan Gambar Unggulan"
|
placeholder="Masukkan Url 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]"
|
||||||
@ -207,7 +208,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"
|
inputClassName="bg-white focus:ring-1 focus:ring-[#2E2F7C] focus:outline-none border-0 min-h-[42px]"
|
||||||
labelClassName="text-sm font-medium text-[#363636]"
|
labelClassName="text-sm font-medium text-[#363636]"
|
||||||
category="content"
|
category="content"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -29,9 +29,7 @@ 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 text-[#2E2F7C] sm:text-4xl">
|
<h2 className="text-xl font-extrabold sm:text-4xl">{title}</h2>
|
||||||
{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
|
||||||
|
|||||||
@ -0,0 +1,11 @@
|
|||||||
|
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,6 +2,7 @@ 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'
|
||||||
@ -18,9 +19,11 @@ export const loader = async ({}: Route.LoaderArgs) => {
|
|||||||
|
|
||||||
const DashboardLayout = () => {
|
const DashboardLayout = () => {
|
||||||
return (
|
return (
|
||||||
<AdminDashboardLayout>
|
<AdminProvider>
|
||||||
<Outlet />
|
<AdminDashboardLayout>
|
||||||
</AdminDashboardLayout>
|
<Outlet />
|
||||||
|
</AdminDashboardLayout>
|
||||||
|
</AdminProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default DashboardLayout
|
export default DashboardLayout
|
||||||
|
|||||||
17
app/routes/_admin.lg-admin._dashboard.users._index.tsx
Normal file
17
app/routes/_admin.lg-admin._dashboard.users._index.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
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
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { UsersPage } from '~/pages/dashboard-users'
|
|
||||||
|
|
||||||
const DashboardUsersLayout = () => <UsersPage />
|
|
||||||
export default DashboardUsersLayout
|
|
||||||
@ -1,9 +1,11 @@
|
|||||||
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'
|
||||||
|
|
||||||
@ -13,6 +15,19 @@ 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')
|
||||||
}
|
}
|
||||||
@ -21,13 +36,6 @@ 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,4 +1,5 @@
|
|||||||
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'
|
||||||
@ -6,6 +7,7 @@ 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'
|
||||||
|
|
||||||
@ -13,10 +15,16 @@ 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) {
|
||||||
const { data } = await getUser({
|
try {
|
||||||
accessToken: userToken,
|
const { data } = await getUser({
|
||||||
})
|
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()
|
||||||
|
|||||||
64
app/routes/actions.admin.upload.tsx
Normal file
64
app/routes/actions.admin.upload.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
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