Compare commits
6 Commits
a333b7924f
...
16a6a14d67
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16a6a14d67 | ||
|
|
f9d861f24d | ||
|
|
9386b8dd69 | ||
|
|
1d23fea02a | ||
|
|
7eb38d03a0 | ||
|
|
581e99c4b3 |
2
.env
Normal file
2
.env
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_URL=http://localhost:8080
|
||||||
|
VITE_SALT_KEY=legalGO
|
||||||
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_URL=YOUR_API_URL
|
||||||
|
VITE_SALT_KEY=YOUR_SALT_KEY
|
||||||
20
app/apis/news/login.ts
Normal file
20
app/apis/news/login.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { type TLoginSchema } from '~/layouts/news/form-login'
|
||||||
|
import HttpClient from '~/libs/http-client'
|
||||||
|
|
||||||
|
const loginResponseSchema = z.object({
|
||||||
|
data: z.object({
|
||||||
|
token: z.string(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const newsLoginRequest = async (payload: TLoginSchema) => {
|
||||||
|
try {
|
||||||
|
const { data } = await HttpClient().post('/api/user/login', payload)
|
||||||
|
return loginResponseSchema.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@ import { APP } from '~/configs/meta'
|
|||||||
|
|
||||||
export const Banner = () => {
|
export const Banner = () => {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[65px] sm:mx-10">
|
<div className="min-h-[65px] lg:mx-10">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Link
|
<Link
|
||||||
to="/#"
|
to="/#"
|
||||||
@ -13,12 +13,9 @@ export const Banner = () => {
|
|||||||
<img
|
<img
|
||||||
src={'/images/banner.png'}
|
src={'/images/banner.png'}
|
||||||
alt={APP.title}
|
alt={APP.title}
|
||||||
className="h-[70px] w-[100%] sm:h-full"
|
className="h-[70px] w-[100%] content-center sm:h-full"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="absolute top-2 mx-10">
|
|
||||||
Lorem ipsum dolor sit, amet consectetur
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,37 +1,57 @@
|
|||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
import { Button } from '~/components/ui/button'
|
import { Button } from '~/components/ui/button'
|
||||||
|
|
||||||
export const Newsletter = () => {
|
type NewsletterProperties = {
|
||||||
return (
|
className?: string
|
||||||
<div className="flex h-[300px] w-full flex-col md:flex-row">
|
title?: string
|
||||||
<div className="flex w-full flex-col justify-center gap-5 bg-[#2E2F7C] px-6 py-5 text-white sm:px-30 md:w-[50%]">
|
description?: string
|
||||||
<h2 className="text-2xl font-bold sm:text-4xl">Join Our Newsletter</h2>
|
textButton?: string
|
||||||
<p className="text:md sm:text-lg">
|
onClick?: () => void
|
||||||
Tidak ingin ketinggalan Berita Hukum terhangat? Ingin mendapat
|
}
|
||||||
informasi kajian dan networking terbaru? Ikuti Newsletter kami dan
|
export const Newsletter = (property: NewsletterProperties) => {
|
||||||
Stay up to Speed!
|
const { className, title, description } = property
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
className="flex flex-col justify-center bg-cover bg-center px-20 py-5 sm:w-full md:w-[50%]"
|
className={`absolute flex h-[400px] w-screen flex-col sm:h-[300px] md:flex-row ${twMerge('', className)}`}
|
||||||
style={{
|
|
||||||
backgroundImage: "url('https://placehold.co/400x300.png')",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<form className="grid gap-5">
|
<div className="flex h-1/2 w-full flex-col justify-center gap-5 bg-[#2E2F7C] px-6 py-5 text-white sm:h-full sm:w-[50%] sm:px-30">
|
||||||
<input
|
<h2 className="text-2xl font-bold sm:text-4xl">
|
||||||
type="email"
|
{title ? `${title}` : 'Join Our Newsletter'}
|
||||||
placeholder="Daftarkan Email Disini"
|
</h2>
|
||||||
className="w-full rounded-md border border-gray-300 bg-white p-4 text-sm focus:outline-none"
|
<p className="text:md sm:text-lg">
|
||||||
/>
|
{description
|
||||||
<Button
|
? `${description}`
|
||||||
type="submit"
|
: `Tidak ingin ketinggalan Berita Hukum terhangat?
|
||||||
variant="newsPrimary"
|
Ingin mendapat informasi kajian dan networking terbaru?
|
||||||
size="block"
|
Ikuti Newsletter kami dan Stay up to Speed!`}
|
||||||
>
|
</p>
|
||||||
Subscribe
|
</div>
|
||||||
</Button>
|
<div
|
||||||
</form>
|
style={{
|
||||||
|
backgroundImage: "url('/images/bg-newsletter.png')",
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
}}
|
||||||
|
className="flex h-1/2 w-full flex-col items-center justify-center text-center sm:h-full sm:w-[50%]"
|
||||||
|
>
|
||||||
|
<form className="grid w-[50%] gap-5">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Daftarkan Email Disini"
|
||||||
|
className="w-full rounded-md border border-gray-300 bg-white p-4 text-sm focus:outline-none"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="newsPrimary"
|
||||||
|
size="block"
|
||||||
|
>
|
||||||
|
Subscribe
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,18 +1,19 @@
|
|||||||
// import { EyeIcon, EyeOffIcon } from 'lucide-react'
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { FormProvider, useForm } from 'react-hook-form'
|
import { useEffect } from 'react'
|
||||||
|
import { useFetcher } from 'react-router'
|
||||||
|
import { RemixFormProvider, useRemixForm } from 'remix-hook-form'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { Button } from '~/components/ui/button'
|
import { Button } from '~/components/ui/button'
|
||||||
import { Input } from '~/components/ui/input'
|
import { Input } from '~/components/ui/input'
|
||||||
import type { NewsContextProperties } from '~/contexts/news'
|
import type { NewsContextProperties } from '~/contexts/news'
|
||||||
|
|
||||||
const loginSchema = z.object({
|
export const loginSchema = z.object({
|
||||||
email: z.string().email('Email tidak valid'),
|
email: z.string().email('Email tidak valid'),
|
||||||
password: z.string().min(6, 'Kata sandi minimal 6 karakter'),
|
password: z.string().min(6, 'Kata sandi minimal 6 karakter'),
|
||||||
})
|
})
|
||||||
|
|
||||||
type TLoginSchema = z.infer<typeof loginSchema>
|
export type TLoginSchema = z.infer<typeof loginSchema>
|
||||||
|
|
||||||
type TProperties = {
|
type TProperties = {
|
||||||
setIsRegisterOpen: NewsContextProperties['setIsRegisterOpen']
|
setIsRegisterOpen: NewsContextProperties['setIsRegisterOpen']
|
||||||
@ -28,26 +29,33 @@ export const FormLogin = (properties: TProperties) => {
|
|||||||
setIsForgetOpen,
|
setIsForgetOpen,
|
||||||
setIsInitSubscribeOpen,
|
setIsInitSubscribeOpen,
|
||||||
} = properties
|
} = properties
|
||||||
|
const fetcher = useFetcher()
|
||||||
|
|
||||||
const formMethods = useForm<TLoginSchema>({
|
const formMethods = useRemixForm<TLoginSchema>({
|
||||||
|
mode: 'onSubmit',
|
||||||
|
fetcher,
|
||||||
resolver: zodResolver(loginSchema),
|
resolver: zodResolver(loginSchema),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { handleSubmit } = formMethods
|
const { handleSubmit } = formMethods
|
||||||
|
|
||||||
const onSubmit = handleSubmit((data) => {
|
useEffect(() => {
|
||||||
console.log('data', data) // eslint-disable-line no-console
|
if (fetcher.data?.success) {
|
||||||
setIsInitSubscribeOpen(true)
|
setIsInitSubscribeOpen(true)
|
||||||
setIsLoginOpen(false)
|
setIsLoginOpen(false)
|
||||||
})
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [fetcher])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<FormProvider {...formMethods}>
|
<RemixFormProvider {...formMethods}>
|
||||||
<form
|
<fetcher.Form
|
||||||
onSubmit={onSubmit}
|
method="post"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
|
action="/actions/news/login"
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
@ -86,8 +94,8 @@ export const FormLogin = (properties: TProperties) => {
|
|||||||
>
|
>
|
||||||
Masuk
|
Masuk
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</fetcher.Form>
|
||||||
</FormProvider>
|
</RemixFormProvider>
|
||||||
|
|
||||||
{/* Link Daftar */}
|
{/* Link Daftar */}
|
||||||
<div className="mt-4 text-center text-sm">
|
<div className="mt-4 text-center text-sm">
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
import { Link } from 'react-router'
|
import { Link, useRouteLoaderData } from 'react-router'
|
||||||
|
|
||||||
import { Button } from '~/components/ui/button'
|
import { Button } from '~/components/ui/button'
|
||||||
import { APP } from '~/configs/meta'
|
import { APP } from '~/configs/meta'
|
||||||
import { useNewsContext } from '~/contexts/news'
|
import { useNewsContext } from '~/contexts/news'
|
||||||
|
import type { loader } from '~/routes/_layout.news'
|
||||||
|
|
||||||
export const HeaderTop = () => {
|
export const HeaderTop = () => {
|
||||||
const { setIsLoginOpen } = useNewsContext()
|
const { setIsLoginOpen } = useNewsContext()
|
||||||
|
const loaderData = useRouteLoaderData<typeof loader>('routes/_layout.news')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-[60px] items-center justify-between bg-white px-5 align-middle sm:h-[100px] sm:gap-[15px] sm:px-[50px] sm:py-[20px]">
|
<div className="flex h-[60px] items-center justify-between bg-white px-5 align-middle sm:h-[100px] sm:gap-[15px] sm:px-[50px] sm:py-[20px]">
|
||||||
@ -31,15 +34,8 @@ export const HeaderTop = () => {
|
|||||||
className="hidden sm:block"
|
className="hidden sm:block"
|
||||||
onClick={() => setIsLoginOpen(true)}
|
onClick={() => setIsLoginOpen(true)}
|
||||||
>
|
>
|
||||||
Akun
|
{loaderData?.userToken ? 'Logout' : 'Masuk'}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="w-[50px] sm:w-[60px]">
|
|
||||||
<img
|
|
||||||
alt="language"
|
|
||||||
src="/flags/id.svg"
|
|
||||||
className="shadow-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,13 +1,19 @@
|
|||||||
import { createCookie } from 'react-router'
|
import { createCookie } from 'react-router'
|
||||||
|
|
||||||
import { ADMIN_COOKIES, USER_COOKIES } from '~/configs/storages'
|
import { ADMIN_COOKIES, USER_COOKIES } from '~/configs/cookies'
|
||||||
|
|
||||||
export const userTokenCookie = createCookie(USER_COOKIES.token, {
|
export const userTokenCookieConfig = createCookie(USER_COOKIES.token, {
|
||||||
|
httpOnly: false,
|
||||||
|
sameSite: 'lax',
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
secrets: [process.env.VITE_SALT_KEY || 'default-secret'],
|
||||||
path: '/news',
|
path: '/news',
|
||||||
})
|
})
|
||||||
|
|
||||||
export const adminTokenCookie = createCookie(ADMIN_COOKIES.token, {
|
export const adminTokenCookieConfig = createCookie(ADMIN_COOKIES.token, {
|
||||||
|
httpOnly: false,
|
||||||
|
sameSite: 'lax',
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
secrets: [process.env.VITE_SALT_KEY || 'default-secret'],
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
})
|
})
|
||||||
|
|||||||
16
app/libs/cookies.ts
Normal file
16
app/libs/cookies.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { adminTokenCookieConfig, userTokenCookieConfig } from './cookie.server'
|
||||||
|
|
||||||
|
export const handleCookie = async (request: Request) => {
|
||||||
|
const headers = request.headers
|
||||||
|
const userToken = (await userTokenCookieConfig.parse(
|
||||||
|
headers.get('Cookie'),
|
||||||
|
)) as string
|
||||||
|
const adminToken = (await adminTokenCookieConfig.parse(
|
||||||
|
headers.get('Cookie'),
|
||||||
|
)) as string
|
||||||
|
|
||||||
|
return {
|
||||||
|
userToken,
|
||||||
|
adminToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,10 @@
|
|||||||
import xior, { merge } from 'xior'
|
import xior, { merge } from 'xior'
|
||||||
|
|
||||||
const baseURL = import.meta.env.VITE_API_URL
|
const baseURL = import.meta.env.VITE_API_URL
|
||||||
const HttpClient = (token?: string) => {
|
|
||||||
|
type THttpClient = { token?: string }
|
||||||
|
const HttpClient = (parameters?: THttpClient) => {
|
||||||
|
const { token } = parameters || {}
|
||||||
const instance = xior.create({
|
const instance = xior.create({
|
||||||
baseURL,
|
baseURL,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,10 +1,20 @@
|
|||||||
import { USER_COOKIES } from '~/configs/storages'
|
import { ADMIN_COOKIES, USER_COOKIES } from '~/configs/cookies'
|
||||||
|
|
||||||
export const setUserLogoutHeaders = () => {
|
export const setUserLogoutHeaders = () => {
|
||||||
const responseHeaders = new Headers()
|
const responseHeaders = new Headers()
|
||||||
responseHeaders.append(
|
responseHeaders.append(
|
||||||
'Set-Cookie',
|
'Set-Cookie',
|
||||||
`${USER_COOKIES.token}=; Path=/news; Max-Age=0`,
|
`${USER_COOKIES.token}=; Path=/news; HttpOnly; SameSite=Strict; Max-Age=0`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return responseHeaders
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setAdminLogoutHeaders = () => {
|
||||||
|
const responseHeaders = new Headers()
|
||||||
|
responseHeaders.append(
|
||||||
|
'Set-Cookie',
|
||||||
|
`${ADMIN_COOKIES.token}=; Path=/admin; HttpOnly; SameSite=Strict; Max-Age=0`,
|
||||||
)
|
)
|
||||||
|
|
||||||
return responseHeaders
|
return responseHeaders
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
|
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 { SearchIcon } from '~/components/icons/search'
|
||||||
import { TitleDashboard } from '~/components/ui/title-dashboard'
|
import { TitleDashboard } from '~/components/ui/title-dashboard'
|
||||||
|
|
||||||
import { CONTENTS } from './data'
|
import { CONTENTS } from './data'
|
||||||
@ -34,6 +36,34 @@ export const ContentsPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<TitleDashboard title="Konten" />
|
<TitleDashboard title="Konten" />
|
||||||
|
<div className="mb-8 flex items-center gap-5 rounded-lg bg-gray-50 text-[#363636]">
|
||||||
|
<div className="w-[400px]">
|
||||||
|
<Field>
|
||||||
|
<Label className="mb-2 block text-sm font-medium">Cari 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 className="rounded-lg bg-white p-6 shadow-md">
|
<div className="rounded-lg bg-white p-6 shadow-md">
|
||||||
<h3 className="py-1 font-semibold text-[#4C5CA0]">Daftar Content</h3>
|
<h3 className="py-1 font-semibold text-[#4C5CA0]">Daftar Content</h3>
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
@ -44,7 +44,7 @@ export const SubscriptionsPage = () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const switchViee = () => {
|
const switchView = () => {
|
||||||
setSubscribtionOpen(!SubscribtionOpen)
|
setSubscribtionOpen(!SubscribtionOpen)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,7 +53,7 @@ export const SubscriptionsPage = () => {
|
|||||||
<TitleDashboard title="Subscription" />
|
<TitleDashboard title="Subscription" />
|
||||||
<Button
|
<Button
|
||||||
className="float-right mt-7 h-10 w-[160px] rounded-md"
|
className="float-right mt-7 h-10 w-[160px] rounded-md"
|
||||||
onClick={switchViee}
|
onClick={switchView}
|
||||||
>
|
>
|
||||||
{SubscribtionOpen ? 'Subscriptions' : 'Save'}
|
{SubscribtionOpen ? 'Subscriptions' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -10,7 +10,9 @@ export const NewsPage = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<Carousel {...SPOTLIGHT} />
|
<Carousel {...SPOTLIGHT} />
|
||||||
</Card>
|
</Card>
|
||||||
<Newsletter />
|
<div className="min-h-[400px] sm:min-h-[300px]">
|
||||||
|
<Newsletter className="mr-0 lg:-ml-14" />
|
||||||
|
</div>
|
||||||
<Card>
|
<Card>
|
||||||
<Carousel {...BERITA} />
|
<Carousel {...BERITA} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
isRouteErrorResponse,
|
isRouteErrorResponse,
|
||||||
@ -58,14 +57,8 @@ export function Layout({ children }: { children: ReactNode }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return <Outlet />
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Outlet />
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
|
|||||||
@ -2,6 +2,17 @@ import { Outlet } from 'react-router'
|
|||||||
|
|
||||||
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 type { Route } from './+types/_layout.news'
|
||||||
|
|
||||||
|
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||||
|
const { userToken } = await handleCookie(request)
|
||||||
|
|
||||||
|
return {
|
||||||
|
userToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const NewsLayout = () => {
|
const NewsLayout = () => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
60
app/routes/actions.news.login.ts
Normal file
60
app/routes/actions.news.login.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { data } from 'react-router'
|
||||||
|
import { getValidatedFormData } from 'remix-hook-form'
|
||||||
|
import { XiorError } from 'xior'
|
||||||
|
|
||||||
|
import { newsLoginRequest } from '~/apis/news/login'
|
||||||
|
import { loginSchema, type TLoginSchema } from '~/layouts/news/form-login'
|
||||||
|
import { generateTokenCookie } from '~/utils/token'
|
||||||
|
|
||||||
|
import type { Route } from './+types/actions.news.login'
|
||||||
|
|
||||||
|
export const action = async ({ request }: Route.ActionArgs) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
errors,
|
||||||
|
data: payload,
|
||||||
|
receivedValues: defaultValues,
|
||||||
|
} = await getValidatedFormData<TLoginSchema>(
|
||||||
|
request,
|
||||||
|
zodResolver(loginSchema),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (errors) {
|
||||||
|
return data({ success: false, errors, defaultValues }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: loginData } = await newsLoginRequest(payload)
|
||||||
|
const { token } = loginData
|
||||||
|
const tokenCookie = generateTokenCookie({
|
||||||
|
token,
|
||||||
|
})
|
||||||
|
|
||||||
|
const headers = new Headers()
|
||||||
|
headers.append('Set-Cookie', await tokenCookie)
|
||||||
|
|
||||||
|
return data(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
accessToken: token,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
status: 200,
|
||||||
|
statusText: 'OK',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof XiorError) {
|
||||||
|
return data({
|
||||||
|
success: false,
|
||||||
|
message: error?.response?.data?.error?.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data({
|
||||||
|
success: false,
|
||||||
|
message: 'Internal server error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/utils/token.ts
Normal file
21
app/utils/token.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { decodeJwt } from 'jose'
|
||||||
|
|
||||||
|
import { userTokenCookieConfig } from '~/libs/cookie.server'
|
||||||
|
|
||||||
|
type TTokenCookie = {
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateTokenCookie = (parameters: TTokenCookie) => {
|
||||||
|
const { token } = parameters
|
||||||
|
|
||||||
|
const decodedToken = decodeJwt(token)
|
||||||
|
const decodedTokenExp = decodedToken.exp
|
||||||
|
const expirationDate = decodedTokenExp
|
||||||
|
? new Date(decodedTokenExp * 1000)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return userTokenCookieConfig.serialize(token, {
|
||||||
|
expires: expirationDate,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -19,7 +19,6 @@
|
|||||||
"@react-router/fs-routes": "^7.1.3",
|
"@react-router/fs-routes": "^7.1.3",
|
||||||
"@react-router/node": "^7.1.3",
|
"@react-router/node": "^7.1.3",
|
||||||
"@react-router/serve": "^7.1.3",
|
"@react-router/serve": "^7.1.3",
|
||||||
"@tanstack/react-query": "^5.66.9",
|
|
||||||
"chart.js": "^4.4.8",
|
"chart.js": "^4.4.8",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"datatables.net-dt": "^2.2.2",
|
"datatables.net-dt": "^2.2.2",
|
||||||
@ -27,11 +26,13 @@
|
|||||||
"embla-carousel-react": "^8.5.2",
|
"embla-carousel-react": "^8.5.2",
|
||||||
"html-react-parser": "^5.2.2",
|
"html-react-parser": "^5.2.2",
|
||||||
"isbot": "^5.1.17",
|
"isbot": "^5.1.17",
|
||||||
|
"jose": "^6.0.8",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-chartjs-2": "^5.3.0",
|
"react-chartjs-2": "^5.3.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-router": "^7.1.3",
|
"react-router": "^7.1.3",
|
||||||
|
"remix-hook-form": "^6.1.3",
|
||||||
"tailwind-merge": "^3.0.1",
|
"tailwind-merge": "^3.0.1",
|
||||||
"xior": "^0.6.3",
|
"xior": "^0.6.3",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
|
|||||||
44
pnpm-lock.yaml
generated
44
pnpm-lock.yaml
generated
@ -23,9 +23,6 @@ importers:
|
|||||||
'@react-router/serve':
|
'@react-router/serve':
|
||||||
specifier: ^7.1.3
|
specifier: ^7.1.3
|
||||||
version: 7.1.3(react-router@7.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(typescript@5.7.3)
|
version: 7.1.3(react-router@7.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(typescript@5.7.3)
|
||||||
'@tanstack/react-query':
|
|
||||||
specifier: ^5.66.9
|
|
||||||
version: 5.66.9(react@19.0.0)
|
|
||||||
chart.js:
|
chart.js:
|
||||||
specifier: ^4.4.8
|
specifier: ^4.4.8
|
||||||
version: 4.4.8
|
version: 4.4.8
|
||||||
@ -47,6 +44,9 @@ importers:
|
|||||||
isbot:
|
isbot:
|
||||||
specifier: ^5.1.17
|
specifier: ^5.1.17
|
||||||
version: 5.1.22
|
version: 5.1.22
|
||||||
|
jose:
|
||||||
|
specifier: ^6.0.8
|
||||||
|
version: 6.0.8
|
||||||
react:
|
react:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.0.0
|
version: 19.0.0
|
||||||
@ -62,6 +62,9 @@ importers:
|
|||||||
react-router:
|
react-router:
|
||||||
specifier: ^7.1.3
|
specifier: ^7.1.3
|
||||||
version: 7.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
version: 7.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
|
remix-hook-form:
|
||||||
|
specifier: ^6.1.3
|
||||||
|
version: 6.1.3(react-dom@19.0.0(react@19.0.0))(react-hook-form@7.54.2(react@19.0.0))(react-router@7.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.0.1
|
specifier: ^3.0.1
|
||||||
version: 3.0.1
|
version: 3.0.1
|
||||||
@ -1369,14 +1372,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^8.57.0 || ^9.0.0
|
eslint: ^8.57.0 || ^9.0.0
|
||||||
|
|
||||||
'@tanstack/query-core@5.66.4':
|
|
||||||
resolution: {integrity: sha512-skM/gzNX4shPkqmdTCSoHtJAPMTtmIJNS0hE+xwTTUVYwezArCT34NMermABmBVUg5Ls5aiUXEDXfqwR1oVkcA==}
|
|
||||||
|
|
||||||
'@tanstack/react-query@5.66.9':
|
|
||||||
resolution: {integrity: sha512-NRI02PHJsP5y2gAuWKP+awamTIBFBSKMnO6UVzi03GTclmHHHInH5UzVgzi5tpu4+FmGfsdT7Umqegobtsp23A==}
|
|
||||||
peerDependencies:
|
|
||||||
react: ^18 || ^19
|
|
||||||
|
|
||||||
'@tanstack/react-virtual@3.13.0':
|
'@tanstack/react-virtual@3.13.0':
|
||||||
resolution: {integrity: sha512-CchF0NlLIowiM2GxtsoKBkXA4uqSnY2KvnXo+kyUFD4a4ll6+J0qzoRsUPMwXV/H26lRsxgJIr/YmjYum2oEjg==}
|
resolution: {integrity: sha512-CchF0NlLIowiM2GxtsoKBkXA4uqSnY2KvnXo+kyUFD4a4ll6+J0qzoRsUPMwXV/H26lRsxgJIr/YmjYum2oEjg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -2799,6 +2794,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
|
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jose@6.0.8:
|
||||||
|
resolution: {integrity: sha512-EyUPtOKyTYq+iMOszO42eobQllaIjJnwkZ2U93aJzNyPibCy7CEvT9UQnaCVB51IAd49gbNdCew1c0LcLTCB2g==}
|
||||||
|
|
||||||
jquery@3.7.1:
|
jquery@3.7.1:
|
||||||
resolution: {integrity: sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==}
|
resolution: {integrity: sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==}
|
||||||
|
|
||||||
@ -3640,6 +3638,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-qx+xQGZVsy55CH0a1hiVwHmqjLryfh7wQyF5HO07XJ9f7dQMY/gPQHhlyDkIzJKC+x2fUCpCcUODUUUFrm7SHA==}
|
resolution: {integrity: sha512-qx+xQGZVsy55CH0a1hiVwHmqjLryfh7wQyF5HO07XJ9f7dQMY/gPQHhlyDkIzJKC+x2fUCpCcUODUUUFrm7SHA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
remix-hook-form@6.1.3:
|
||||||
|
resolution: {integrity: sha512-lpEWqdehtF0ok0D8varghH64mm/GFgbilPCMtQz/J1RVu+J/BPgYZgb44yhIYGI09HfNDADSXBTIvX4WLwJmwQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18.2.0 || ^19.0.0
|
||||||
|
react-dom: ^18.2.0 || ^19.0.0
|
||||||
|
react-hook-form: ^7.51.0
|
||||||
|
react-router: '>=7.0.0'
|
||||||
|
|
||||||
require-directory@2.1.1:
|
require-directory@2.1.1:
|
||||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -5524,13 +5530,6 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
'@tanstack/query-core@5.66.4': {}
|
|
||||||
|
|
||||||
'@tanstack/react-query@5.66.9(react@19.0.0)':
|
|
||||||
dependencies:
|
|
||||||
'@tanstack/query-core': 5.66.4
|
|
||||||
react: 19.0.0
|
|
||||||
|
|
||||||
'@tanstack/react-virtual@3.13.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
'@tanstack/react-virtual@3.13.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/virtual-core': 3.13.0
|
'@tanstack/virtual-core': 3.13.0
|
||||||
@ -7182,6 +7181,8 @@ snapshots:
|
|||||||
|
|
||||||
jiti@2.4.2: {}
|
jiti@2.4.2: {}
|
||||||
|
|
||||||
|
jose@6.0.8: {}
|
||||||
|
|
||||||
jquery@3.7.1: {}
|
jquery@3.7.1: {}
|
||||||
|
|
||||||
js-beautify@1.15.1:
|
js-beautify@1.15.1:
|
||||||
@ -7954,6 +7955,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
jsesc: 0.5.0
|
jsesc: 0.5.0
|
||||||
|
|
||||||
|
remix-hook-form@6.1.3(react-dom@19.0.0(react@19.0.0))(react-hook-form@7.54.2(react@19.0.0))(react-router@7.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0):
|
||||||
|
dependencies:
|
||||||
|
react: 19.0.0
|
||||||
|
react-dom: 19.0.0(react@19.0.0)
|
||||||
|
react-hook-form: 7.54.2(react@19.0.0)
|
||||||
|
react-router: 7.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
|
|
||||||
require-directory@2.1.1: {}
|
require-directory@2.1.1: {}
|
||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
|
|||||||
BIN
public/images/bg-newsletter.png
Normal file
BIN
public/images/bg-newsletter.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.7 MiB |
Loading…
x
Reference in New Issue
Block a user