diff --git a/apps/landing/.env.example b/apps/landing/.env.example index 4b73e25..0acada9 100644 --- a/apps/landing/.env.example +++ b/apps/landing/.env.example @@ -1 +1,4 @@ -NEXT_PUBLIC_API_URL= \ No newline at end of file +NEXT_PUBLIC_API_URL= + +TURNSTILE_SECRET_KEY= +NEXT_PUBLIC_TURNSTILE_SITEKEY= \ No newline at end of file diff --git a/apps/landing/src/app/(auth)/_components/animated-background.tsx b/apps/landing/src/app/(auth)/_components/animated-background.tsx new file mode 100644 index 0000000..60b146c --- /dev/null +++ b/apps/landing/src/app/(auth)/_components/animated-background.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { motion } from 'framer-motion'; +import { useEffect, useMemo, useState } from 'react'; +import { + SiCplusplus, + SiCss3, + SiGo, + SiHtml5, + SiJavascript, + SiPhp, + SiPython, + SiRuby, + SiRust, + SiSwift, + SiTypescript, +} from 'react-icons/si'; + +export function AnimatedBackground() { + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + + const iconColorMap = useMemo( + () => [ + { Icon: SiJavascript, color: '#F7DF1E' }, + { Icon: SiTypescript, color: '#3178C6' }, + { Icon: SiPython, color: '#3776AB' }, + { Icon: SiCplusplus, color: '#00599C' }, + { Icon: SiRuby, color: '#CC342D' }, + { Icon: SiSwift, color: '#F05138' }, + { Icon: SiRust, color: '#000000' }, + { Icon: SiGo, color: '#00ADD8' }, + { Icon: SiPhp, color: '#777BB4' }, + { Icon: SiHtml5, color: '#E34F26' }, + { Icon: SiCss3, color: '#1572B6' }, + ], + [] + ); + + const getRandom = (min: number, max: number) => + Math.random() * (max - min) + min; + + const floatingIcons = useMemo(() => { + if (!isClient) return []; + + return Array.from({ length: 40 }).map((_, i) => { + const { Icon, color } = iconColorMap[i % iconColorMap.length]; + return ( + + + + ); + }); + }, [isClient, iconColorMap]); + + if (!isClient) return null; + + return ( +
{floatingIcons}
+ ); +} diff --git a/apps/landing/src/app/(auth)/_http/fetch-post-verify-turnstile.ts b/apps/landing/src/app/(auth)/_http/fetch-post-verify-turnstile.ts new file mode 100644 index 0000000..aefeb2d --- /dev/null +++ b/apps/landing/src/app/(auth)/_http/fetch-post-verify-turnstile.ts @@ -0,0 +1,41 @@ +export async function fetchPostverifyTurnstile( + token: string, + remoteIp?: string +): Promise { + const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; + const params = new URLSearchParams({ + secret: String(process.env.TURNSTILE_SECRET_KEY), + response: token, + }); + if (remoteIp) { + params.append('remoteip', remoteIp); + } + + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + + if (!res.ok) { + console.error('Turnstile verify HTTP error', res.status); + return false; + } + + const data = (await res.json()) as TurnstileVerifyResponse; + if (!data.success) { + console.warn('Turnstile failure', data['error-codes']); + return false; + } + + return true; +} + +interface TurnstileVerifyResponse { + success: boolean; + challenge_ts: string; + hostname: string; + 'error-codes'?: string[]; +} diff --git a/apps/landing/src/app/(auth)/forgot-password/_actions/forgot-password-action.ts b/apps/landing/src/app/(auth)/forgot-password/_actions/forgot-password-action.ts new file mode 100644 index 0000000..200d4db --- /dev/null +++ b/apps/landing/src/app/(auth)/forgot-password/_actions/forgot-password-action.ts @@ -0,0 +1,33 @@ +'use server'; + +import { fetcher } from '@/lib/fetcher'; +import { getRemoteIp } from '@/lib/headers'; +import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile'; +import { + forgotPasswordValidationSchema, + ForgotPasswordValidationType, +} from '../_validation/forgot-password-validation'; + +export async function ForgotPasswordAction( + request: ForgotPasswordValidationType +) { + const validRequest = forgotPasswordValidationSchema.parse(request); + const remoteIp = await getRemoteIp(); + + const isCapchaValid = await fetchPostverifyTurnstile( + validRequest.token, + remoteIp + ); + + if (!isCapchaValid) throw new Error('Failed to verify captcha'); + + const { data, error } = await fetcher.POST('/v1/auth/forgot', { + body: { + email: validRequest.email, + }, + }); + + if (error) throw new Error(error.message); + + return data; +} diff --git a/apps/landing/src/app/(auth)/forgot-password/_components/forgot-password-form.tsx b/apps/landing/src/app/(auth)/forgot-password/_components/forgot-password-form.tsx new file mode 100644 index 0000000..5ead8e0 --- /dev/null +++ b/apps/landing/src/app/(auth)/forgot-password/_components/forgot-password-form.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { + Button, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from '@components'; +import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile'; +import { useRef, useState } from 'react'; +import { LuLoader } from 'react-icons/lu'; +import { useFormForgotPassword } from '../_hooks/use-form-forgot-password'; +import { usePostForgotPassowrd } from '../_hooks/use-post-forgot-password'; +import { ForgotPasswordValidationType } from '../_validation/forgot-password-validation'; + +export function ForgotPasswordForm() { + const ref = useRef(null); + + const [step, setStep] = useState(1); + const [emailValue, setEmailValue] = useState(''); + + const form = useFormForgotPassword(); + const { mutate, isPending, error } = usePostForgotPassowrd(form); + + const handleFirstStep = (values: ForgotPasswordValidationType) => { + setEmailValue(values.email); + setStep(2); + }; + + const handleSecondStep = () => { + mutate({ email: emailValue, token: form.getValues('token') }); + }; + + return ( +
+ { + e.preventDefault(); + handleSecondStep(); + } + } + className="w-full space-y-4" + > + {error && ( +
+ {(error as Error).message} +
+ )} + + {step === 1 && ( + ( + + Email + + + + + + )} + /> + )} + + {step === 2 && ( + form.setValue('token', token)} + options={{ theme: 'light', size: 'flexible', language: 'id' }} + /> + )} + + + + + ); +} diff --git a/apps/landing/src/app/(auth)/forgot-password/_hooks/use-form-forgot-password.ts b/apps/landing/src/app/(auth)/forgot-password/_hooks/use-form-forgot-password.ts new file mode 100644 index 0000000..ebf9172 --- /dev/null +++ b/apps/landing/src/app/(auth)/forgot-password/_hooks/use-form-forgot-password.ts @@ -0,0 +1,16 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { + forgotPasswordValidationSchema, + type ForgotPasswordValidationType, +} from '../_validation/forgot-password-validation'; + +export function useFormForgotPassword() { + return useForm({ + resolver: zodResolver(forgotPasswordValidationSchema), + defaultValues: { + email: '', + token: '', + }, + }); +} diff --git a/apps/landing/src/app/(auth)/forgot-password/_hooks/use-post-forgot-password.ts b/apps/landing/src/app/(auth)/forgot-password/_hooks/use-post-forgot-password.ts new file mode 100644 index 0000000..a06a05f --- /dev/null +++ b/apps/landing/src/app/(auth)/forgot-password/_hooks/use-post-forgot-password.ts @@ -0,0 +1,20 @@ +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { ForgotPasswordAction } from '../_actions/forgot-password-action'; +import { useFormForgotPassword } from './use-form-forgot-password'; + +export function usePostForgotPassowrd( + form: ReturnType +) { + return useMutation({ + mutationFn: ForgotPasswordAction, + onSuccess: ({ message }) => { + form.reset(); + toast.success(message); + }, + onError: ({ message }) => { + form.reset(); + toast.error(message); + }, + }); +} diff --git a/apps/landing/src/app/(auth)/forgot-password/_http/fetch-post-forgot-password.ts b/apps/landing/src/app/(auth)/forgot-password/_http/fetch-post-forgot-password.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/landing/src/app/(auth)/forgot-password/_validation/forgot-password-validation.ts b/apps/landing/src/app/(auth)/forgot-password/_validation/forgot-password-validation.ts new file mode 100644 index 0000000..d99d3a1 --- /dev/null +++ b/apps/landing/src/app/(auth)/forgot-password/_validation/forgot-password-validation.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const forgotPasswordValidationSchema = z.object({ + email: z.string().email({ message: 'Format email tidak valid' }), + token: z.string(), +}); +export type ForgotPasswordValidationType = z.infer< + typeof forgotPasswordValidationSchema +>; diff --git a/apps/landing/src/app/(auth)/forgot-password/page.tsx b/apps/landing/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..36cccf8 --- /dev/null +++ b/apps/landing/src/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,23 @@ +import { LogoSimple } from '@/app/_components/logo'; +import { Metadata } from 'next'; +import { ForgotPasswordForm } from './_components/forgot-password-form'; + +export const metadata: Metadata = { + title: 'IMPHNEN - Signin', +}; + +export default function Page() { + return ( + <> +
+ + +

+ Reset password akunmu +

+ + +
+ + ); +} diff --git a/apps/landing/src/app/(auth)/layout.tsx b/apps/landing/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..bfcbcb5 --- /dev/null +++ b/apps/landing/src/app/(auth)/layout.tsx @@ -0,0 +1,25 @@ +import { baiJamjureeFont } from '@/lib/fonts'; +import { Card } from '@components'; +import { cn } from '@utils'; +import { ReactNode } from 'react'; +import { AnimatedBackground } from './_components/animated-background'; + +export default function Layout({ children }: { children: ReactNode }) { + return ( +
+ +
+ +
+
{children}
+
+
+
+
+ ); +} diff --git a/apps/landing/src/app/(auth)/reset-password/_actions/reset-password-actions.ts b/apps/landing/src/app/(auth)/reset-password/_actions/reset-password-actions.ts new file mode 100644 index 0000000..b4a8a1a --- /dev/null +++ b/apps/landing/src/app/(auth)/reset-password/_actions/reset-password-actions.ts @@ -0,0 +1,28 @@ +'use server'; + +import { fetcher } from '@/lib/fetcher'; +import { + resetPasswordValidationSchema, + ResetPasswordValidationSchema, +} from '../_validation/reset-password-validation'; + +export async function resetPasswordAction( + request: ResetPasswordValidationSchema +) { + const validRequest = resetPasswordValidationSchema.parse(request); + + if (validRequest.confirm_password !== validRequest.confirm_password) { + throw new Error('Password missmatch'); + } + + const { data, error } = await fetcher.POST('/v1/auth/new-password', { + body: { + password: validRequest.password, + token: validRequest.token, + }, + }); + + if (error) throw new Error(error.message); + + return data; +} diff --git a/apps/landing/src/app/(auth)/reset-password/_components/reset-password-form.tsx b/apps/landing/src/app/(auth)/reset-password/_components/reset-password-form.tsx new file mode 100644 index 0000000..c4289b3 --- /dev/null +++ b/apps/landing/src/app/(auth)/reset-password/_components/reset-password-form.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { + Button, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from '@components'; +import { LuLoaderCircle } from 'react-icons/lu'; +import { usePostResetPassword } from '../_hooks/use-post-reset-password'; +import { useResetPasswordForm } from '../_hooks/use-reset-password-form'; +import { ResetPasswordValidationSchema } from '../_validation/reset-password-validation'; + +export function ResetPasswordForm() { + const form = useResetPasswordForm(); + const { mutate, error, isPending } = usePostResetPassword(form); + + const onSubmit = (values: ResetPasswordValidationSchema) => { + mutate(values); + }; + + return ( +
+ {error && ( +
+ {(error as Error).message} +
+ )} + + + ( + + New Password + + + + + + )} + /> + + ( + + Confirm New Password + + + + + + )} + /> + + + + + ); +} diff --git a/apps/landing/src/app/(auth)/reset-password/_hooks/use-post-reset-password.ts b/apps/landing/src/app/(auth)/reset-password/_hooks/use-post-reset-password.ts new file mode 100644 index 0000000..80f89ec --- /dev/null +++ b/apps/landing/src/app/(auth)/reset-password/_hooks/use-post-reset-password.ts @@ -0,0 +1,24 @@ +import { useMutation } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { resetPasswordAction } from '../_actions/reset-password-actions'; +import { useResetPasswordForm } from './use-reset-password-form'; + +export function usePostResetPassword( + form: ReturnType +) { + const router = useRouter(); + + return useMutation({ + mutationFn: resetPasswordAction, + onSuccess: ({ message }) => { + form.reset(); + router.replace('/signin'); + toast.success(message); + }, + onError: ({ message }) => { + form.reset(); + toast.error(message); + }, + }); +} diff --git a/apps/landing/src/app/(auth)/reset-password/_hooks/use-reset-password-form.ts b/apps/landing/src/app/(auth)/reset-password/_hooks/use-reset-password-form.ts new file mode 100644 index 0000000..4cdbb1c --- /dev/null +++ b/apps/landing/src/app/(auth)/reset-password/_hooks/use-reset-password-form.ts @@ -0,0 +1,22 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useSearchParams } from 'next/navigation'; +import { useForm } from 'react-hook-form'; + +import { + resetPasswordValidationSchema, + ResetPasswordValidationSchema, +} from '../_validation/reset-password-validation'; + +export function useResetPasswordForm() { + const searchParams = useSearchParams(); + const tokenFromQuery = searchParams.get('token') ?? ''; + + return useForm({ + resolver: zodResolver(resetPasswordValidationSchema), + defaultValues: { + password: '', + confirm_password: '', + token: tokenFromQuery, + }, + }); +} diff --git a/apps/landing/src/app/(auth)/reset-password/_validation/reset-password-validation.ts b/apps/landing/src/app/(auth)/reset-password/_validation/reset-password-validation.ts new file mode 100644 index 0000000..1261ca8 --- /dev/null +++ b/apps/landing/src/app/(auth)/reset-password/_validation/reset-password-validation.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +export const resetPasswordValidationSchema = z + .object({ + password: z + .string({ + required_error: 'Password tidak boleh kosong', + invalid_type_error: 'Password harus berupa string', + }) + .min(8, 'Password harus minimal 8 karakter') + .max(50, 'Password tidak boleh lebih dari 50 karakter') + .regex( + /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*\W).+$/, + 'Password harus mengandung setidaknya satu huruf kapital, satu huruf kecil, satu angka, dan satu karakter spesial' + ), + confirm_password: z + .string({ + required_error: 'Konfirmasi password tidak boleh kosong', + }) + .min(8, 'Konfirmasi password harus minimal 8 karakter') + .max(50, 'Konfirmasi password tidak boleh lebih dari 50 karakter'), + token: z.string(), + }) + .refine((data) => data.password === data.confirm_password, { + message: 'Password dan Konfirmasi Password harus sama', + path: ['confirm_password'], + }); + +export type ResetPasswordValidationSchema = z.infer< + typeof resetPasswordValidationSchema +>; diff --git a/apps/landing/src/app/(auth)/reset-password/page.tsx b/apps/landing/src/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..6378290 --- /dev/null +++ b/apps/landing/src/app/(auth)/reset-password/page.tsx @@ -0,0 +1,30 @@ +import { LogoSimple } from '@/app/_components/logo'; +import { redirect } from 'next/navigation'; +import { use } from 'react'; +import { ResetPasswordForm } from './_components/reset-password-form'; + +export const dynamic = 'force-dynamic'; + +export default function Page({ + searchParams, +}: { + searchParams: Promise<{ token?: string }>; +}) { + const { token } = use(searchParams); + + if (!token) redirect('/signin'); + + return ( + <> +
+ + +

+ Ubah passwordmu +

+ + +
+ + ); +} diff --git a/apps/landing/src/app/(auth)/signin/_actions/signin-action.ts b/apps/landing/src/app/(auth)/signin/_actions/signin-action.ts new file mode 100644 index 0000000..0397189 --- /dev/null +++ b/apps/landing/src/app/(auth)/signin/_actions/signin-action.ts @@ -0,0 +1,22 @@ +'use server'; + +import { setAccessToken, setRefreshToken } from '@/lib/cookies'; +import { fetchPostSignin } from '../_http/fetch-post-signin'; +import { + type SignInValidationType, + signInValidationSchema, +} from '../_validation/signin-validation'; + +export async function SigninAction(request: SignInValidationType) { + const validRequest = signInValidationSchema.parse(request); + + const { data } = await fetchPostSignin(validRequest); + + const accessToken = data.token.access_token; + const refreshToken = data.token.refresh_token; + + await setAccessToken(accessToken); + await setRefreshToken(refreshToken); + + return data; +} diff --git a/apps/landing/src/app/(auth)/signin/_components/signin-form.tsx b/apps/landing/src/app/(auth)/signin/_components/signin-form.tsx new file mode 100644 index 0000000..490627b --- /dev/null +++ b/apps/landing/src/app/(auth)/signin/_components/signin-form.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { + Button, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from '@components'; +import Link from 'next/link'; +import { LuLoader } from 'react-icons/lu'; +import { useFormSignin } from '../_hooks/use-form-signin'; +import { usePostSignin } from '../_hooks/use-post-signin'; +import { type SignInValidationType } from '../_validation/signin-validation'; + +export function SigninForm() { + const form = useFormSignin(); + const { mutate, isPending, error } = usePostSignin(form); + + const onSubmit = (values: SignInValidationType) => { + mutate(values); + }; + + return ( +
+ + {error && ( +
+ {(error as Error).message} +
+ )} + + ( + + Email + + + + + + )} + /> + + ( + + Password + + + + +
+ + Lupa Password? + +
+
+ )} + /> + + + + + ); +} diff --git a/apps/landing/src/app/(auth)/signin/_hooks/use-form-signin.ts b/apps/landing/src/app/(auth)/signin/_hooks/use-form-signin.ts new file mode 100644 index 0000000..76af058 --- /dev/null +++ b/apps/landing/src/app/(auth)/signin/_hooks/use-form-signin.ts @@ -0,0 +1,16 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { + signInValidationSchema, + type SignInValidationType, +} from '../_validation/signin-validation'; + +export function useFormSignin() { + return useForm({ + resolver: zodResolver(signInValidationSchema), + defaultValues: { + email: '', + password: '', + }, + }); +} diff --git a/apps/landing/src/app/(auth)/signin/_hooks/use-post-signin.ts b/apps/landing/src/app/(auth)/signin/_hooks/use-post-signin.ts new file mode 100644 index 0000000..2d4ee62 --- /dev/null +++ b/apps/landing/src/app/(auth)/signin/_hooks/use-post-signin.ts @@ -0,0 +1,28 @@ +import { useMutation } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { SigninAction } from '../_actions/signin-action'; +import { useFormSignin } from './use-form-signin'; + +export function usePostSignin(form: ReturnType) { + const router = useRouter(); + + return useMutation({ + mutationFn: SigninAction, + + onSuccess: () => { + router.push('/'); + }, + onError: (error, variables) => { + form.resetField('password'); + + if (error.message.includes('not active')) { + setTimeout(() => { + router.push(`/verification?ref=${variables.email}`); + }, 750); + } + + toast.error(error.message); + }, + }); +} diff --git a/apps/landing/src/app/(auth)/signin/_http/fetch-post-signin.ts b/apps/landing/src/app/(auth)/signin/_http/fetch-post-signin.ts new file mode 100644 index 0000000..52fbab2 --- /dev/null +++ b/apps/landing/src/app/(auth)/signin/_http/fetch-post-signin.ts @@ -0,0 +1,24 @@ +import { fetcher } from '@/lib/fetcher'; +import { SignInValidationType } from '../_validation/signin-validation'; + +export async function fetchPostSignin({ + email, + password, +}: SignInValidationType) { + const { data, error, response } = await fetcher.POST('/v1/auth/login', { + body: { + email, + password, + }, + }); + + if (error) { + throw new Error(error.message); + } + + if (!response.ok) { + throw new Error('Someting went wrong, please try again later'); + } + + return data; +} diff --git a/apps/landing/src/app/(auth)/signin/_validation/signin-validation.ts b/apps/landing/src/app/(auth)/signin/_validation/signin-validation.ts new file mode 100644 index 0000000..0bb050b --- /dev/null +++ b/apps/landing/src/app/(auth)/signin/_validation/signin-validation.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export const signInValidationSchema = z.object({ + email: z + .string({ + required_error: 'Email tidak boleh kosong', + invalid_type_error: 'Email harus berupa string', + }) + .min(1, 'Email tidak boleh kosong') + .email('Email harus valid'), + password: z + .string({ + required_error: 'Password tidak boleh kosong', + invalid_type_error: 'Password harus berupa string', + }) + .min(1, 'Password tidak boleh kosong'), +}); +export type SignInValidationType = z.infer; diff --git a/apps/landing/src/app/(auth)/signin/page.tsx b/apps/landing/src/app/(auth)/signin/page.tsx new file mode 100644 index 0000000..2eb58e1 --- /dev/null +++ b/apps/landing/src/app/(auth)/signin/page.tsx @@ -0,0 +1,36 @@ +import { LogoSimple } from '@/app/_components/logo'; +import { Metadata } from 'next'; +import Link from 'next/link'; +import { SigninForm } from './_components/signin-form'; + +export const metadata: Metadata = { + title: 'IMPHNEN - Signin', +}; + +export default function Page() { + return ( + <> +
+ + +

+ Masuk untuk mengakses akunmu +

+
+ + + +
+

+ Belum punya akun?{' '} + + Daftar + +

+
+ + ); +} diff --git a/apps/landing/src/app/(auth)/signup/_actions/signup-action.ts b/apps/landing/src/app/(auth)/signup/_actions/signup-action.ts new file mode 100644 index 0000000..a36fbc0 --- /dev/null +++ b/apps/landing/src/app/(auth)/signup/_actions/signup-action.ts @@ -0,0 +1,25 @@ +'use server'; + +import { getRemoteIp } from '@/lib/headers'; +import { z } from 'zod'; +import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile'; +import { fetchPostSignin } from '../_http/fetch-post-signup'; +import { signupValidationSchema } from '../_validation/signup-validation'; + +export async function SignupAction( + request: z.infer +) { + const validRequest = signupValidationSchema.parse(request); + const remoteIp = await getRemoteIp(); + + const isCapchaValid = await fetchPostverifyTurnstile( + validRequest.token, + remoteIp + ); + + if (!isCapchaValid) throw new Error('Failed to verify captcha'); + + const data = await fetchPostSignin(validRequest); + + return data; +} diff --git a/apps/landing/src/app/(auth)/signup/_components/signup-form.tsx b/apps/landing/src/app/(auth)/signup/_components/signup-form.tsx new file mode 100644 index 0000000..40fe83f --- /dev/null +++ b/apps/landing/src/app/(auth)/signup/_components/signup-form.tsx @@ -0,0 +1,223 @@ +'use client'; + +import { + Button, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from '@components'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile'; +import { useMutation } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; +import { useRef, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { LuLoader } from 'react-icons/lu'; +import { z } from 'zod'; +import { SignupAction } from '../_actions/signup-action'; +import { + signupValidationSchema, + stepOneSignupValidationSchema, + stepTwoSignupValidationSchema, +} from '../_validation/signup-validation'; + +export function SignupForm() { + const router = useRouter(); + const ref = useRef(null); + + const [step, setStep] = useState(1); + const [stepOneData, setStepOneData] = useState | null>(null); + + const firstForm = useForm>({ + resolver: zodResolver(stepOneSignupValidationSchema), + defaultValues: { + email: '', + phone_number: '', + fullname: '', + password: '', + confirm_password: '', + }, + }); + + const secondForm = useForm>({ + resolver: zodResolver(stepTwoSignupValidationSchema), + defaultValues: { + token: '', + }, + }); + + const { mutate, isPending, error } = useMutation({ + mutationFn: async (data: z.infer) => { + const result = await SignupAction(data); + + return { + ...result, + email: data.email, + }; + }, + onSuccess: ({ email }) => { + router.push(`/verification?ref=${email}`); + }, + onError: () => { + ref.current?.reset(); + secondForm.resetField('token'); + }, + }); + + const handleFirstSubmit = ( + values: z.infer + ) => { + setStepOneData(values); + setStep(2); + }; + + const handleSecondSubmit = ( + values: z.infer + ) => { + if (stepOneData) { + mutate({ ...stepOneData, ...values }); + } + }; + + return ( + <> + {step === 1 && ( +
+ + ( + + Full Name + + + + + + )} + /> + + ( + + Email + + + + + + )} + /> + + ( + + Nomor Telepon + + + + + + )} + /> + + ( + + Password + + + + + + )} + /> + + ( + + Confirm Password + + + + + + )} + /> + + + + + )} + + {step === 2 && ( +
+ + {error && ( +
+ {(error as Error).message} +
+ )} + + secondForm.setValue('token', token)} + options={{ + theme: 'light', + size: 'flexible', + language: 'id', + }} + /> + +
+ + +
+ + + )} + + ); +} diff --git a/apps/landing/src/app/(auth)/signup/_http/fetch-post-signup.ts b/apps/landing/src/app/(auth)/signup/_http/fetch-post-signup.ts new file mode 100644 index 0000000..16e6c16 --- /dev/null +++ b/apps/landing/src/app/(auth)/signup/_http/fetch-post-signup.ts @@ -0,0 +1,28 @@ +import { fetcher } from '@/lib/fetcher'; +import { SignupValidationSchema } from '../_validation/signup-validation'; + +export async function fetchPostSignin({ + email, + password, + fullname, + phone_number, +}: SignupValidationSchema) { + const { data, error, response } = await fetcher.POST('/v1/auth/register', { + body: { + email, + password, + fullname, + phone_number, + }, + }); + + if (error) { + throw new Error(error.message); + } + + if (!response.ok) { + throw new Error('Someting went wrong, please try again later'); + } + + return data; +} diff --git a/apps/landing/src/app/(auth)/signup/_validation/signup-validation.ts b/apps/landing/src/app/(auth)/signup/_validation/signup-validation.ts new file mode 100644 index 0000000..dd994a4 --- /dev/null +++ b/apps/landing/src/app/(auth)/signup/_validation/signup-validation.ts @@ -0,0 +1,57 @@ +import { z } from 'zod'; + +export const stepOneSignupValidationSchema = z + .object({ + email: z + .string({ + required_error: 'Email tidak boleh kosong', + invalid_type_error: 'Email harus berupa string', + }) + .min(1, 'Email tidak boleh kosong') + .email('Email harus valid'), + fullname: z + .string({ + required_error: 'Nama tidak boleh kosong', + invalid_type_error: 'Nama harus berupa string', + }) + .min(1, 'Nama tidak boleh kosong') + .max(50, 'Nama tidak boleh lebih dari 50 karakter'), + password: z + .string({ + required_error: 'Password tidak boleh kosong', + invalid_type_error: 'Password harus berupa string', + }) + .min(8, 'Password harus minimal 8 karakter') + .max(50, 'Password tidak boleh lebih dari 50 karakter') + .regex( + /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*\W).+$/, + 'Password harus mengandung setidaknya satu huruf kapital, satu huruf kecil, satu angka, dan satu karakter spesial' + ), + confirm_password: z + .string({ + required_error: 'Konfirmasi password tidak boleh kosong', + }) + .min(8, 'Konfirmasi password harus minimal 8 karakter') + .max(50, 'Konfirmasi password tidak boleh lebih dari 50 karakter'), + phone_number: z + .string({ + required_error: 'Nomor telepon tidak boleh kosong', + invalid_type_error: 'Nomor telepon harus berupa string', + }) + .min(10, 'Nomor telepon tidak boleh kurang dari 10 karakter') + .max(15, 'Nomor telepon tidak boleh lebih dari 15 karakter') + .regex(/^\d+$/, 'Nomor telepon hanya boleh berisi angka'), + }) + .refine((data) => data.password === data.confirm_password, { + message: 'Password dan Konfirmasi Password harus sama', + path: ['confirm_password'], + }); + +export const stepTwoSignupValidationSchema = z.object({ + token: z.string(), +}); + +export const signupValidationSchema = stepOneSignupValidationSchema.and( + stepTwoSignupValidationSchema +); +export type SignupValidationSchema = z.infer; diff --git a/apps/landing/src/app/(auth)/signup/page.tsx b/apps/landing/src/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..444d145 --- /dev/null +++ b/apps/landing/src/app/(auth)/signup/page.tsx @@ -0,0 +1,36 @@ +import { LogoSimple } from '@/app/_components/logo'; +import { Metadata } from 'next'; +import Link from 'next/link'; +import { SignupForm } from './_components/signup-form'; + +export const metadata: Metadata = { + title: 'IMPHNEN - Signup', +}; + +export default function Page() { + return ( + <> +
+ + +

+ Buat akunmu sekarang +

+
+ + + +
+

+ Sudah punya akun?{' '} + + Masuk + +

+
+ + ); +} diff --git a/apps/landing/src/app/(auth)/verification/_actions/resend-otp-action.ts b/apps/landing/src/app/(auth)/verification/_actions/resend-otp-action.ts new file mode 100644 index 0000000..48f733e --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_actions/resend-otp-action.ts @@ -0,0 +1,31 @@ +'use server'; + +import { fetcher } from '@/lib/fetcher'; +import { getRemoteIp } from '@/lib/headers'; +import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile'; +import { + resendOTPValidationSchema, + type ResendOTPValidationType, +} from '../_validation/resend-otp-validation'; + +export async function resendOTPAction(request: ResendOTPValidationType) { + const validRequest = resendOTPValidationSchema.parse(request); + const remoteIp = await getRemoteIp(); + + const isCapchaValid = await fetchPostverifyTurnstile( + validRequest.token, + remoteIp + ); + + if (!isCapchaValid) throw new Error('Failed to verify captcha'); + + const { data, error } = await fetcher.POST('/v1/auth/send-otp', { + body: { + email: validRequest.email, + }, + }); + + if (error) throw new Error(error.message); + + return data; +} diff --git a/apps/landing/src/app/(auth)/verification/_actions/verify-email-action.ts b/apps/landing/src/app/(auth)/verification/_actions/verify-email-action.ts new file mode 100644 index 0000000..8e18021 --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_actions/verify-email-action.ts @@ -0,0 +1,15 @@ +'use server'; + +import { fetchPostVerifyEmail } from '../_http/fetch-post-verify-email'; +import { + type VerifyEmailValidationType, + verifyEmailValidationSchema, +} from '../_validation/verify-email-validation'; + +export async function verifyEmailAction(request: VerifyEmailValidationType) { + const validRequest = verifyEmailValidationSchema.parse(request); + + const data = await fetchPostVerifyEmail(validRequest); + + return data; +} diff --git a/apps/landing/src/app/(auth)/verification/_components/resend-otp-form.tsx b/apps/landing/src/app/(auth)/verification/_components/resend-otp-form.tsx new file mode 100644 index 0000000..7731608 --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_components/resend-otp-form.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { + Button, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from '@components'; +import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile'; +import { useRef } from 'react'; +import { useFormResendOTP } from '../_hooks/use-form-resend-otp'; +import { usePostResendOTP } from '../_hooks/use-post-resend-otp'; +import { ResendOTPValidationType } from '../_validation/resend-otp-validation'; + +export function ResendOTPForm() { + const ref = useRef(null); + + const form = useFormResendOTP(); + + const { mutate, isPending, error } = usePostResendOTP(form); + + const onSubmit = (values: ResendOTPValidationType) => { + mutate(values); + }; + + return ( +
+ + {error && ( +
+ {(error as Error).message} +
+ )} + + ( + + Email + + + + + + )} + /> + + form.setValue('token', token)} + options={{ + theme: 'light', + size: 'flexible', + language: 'id', + }} + /> + + + + + ); +} diff --git a/apps/landing/src/app/(auth)/verification/_components/verification-tabs.tsx b/apps/landing/src/app/(auth)/verification/_components/verification-tabs.tsx new file mode 100644 index 0000000..319505c --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_components/verification-tabs.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { cn } from '@utils'; +import { useState } from 'react'; +import { ResendOTPForm } from './resend-otp-form'; +import { VerifyEmailForm } from './verify-email-form'; + +export function VerificationTabs() { + const [activeTab, setActiveTab] = useState<'form' | 'resend'>('form'); + + return ( + <> +
+ + +
+ + {activeTab === 'form' ? : } + + ); +} diff --git a/apps/landing/src/app/(auth)/verification/_components/verify-email-form.tsx b/apps/landing/src/app/(auth)/verification/_components/verify-email-form.tsx new file mode 100644 index 0000000..895bb10 --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_components/verify-email-form.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { + Button, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from '@components'; +import { LuLoader } from 'react-icons/lu'; +import { useFormVerifyEmail } from '../_hooks/use-form-verify-email'; +import { usePostVerifyEmail } from '../_hooks/use-post-verify-email'; +import { type VerifyEmailValidationType } from '../_validation/verify-email-validation'; + +export function VerifyEmailForm() { + const form = useFormVerifyEmail(); + const { mutate, isPending, error } = usePostVerifyEmail(form); + + const onSubmit = (values: VerifyEmailValidationType) => { + mutate(values); + }; + + return ( +
+ + {error && ( +
+ {(error as Error).message} +
+ )} + + ( + + Email + + + + + + )} + /> + + ( + + OTP + + + + + + )} + /> + + + + + ); +} diff --git a/apps/landing/src/app/(auth)/verification/_hooks/use-form-resend-otp.ts b/apps/landing/src/app/(auth)/verification/_hooks/use-form-resend-otp.ts new file mode 100644 index 0000000..8578cda --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_hooks/use-form-resend-otp.ts @@ -0,0 +1,20 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useSearchParams } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { + resendOTPValidationSchema, + type ResendOTPValidationType, +} from '../_validation/resend-otp-validation'; + +export function useFormResendOTP() { + const searchParams = useSearchParams(); + const email = searchParams.get('ref'); + + return useForm({ + resolver: zodResolver(resendOTPValidationSchema), + defaultValues: { + email: email ?? '', + token: '', + }, + }); +} diff --git a/apps/landing/src/app/(auth)/verification/_hooks/use-form-verify-email.ts b/apps/landing/src/app/(auth)/verification/_hooks/use-form-verify-email.ts new file mode 100644 index 0000000..859a6a8 --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_hooks/use-form-verify-email.ts @@ -0,0 +1,20 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useSearchParams } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { + verifyEmailValidationSchema, + type VerifyEmailValidationType, +} from '../_validation/verify-email-validation'; + +export function useFormVerifyEmail() { + const searchParams = useSearchParams(); + const email = searchParams.get('ref'); + + return useForm({ + resolver: zodResolver(verifyEmailValidationSchema), + defaultValues: { + email: email ?? '', + otp: '', + }, + }); +} diff --git a/apps/landing/src/app/(auth)/verification/_hooks/use-post-resend-otp.ts b/apps/landing/src/app/(auth)/verification/_hooks/use-post-resend-otp.ts new file mode 100644 index 0000000..401d6eb --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_hooks/use-post-resend-otp.ts @@ -0,0 +1,18 @@ +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { resendOTPAction } from '../_actions/resend-otp-action'; +import { useFormResendOTP } from './use-form-resend-otp'; + +export function usePostResendOTP(form: ReturnType) { + return useMutation({ + mutationFn: resendOTPAction, + onSuccess: ({ message }) => { + form.reset(); + toast.success(message); + }, + onError: ({ message }) => { + form.reset(); + toast.error(message); + }, + }); +} diff --git a/apps/landing/src/app/(auth)/verification/_hooks/use-post-verify-email.ts b/apps/landing/src/app/(auth)/verification/_hooks/use-post-verify-email.ts new file mode 100644 index 0000000..e7da35d --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_hooks/use-post-verify-email.ts @@ -0,0 +1,20 @@ +import { useMutation } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; +import { verifyEmailAction } from '../_actions/verify-email-action'; +import { useFormVerifyEmail } from './use-form-verify-email'; + +export function usePostVerifyEmail( + form: ReturnType +) { + const router = useRouter(); + + return useMutation({ + mutationFn: verifyEmailAction, + onSuccess: () => { + router.push('/'); + }, + onError: () => { + form.resetField('otp'); + }, + }); +} diff --git a/apps/landing/src/app/(auth)/verification/_http/fetch-post-verify-email.ts b/apps/landing/src/app/(auth)/verification/_http/fetch-post-verify-email.ts new file mode 100644 index 0000000..e0a57c8 --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_http/fetch-post-verify-email.ts @@ -0,0 +1,31 @@ +import { fetcher } from '@/lib/fetcher'; +import { VerifyEmailValidationType } from '../_validation/verify-email-validation'; + +export async function fetchPostVerifyEmail({ + email, + otp, +}: VerifyEmailValidationType) { + const formattedOTP = parseInt(otp); + + const { data, error, response } = await fetcher.POST( + '/v1/auth/verify-email', + { + body: { + email, + otp: formattedOTP, + }, + } + ); + + if (error) { + throw new Error(error.message); + } + + if (!response.ok) { + throw new Error('Something went wrong, please try again later'); + } + + console.log(data); + + return data; +} diff --git a/apps/landing/src/app/(auth)/verification/_validation/resend-otp-validation.ts b/apps/landing/src/app/(auth)/verification/_validation/resend-otp-validation.ts new file mode 100644 index 0000000..2b4bc20 --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_validation/resend-otp-validation.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const resendOTPValidationSchema = z.object({ + email: z.string().email({ message: 'Format email tidak valid' }), + token: z.string().min(1, { message: 'OTP harus terdiri dari 1 digit' }), +}); +export type ResendOTPValidationType = z.infer; diff --git a/apps/landing/src/app/(auth)/verification/_validation/verify-email-validation.ts b/apps/landing/src/app/(auth)/verification/_validation/verify-email-validation.ts new file mode 100644 index 0000000..7ace2c8 --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_validation/verify-email-validation.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const verifyEmailValidationSchema = z.object({ + email: z.string().email({ message: 'Format email tidak valid' }), + otp: z + .string() + .min(6, { message: 'OTP harus terdiri dari 6 digit' }) + .max(6, { message: 'OTP harus terdiri dari 6 digit' }), +}); +export type VerifyEmailValidationType = z.infer< + typeof verifyEmailValidationSchema +>; diff --git a/apps/landing/src/app/(auth)/verification/page.tsx b/apps/landing/src/app/(auth)/verification/page.tsx new file mode 100644 index 0000000..21658ed --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/page.tsx @@ -0,0 +1,21 @@ +import { Suspense } from 'react'; +import { LogoSimple } from '../../_components/logo'; +import { VerificationTabs } from './_components/verification-tabs'; + +export default function Page() { + return ( + <> +
+ + +

+ Verifikasi akun mu sekarang +

+
+ + + + + + ); +} diff --git a/apps/landing/src/app/(home)/_components/call-to-action.tsx b/apps/landing/src/app/(home)/_components/call-to-action.tsx deleted file mode 100644 index b7a608f..0000000 --- a/apps/landing/src/app/(home)/_components/call-to-action.tsx +++ /dev/null @@ -1,75 +0,0 @@ -'use client'; - -import { Button } from '@components'; -import { motion, useInView } from 'framer-motion'; -import { useRef } from 'react'; - -export function CallToAction() { - const ref = useRef(null); - const isInView = useInView(ref, { once: true, amount: 0.2 }); - - return ( -
- {/* Background Elements */} -
-
-
-
- -
- -
-
- -
-

- Siap Menjadi{' '} - - Programmer Handal? - -

-

- Bergabunglah dengan komunitas IMPHNEN sekarang dan mulai - perjalanan programming mu dengan cara yang menyenangkan! -

- -
- - -
-
- - {/* Decorative Elements */} -
-
-
- -
-
- ); -} diff --git a/apps/landing/src/app/(home)/_components/community.tsx b/apps/landing/src/app/(home)/_components/community.tsx deleted file mode 100644 index 20b6ff0..0000000 --- a/apps/landing/src/app/(home)/_components/community.tsx +++ /dev/null @@ -1,110 +0,0 @@ -'use client'; - -import COMMUNITIES_STATS from '@/data/communities-stats.json'; -import COMMUNITIES from '@/data/communities.json'; -import { Button } from '@components'; -import { Icon } from '@iconify/react'; -import { motion, useInView } from 'framer-motion'; -import { useRef } from 'react'; - -export function Community() { - const ref = useRef(null); - const isInView = useInView(ref, { once: true, amount: 0.2 }); - - const containerVariants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.1, - }, - }, - }; - - const itemVariants = { - hidden: { y: 20, opacity: 0 }, - visible: { - y: 0, - opacity: 1, - transition: { duration: 0.5 }, - }, - }; - - return ( -
-
-
-
-
-
- -

- Komunitas{' '} - - Kami - -

-

- Bergabunglah dengan ribuan programmer Indonesia yang saling membantu - dan berbagi pengalaman. -

-
- - {COMMUNITIES.map((c, i) => ( - -
-
-
- -
-

{c.title}

-

{c.description}

- -
-
- - ))} - - - {COMMUNITIES_STATS.map((s, i) => ( -
-
- {s.value} -
-
{s.label}
-
- ))} -
-
-
- ); -} diff --git a/apps/landing/src/app/(home)/_components/features.tsx b/apps/landing/src/app/(home)/_components/features.tsx deleted file mode 100644 index 61df652..0000000 --- a/apps/landing/src/app/(home)/_components/features.tsx +++ /dev/null @@ -1,94 +0,0 @@ -'use client'; - -import FEATURES from '@/data/features.json'; -import { Icon } from '@iconify/react'; -import { motion, useInView } from 'framer-motion'; -import { useRef } from 'react'; - -export function Features() { - const ref = useRef(null); - const isInView = useInView(ref, { once: true, amount: 0.2 }); - - const containerVariants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.1, - }, - }, - }; - - const itemVariants = { - hidden: { y: 20, opacity: 0 }, - visible: { - y: 0, - opacity: 1, - transition: { duration: 0.5 }, - }, - }; - - return ( -
-
-
-
-
- -
-
- -
- Fitur Unggulan -
-

- Belajar programming dengan cara yang lebih baik -

-

- IMPHNEN hadir dengan berbagai fitur untuk membantu kamu menjadi - programmer handal tanpa harus pusing dengan coding. -

-
-
- - - {FEATURES.map((feature, index) => ( - -
- -
-
- -
-

{feature.title}

-

{feature.description}

-
- -
- - ))} - -
-
- ); -} diff --git a/apps/landing/src/app/(home)/_components/learning-resources.tsx b/apps/landing/src/app/(home)/_components/learning-resources.tsx deleted file mode 100644 index 9f39a99..0000000 --- a/apps/landing/src/app/(home)/_components/learning-resources.tsx +++ /dev/null @@ -1,141 +0,0 @@ -'use client'; - -import LEARNING_RESOURCES from '@/data/learning-resources.json'; -import { Button } from '@components'; -import { Icon } from '@iconify/react'; -import { motion, useInView } from 'framer-motion'; -import { useRef } from 'react'; - -export function LearningResources() { - const ref = useRef(null); - const isInView = useInView(ref, { once: true, amount: 0.2 }); - - const containerVariants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.1, - }, - }, - }; - - const itemVariants = { - hidden: { y: 20, opacity: 0 }, - visible: { - y: 0, - opacity: 1, - transition: { duration: 0.5 }, - }, - }; - - return ( -
-
-
-
-
-
-
-
- -

- Sumber Belajar -

-

- Akses berbagai materi belajar yang akan membantu kamu menguasai - konsep programming dengan cara yang menyenangkan. -

-
-
- - {LEARNING_RESOURCES.map((r, i) => ( - -
-
-
- -
-

{r.title}

-

{r.description}

- - -
-
- - ))} - - -
-
-
- Rekomendasi Terbaik -
-

- Kursus Lengkap Web Development -

-

- Pelajari HTML, CSS, JavaScript, React, dan Node.js dalam satu - kursus komprehensif yang dirancang untuk pemula hingga tingkat - menengah. -

-
- - -
-
-
-
-
-
-
-
-
-
-
- -
-
- ); -} diff --git a/apps/landing/src/app/(home)/_components/testimonials.tsx b/apps/landing/src/app/(home)/_components/testimonials.tsx deleted file mode 100644 index 159304a..0000000 --- a/apps/landing/src/app/(home)/_components/testimonials.tsx +++ /dev/null @@ -1,126 +0,0 @@ -'use client'; - -import TESTIMONIAL_STATS from '@/data/testimonial-stats.json'; -import TESTIMONIALS from '@/data/testimonials.json'; -import { QuoteIcon } from '@components'; -import { motion, useInView } from 'framer-motion'; -import Image from 'next/image'; -import { useRef } from 'react'; - -export function Testimonials() { - const ref = useRef(null); - const isInView = useInView(ref, { once: true, amount: 0.2 }); - - const containerVariants = { - hidden: { opacity: 0 }, - visible: { opacity: 1, transition: { staggerChildren: 0.1 } }, - }; - - const itemVariants = { - hidden: { y: 20, opacity: 0 }, - visible: { y: 0, opacity: 1, transition: { duration: 0.5 } }, - }; - - return ( -
-
-
-
-
- -
- -

- - Testimoni - - Member -

-

- Apa kata mereka yang telah bergabung dengan komunitas IMPHNEN? -

-
- - - {TESTIMONIALS.map((t, idx) => ( - -
- -
-
-

- “{t.quote}” -

-
-
- {t.name} -
-
-

{t.name}

-

{t.role}

-
-
-
-
- - ))} - - - -
-
-

- Bergabunglah dengan 10,000+ programmer Indonesia lainnya -

-

- Komunitas kami terus berkembang dengan programmer dari berbagai - latar belakang dan tingkat keahlian. Bersama-sama, kita belajar, - berbagi, dan tumbuh sebagai profesional. -

-
-
- {TESTIMONIAL_STATS.map((s, idx) => ( -
-
- {s.value} -
-
{s.label}
-
- ))} -
-
-
-
-
- ); -} diff --git a/apps/landing/src/app/(home)/page.tsx b/apps/landing/src/app/(home)/page.tsx deleted file mode 100644 index b9a8680..0000000 --- a/apps/landing/src/app/(home)/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { CallToAction } from './_components/call-to-action'; -import { Community } from './_components/community'; -import { Features } from './_components/features'; -import { Hero } from './_components/hero'; -import { LearningResources } from './_components/learning-resources'; -import { Testimonials } from './_components/testimonials'; - -export default function Page() { - return ( - <> - - - - - - - - ); -} diff --git a/apps/landing/src/app/(public)/(home)/_components/community-section.tsx b/apps/landing/src/app/(public)/(home)/_components/community-section.tsx new file mode 100644 index 0000000..a005d35 --- /dev/null +++ b/apps/landing/src/app/(public)/(home)/_components/community-section.tsx @@ -0,0 +1,154 @@ +'use client'; + +import SOCIALS from '@/data/socials.json'; +import { motion, useInView } from 'framer-motion'; +import { useRef } from 'react'; +import { + FaArrowRight, + FaDiscord, + FaFacebook, + FaInstagram, + FaLinkedin, + FaTiktok, +} from 'react-icons/fa'; + +export function CommunitySection() { + const ref = useRef(null); + const isInView = useInView(ref, { once: true, amount: 0.2 }); + + const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.1, + delayChildren: 0.2, + }, + }, + }; + + const itemVariants = { + hidden: { y: 20, opacity: 0 }, + visible: { + y: 0, + opacity: 1, + transition: { + duration: 0.4, + ease: [0.25, 0.46, 0.45, 0.94], + }, + }, + }; + + const getIconComponent = (iconName: string) => { + switch (iconName) { + case 'FaFacebook': + return FaFacebook; + case 'FaDiscord': + return FaDiscord; + case 'FaInstagram': + return FaInstagram; + case 'FaTiktok': + return FaTiktok; + case 'FaLinkedin': + return FaLinkedin; + default: + return FaArrowRight; + } + }; + + const getPlatformColors = (iconName: string) => { + switch (iconName) { + case 'FaFacebook': + return { + iconColor: 'text-[#1877F2]', + }; + case 'FaDiscord': + return { + iconColor: 'text-[#5865F2]', + }; + case 'FaInstagram': + return { + iconColor: 'text-[#E4405F]', + }; + case 'FaTiktok': + return { + iconColor: 'text-[#000000]', + }; + case 'FaLinkedin': + return { + iconColor: 'text-[#0A66C2]', + }; + default: + return { + iconColor: 'text-primary-500', + }; + } + }; + + return ( +
+
+
+ +

+ Bergabung dengan Komunitas Kami di + + Berbagai Platform + +

+

+ Terhubung dengan sesama developer di komunitas kami +

+
+
+ + + {SOCIALS.map((community, index) => { + const IconComponent = getIconComponent(community.icon); + const colors = getPlatformColors(community.icon); + + return ( + +
+
+ +

+ {community.name} +

+
+

+ {community.description} +

+ + Jelajahi Komunitas + + +
+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/landing/src/app/(public)/(home)/_components/cta-section.tsx b/apps/landing/src/app/(public)/(home)/_components/cta-section.tsx new file mode 100644 index 0000000..33ffaf0 --- /dev/null +++ b/apps/landing/src/app/(public)/(home)/_components/cta-section.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { LogoSimple } from '@/app/_components/logo'; +import { motion, useInView } from 'framer-motion'; +import { useRef } from 'react'; +import { FaArrowRight } from 'react-icons/fa'; + +export function CTASection() { + const ref = useRef(null); + const isInView = useInView(ref, { once: true, amount: 0.2 }); + + return ( +
+ {/* Background pattern */} +
+ +
+
+ {/* Text Content */} +
+ +

+ LET'S GO + + SAAT + NYA + + KAMU JOIN! +

+
+ + + Jadilah bagian dari komunitas developer terbesar di Indonesia. + Tingkatkan skill, perluas jaringan, dan raih kesempatan karir + bersama kami! + + + + + Join Sekarang + + + +
+ + {/* Illustration */} + +
+
+
+
+ +
+

180.000+

+

Programmer Sudah Bergabung

+
+
+
+
+
+
+
+
+ ); +} diff --git a/apps/landing/src/app/(home)/_components/hero.tsx b/apps/landing/src/app/(public)/(home)/_components/hero-section.tsx similarity index 72% rename from apps/landing/src/app/(home)/_components/hero.tsx rename to apps/landing/src/app/(public)/(home)/_components/hero-section.tsx index f669874..5d3b497 100644 --- a/apps/landing/src/app/(home)/_components/hero.tsx +++ b/apps/landing/src/app/(public)/(home)/_components/hero-section.tsx @@ -1,12 +1,18 @@ 'use client'; +import HERO_CONTENT from '@/data/hero-content.json'; import HERO_STATS from '@/data/hero-stats.json'; -import { Button, SparklesIcon } from '@components'; +import { Button } from '@components'; import { motion } from 'framer-motion'; import Image from 'next/image'; +import { useRouter } from 'next/navigation'; import { Fragment, useEffect, useState } from 'react'; -export function Hero() { +export function HeroSection() { + const router = useRouter(); + const { communityLabel, headingLine1, headingLine2, description, buttons } = + HERO_CONTENT; + const [scrollY, setScrollY] = useState(0); useEffect(() => { @@ -20,6 +26,7 @@ export function Hero() { return (
+ {/* Background gradients and motion */}
-
- - Komunitas Programmer Indonesia -
+ + {communityLabel} +

- Programmer Handal,
- - Tanpa Ribet + {headingLine1}{' '} + + {headingLine2}

- Temukan potensi programming Anda bersama komunitas yang - mendukung, tutorial interaktif, dan sumber daya berkualitas - tinggi. + {description}

-
@@ -96,7 +89,7 @@ export function Hero() { {HERO_STATS.map(({ value, label }, i) => (
-
+
{value}
{label}
diff --git a/apps/landing/src/app/(public)/(home)/_components/testimonial-section.tsx b/apps/landing/src/app/(public)/(home)/_components/testimonial-section.tsx new file mode 100644 index 0000000..bc4d169 --- /dev/null +++ b/apps/landing/src/app/(public)/(home)/_components/testimonial-section.tsx @@ -0,0 +1,102 @@ +'use client'; + +import TESTIMONIALS from '@/data/testimonials.json'; +import { buttonVariants } from '@components'; +import { motion, useInView } from 'framer-motion'; +import Image from 'next/image'; +import Link from 'next/link'; +import { useRef } from 'react'; +import { FaQuoteLeft } from 'react-icons/fa'; + +export function TestimonialSection() { + const ref = useRef(null); + const isInView = useInView(ref, { once: true, amount: 0.1 }); + + const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.1, + delayChildren: 0.2, + }, + }, + }; + + const itemVariants = { + hidden: { y: 20, opacity: 0 }, + visible: { + y: 0, + opacity: 1, + transition: { + duration: 0.4, + ease: [0.25, 0.46, 0.45, 0.94], + }, + }, + }; + + return ( +
+
+
+ +

+ Apa Kata Mereka Tentang + + Komunitas Kami? + +

+
+
+ + + {TESTIMONIALS.map((testimonial) => ( + +
+
+ {testimonial.name} +
+

+ {testimonial.name} +

+

{testimonial.role}

+
+
+
+ +

{testimonial.text}

+
+
+
+ ))} +
+ +
+ + Tulis Testimonimu + +
+
+
+ ); +} diff --git a/apps/landing/src/app/(public)/(home)/page.tsx b/apps/landing/src/app/(public)/(home)/page.tsx new file mode 100644 index 0000000..535f374 --- /dev/null +++ b/apps/landing/src/app/(public)/(home)/page.tsx @@ -0,0 +1,14 @@ +import { CommunitySection } from './_components/community-section'; +import { CTASection } from './_components/cta-section'; +import { HeroSection } from './_components/hero-section'; +import { TestimonialSection } from './_components/testimonial-section'; +export default function Page() { + return ( + <> + + + + + + ); +} diff --git a/apps/landing/src/app/(public)/_components/footer.tsx b/apps/landing/src/app/(public)/_components/footer.tsx new file mode 100644 index 0000000..a6d1257 --- /dev/null +++ b/apps/landing/src/app/(public)/_components/footer.tsx @@ -0,0 +1,102 @@ +import { LogoSimple } from '@/app/_components/logo'; +import NAVIGATIONS from '@/data/navigations.json'; +import SOCIALS from '@/data/socials.json'; +import Link from 'next/link'; +import { + FaDiscord, + FaFacebook, + FaInstagram, + FaLinkedinIn, + FaTiktok, +} from 'react-icons/fa'; + +export default function Footer() { + return ( +
+
+
+
+
+ +
+

+ Ingin Menjadi Programmer Handal Namun Enggan Ngoding +

+
+ + + + + + + + + + + + + + + +
+
+
+

Halaman

+
    + {NAVIGATIONS.map(({ link, title }) => ( +
  • + + {title} + +
  • + ))} +
+
+
+

Link

+
    + {SOCIALS.map(({ link, name }) => ( +
  • + + {name} + +
  • + ))} +
+
+
+

Patners

+
    +
    +
    +
    +

    + © {new Date().getFullYear()} IMPHNEN - Ingin Menjadi Programmer + Handal, Namun Enggan Ngoding. All rights reserved. +

    +
    +
    +
    + ); +} diff --git a/apps/landing/src/app/(public)/_components/header.tsx b/apps/landing/src/app/(public)/_components/header.tsx new file mode 100644 index 0000000..30b40d7 --- /dev/null +++ b/apps/landing/src/app/(public)/_components/header.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { LogoSimple } from '@/app/_components/logo'; +import NAVIGATIONS from '@/data/navigations.json'; +import { Button } from '@components'; +import { cn } from '@utils'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { LuMenu, LuX } from 'react-icons/lu'; + +export function Header() { + const router = useRouter(); + + const [isScrolled, setIsScrolled] = useState(false); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + + useEffect(() => { + const handleScroll = () => { + setIsScrolled(window.scrollY > 10); + }; + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, []); + + return ( +
    +
    +
    +
    + + + +
    +
    + + {/* Desktop Navigation */} + + +
    + + + + {/* Mobile Menu Button */} + +
    +
    + + {/* Mobile Menu */} + {mobileMenuOpen && ( +
    + +
    + )} +
    + ); +} diff --git a/apps/landing/src/app/_components/simple-theme-toggle.tsx b/apps/landing/src/app/(public)/_components/theme-toggle.tsx similarity index 92% rename from apps/landing/src/app/_components/simple-theme-toggle.tsx rename to apps/landing/src/app/(public)/_components/theme-toggle.tsx index a4c65ce..6eac564 100644 --- a/apps/landing/src/app/_components/simple-theme-toggle.tsx +++ b/apps/landing/src/app/(public)/_components/theme-toggle.tsx @@ -4,7 +4,7 @@ import { Button, MoonIcon, SunIcon } from '@components'; import { useTheme } from 'next-themes'; import { useEffect, useState } from 'react'; -export function SimpleThemeToggle() { +export function ThemeToggle() { const { theme, setTheme } = useTheme(); const [mounted, setMounted] = useState(false); @@ -22,7 +22,7 @@ export function SimpleThemeToggle() { return ( + +
    + +
    + {TESTIMONIALS.map((testimonial) => ( +
    +
    +
    + {testimonial.name} +
    +

    + {testimonial.name} +

    +

    {testimonial.role}

    +
    +
    +
    + +

    {testimonial.text}

    +
    +
    +
    + ))} +
    +
    + ); +} diff --git a/apps/landing/src/app/(public)/testimonials/submit/page.tsx b/apps/landing/src/app/(public)/testimonials/submit/page.tsx new file mode 100644 index 0000000..7d1e0b7 --- /dev/null +++ b/apps/landing/src/app/(public)/testimonials/submit/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return <>; +} diff --git a/apps/landing/src/app/_components/footer.tsx b/apps/landing/src/app/_components/footer.tsx deleted file mode 100644 index 6f5b4ba..0000000 --- a/apps/landing/src/app/_components/footer.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import Link from 'next/link'; - -export default function Footer() { - return ( -
    -
    -
    -
    -
    - - IMPHNEN - -
    -

    - Ingin Menjadi Programmer Handal merupakan komunitas Programmer - Indonesia. -

    -
    - - - - - - - - - - - - - - - - - - - - -
    -
    -
    -

    Tautan Cepat

    -
      -
    • - - Fitur - -
    • -
    • - - Komunitas - -
    • -
    • - - Sumber Belajar - -
    • -
    • - - Testimoni - -
    • -
    -
    -
    -

    Sumber Belajar

    -
      -
    • - - Video Tutorial - -
    • -
    • - - Artikel - -
    • -
    • - - Tantangan Koding - -
    • -
    • - - Sharing Session - -
    • -
    -
    -
    -

    Bahasa Pemrograman

    -
    - - PHP - - - JavaScript - - - Python - - - C# - - - Java - - - Go - - - Rust - - - HTML - -
    - -
    -

    Newsletter

    -

    - Dapatkan update terbaru dari kami -

    -
    - - -
    -
    -
    -
    -
    -

    - © {new Date().getFullYear()} IMPHNEN - Ingin Menjadi Programmer - Handal, Namun Enggan Ngoding. All rights reserved. -

    -
    -
    -
    - ); -} diff --git a/apps/landing/src/app/_components/header.tsx b/apps/landing/src/app/_components/header.tsx deleted file mode 100644 index 8a7fb09..0000000 --- a/apps/landing/src/app/_components/header.tsx +++ /dev/null @@ -1,154 +0,0 @@ -'use client'; - -import { Button, MenuIcon, XIcon } from '@components'; -import { cn } from '@utils'; -import Image from 'next/image'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { useEffect, useState } from 'react'; -import { SimpleThemeToggle } from './simple-theme-toggle'; - -export function Header() { - const router = useRouter(); - - const [isScrolled, setIsScrolled] = useState(false); - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - - useEffect(() => { - const handleScroll = () => { - setIsScrolled(window.scrollY > 10); - }; - window.addEventListener('scroll', handleScroll); - return () => window.removeEventListener('scroll', handleScroll); - }, []); - - return ( -
    -
    -
    -
    - - IMPHNEN - -
    -
    - - {/* Desktop Navigation */} - - -
    - - - - - {/* Mobile Menu Button */} - -
    -
    - - {/* Mobile Menu */} - {mobileMenuOpen && ( -
    - -
    - )} -
    - ); -} diff --git a/apps/landing/src/app/_components/logo.tsx b/apps/landing/src/app/_components/logo.tsx new file mode 100644 index 0000000..d7fdfa7 --- /dev/null +++ b/apps/landing/src/app/_components/logo.tsx @@ -0,0 +1,71 @@ +export function Logo(props: React.SVGProps) { + return ( + + + + + + + + + + ); +} + +export function LogoSimple(props: React.SVGProps) { + return ( + + + + + + + + + + ); +} diff --git a/apps/landing/src/app/_components/providers.tsx b/apps/landing/src/app/_components/providers.tsx new file mode 100644 index 0000000..6f21b25 --- /dev/null +++ b/apps/landing/src/app/_components/providers.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ThemeProviderProps } from 'next-themes'; +import { ReactNode } from 'react'; + +export function Providers({ children }: ThemeProviderProps) { + return {children}; +} + +export const queryClient = new QueryClient(); + +function QueryProvider({ children }: { children: ReactNode }) { + return ( + {children} + ); +} diff --git a/apps/landing/src/app/_components/theme-provider.tsx b/apps/landing/src/app/_components/theme-provider.tsx deleted file mode 100644 index 4db6c3c..0000000 --- a/apps/landing/src/app/_components/theme-provider.tsx +++ /dev/null @@ -1,8 +0,0 @@ -'use client'; - -import type { ThemeProviderProps } from 'next-themes'; -import { ThemeProvider as NextThemesProvider } from 'next-themes'; - -export function ThemeProvider({ children, ...props }: ThemeProviderProps) { - return {children}; -} diff --git a/apps/landing/src/app/_components/toaster.tsx b/apps/landing/src/app/_components/toaster.tsx new file mode 100644 index 0000000..ee824a2 --- /dev/null +++ b/apps/landing/src/app/_components/toaster.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { Toaster as Sonner } from 'sonner'; + +type ToasterProps = React.ComponentProps; + +const Toaster = ({ ...props }: ToasterProps) => { + return ( + + ); +}; + +export { Toaster }; diff --git a/apps/landing/src/app/layout.tsx b/apps/landing/src/app/layout.tsx index 95903a5..d5484f1 100644 --- a/apps/landing/src/app/layout.tsx +++ b/apps/landing/src/app/layout.tsx @@ -1,11 +1,9 @@ +import { poppinsFont } from '@/lib/fonts'; import '@/styles/globals.css'; +import { cn } from '@utils'; import { type Metadata } from 'next'; -import { Inter } from 'next/font/google'; -import Footer from './_components/footer'; -import { Header } from './_components/header'; -import { ThemeProvider } from './_components/theme-provider'; - -const inter = Inter({ subsets: ['latin'] }); +import { Providers } from './_components/providers'; +import { Toaster } from './_components/toaster'; export const metadata: Metadata = { title: 'IMPHNEN - Ingin Menjadi Programmer Handal?', @@ -19,19 +17,16 @@ export default function RootLayout({ }) { return ( - - + -
    -
    -
    {children}
    -
    -
    -
    + {children} + + ); diff --git a/apps/landing/src/data/communities-stats.json b/apps/landing/src/data/communities-stats.json deleted file mode 100644 index 689484b..0000000 --- a/apps/landing/src/data/communities-stats.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - { "value": "100K+", "label": "Member Aktif" }, - { "value": "50+", "label": "Event Bulanan" }, - { "value": "100+", "label": "Mentor Profesional" }, - { "value": "5K+", "label": "Diskusi Mingguan" } -] diff --git a/apps/landing/src/data/communities.json b/apps/landing/src/data/communities.json deleted file mode 100644 index a9f179f..0000000 --- a/apps/landing/src/data/communities.json +++ /dev/null @@ -1,23 +0,0 @@ -[ - { - "iconName": "tabler:brand-facebook", - "title": "Facebook Group", - "description": "Bergabunglah dengan grup Facebook kami untuk diskusi santai dan berbagi artikel menarik.", - "buttonText": "Gabung Sekarang", - "buttonLink": "https://facebook.com/groups/programmerhandal" - }, - { - "iconName": "tabler:brand-instagram", - "title": "Instagram", - "description": "Ikuti kami di Instagram untuk tips programming, konten inspiratif, dan info event terbaru.", - "buttonText": "Follow Kami", - "buttonLink": "https://www.instagram.com/imphnen.dev" - }, - { - "iconName": "tabler:brand-discord-filled", - "title": "Discord Server", - "description": "Diskusikan langsung dengan sesama programmer dan dapatkan bantuan langsung dari para ahli.", - "buttonText": "Join Server", - "buttonLink": "https://discord.com/invite/imphnen" - } -] diff --git a/apps/landing/src/data/events.json b/apps/landing/src/data/events.json new file mode 100644 index 0000000..9778e48 --- /dev/null +++ b/apps/landing/src/data/events.json @@ -0,0 +1,46 @@ +[ + { + "name": "IMPHNEN X GDG Medan", + "description": "Join us for the Google Cloud Roadshow: Build with AI in Medan! This is your chance to dive deep into the latest advancements in cloud technology and AI, guided by industry experts.", + "start_date": "2025-04-26T13:00:00+07:00", + "end_date": "2025-04-26T17:00:00+07:00", + "thumbnail": "https://scontent.fsoc1-2.fna.fbcdn.net/v/t39.30808-6/492363939_2132158393873766_2304913620264481070_n.jpg?_nc_cat=105&ccb=1-7&_nc_sid=127cfc&_nc_eui2=AeFe43oCdy2FOIVI6pr_grkO_q5XyVVxt5v-rlfJVXG3m9KgINIp9lo_vzXP4CtAAFJviDNJdDPaPyGnz7B8-gfE&_nc_ohc=bv3GExoL4pUQ7kNvwEpV7xQ&_nc_oc=Adk45dIpzgFxCHgtQKDTo99Sewj0ZMO4LX1cNEI2Dj7VeQDp8jd1RzYnG_UwsKkaTug&_nc_zt=23&_nc_ht=scontent.fsoc1-2.fna&_nc_gid=ZPKg-Y9FLRXcDiSak5WcLA&oh=00_AfL7BFg5g-q6MALMRFUlgaUWK8jHxQVTHdZ_1ezxBthXfw&oe=68354615", + "type": "onsite", + "price": 0, + "location": "Magnificient, Medan", + "detail_link": "https://n8n.gdgmedan.com/form/8466f60d-94b0-42a6-93aa-8738ae4ab5df?fbclid=IwY2xjawKcQoNleHRuA2FlbQIxMABicmlkETFnQ1RvSU5aWVhpWldaOXBjAR7LUWa_npCLb-QSfk1QFTl12tyw_eBhNO8oI8N2LvhwweeE5_41uQsARsEQGw_aem_1IP2vqrQcvjsvLCBy2C6Hg" + }, + { + "name": "IMPHNEN X Connect Citcom", + "description": "Unleash the Future with AI: Decode the potential of artificial intelligence to revolutionize your strategies, unchain innovation, and gain a powerful competitive edge in today's fast-evolving business landscape. Harness the transformative capabilities of AI to outsmart competitors, optimize operations, and drive sustainable growth.", + "start_date": "2025-04-22T13:00:00+07:00", + "end_date": "2025-04-22T17:00:00+07:00", + "thumbnail": "https://scontent.fsoc1-2.fna.fbcdn.net/v/t39.30808-6/490432625_2122941998128739_5372979458124718061_n.jpg?stp=dst-jpg_p526x296_tt6&_nc_cat=105&ccb=1-7&_nc_sid=aa7b47&_nc_eui2=AeGc_W_0Gd3XY9X9ImKz1h1HpYKlinPYX-qlgqWKc9hf6k75HObQiJ8A8m6BZOIe2OrVQjjyXnHq08Rc4JuQWeCQ&_nc_ohc=uoSae0n37k0Q7kNvwHch8E5&_nc_oc=AdngSKtrCdZ4H3hlHh-9cttOnaKyrwiFEcm88kjp0gaflvQ65LUp4OT45-XVGDAoIo8&_nc_zt=23&_nc_ht=scontent.fsoc1-2.fna&_nc_gid=YGkoLGAxNuB0uF3C9hSYGQ&oh=00_AfLRkrzG7DRYa3HRuy9aXfUfG6zLkRBDj9LxqXKOfF9x7Q&oe=683546E0", + "type": "onsite", + "price": 0, + "location": "El Hotel, Bandung", + "detail_link": "/events/1" + }, + { + "name": "JVM Meetup #64", + "description": "Join us for an insightful talk where we’ll explore how AI is reshaping the payment industry. Whether you’re into tech, AI, or just curious about the future of transactions, this is a must-attend event!", + "start_date": "2025-04-30T13:00:00+07:00", + "end_date": "2025-04-30T17:00:00+07:00", + "thumbnail": "https://scontent.fsoc1-1.fna.fbcdn.net/v/t39.30808-6/494158983_2136830150073257_5259003432367969457_n.jpg?_nc_cat=100&ccb=1-7&_nc_sid=833d8c&_nc_eui2=AeGcneO8ihnXyQ_7WV93z7Onz0UPVTr-bf_PRQ9VOv5t_wI-el8xouZ-Z4TKGMMlBPi21d9AA55SP54MHkzO4rx1&_nc_ohc=3Q9NKGIkpT4Q7kNvwHKzwXr&_nc_oc=AdkRJ--zwRGcfX_F2eIgx0qhK-N3OkhwqRwujFhgSRWy5mH_SdIyBmwW8PHHXp71ZUM&_nc_zt=23&_nc_ht=scontent.fsoc1-1.fna&_nc_gid=Xi30SjzSBAU1XN9rRxp0VA&oh=00_AfKvjd9LTMfOwfPLD43U8lKYEr4KABecj336J8hAY8ekMg&oe=68353105", + "type": "onsite", + "price": 0, + "location": "El Hotel, Bandung", + "detail_link": "https://lu.ma/422jkgz0?utm_campaign=jvmmeetup&utm_medium=social%2C%20event%2C%20meetup&utm_source=google%2C%20facebook%2C%20linkedin%2C%20instagram" + }, + { + "name": "JVM Meetup #65", + "description": "Di era digital yang terus berkembang, Artificial Intelligence (AI) bukan lagi teknologi masa depantapi alat bantu masa kini yang siap mendongkrak efisiensi, kreativitas, dan produktivitas kita semua, terutama para penggiat IT dan pelaku usaha!", + "start_date": "2025-05-15T13:00:00+07:00", + "end_date": "2025-05-15T17:00:00+07:00", + "thumbnail": "https://scontent.fsoc1-1.fna.fbcdn.net/v/t39.30808-6/495382435_2144684952621110_8304851620615091109_n.jpg?_nc_cat=104&ccb=1-7&_nc_sid=833d8c&_nc_eui2=AeHwnU4KixfUsslo3M90ahT8GwMEI7zWSnQbAwQjvNZKdP3RHiUjdDZtuF1dIMvorL0nW0rDGOD6tPDi0i9ERK96&_nc_ohc=R1NyXnu-FKEQ7kNvwGbO-iN&_nc_oc=Adluf-uw1tmzkNME9vNhwkTv5-x_GHMfsLj1bzGyYds_ODq3UnWMq2DthyUisF9e3gU&_nc_zt=23&_nc_ht=scontent.fsoc1-1.fna&_nc_gid=kykMk9nPtcrtk-InL6tF6w&oh=00_AfLZSrEPpV83lVFRMz19n9RzfnsM4o7K7TijpdAjWYViiA&oe=6835231B", + "type": "onsite", + "price": 0, + "location": "Telkom Landmark Tower (Lt.31)", + "detail_link": "s.id/jvm65" + } +] diff --git a/apps/landing/src/data/features.json b/apps/landing/src/data/features.json deleted file mode 100644 index 5163089..0000000 --- a/apps/landing/src/data/features.json +++ /dev/null @@ -1,22 +0,0 @@ -[ - { - "iconName": "tabler:device-laptop", - "title": "Belajar Tanpa Koding", - "description": "Pelajari konsep programming dengan cara yang mudah dipahami tanpa harus menulis kode yang rumit." - }, - { - "iconName": "tabler:users", - "title": "Komunitas Supportif", - "description": "Bergabunglah dengan komunitas programmer Indonesia yang siap membantu dan berbagi pengalaman." - }, - { - "iconName": "tabler:book", - "title": "Tutorial Interaktif", - "description": "Akses tutorial interaktif yang membuat konsep programming lebih mudah untuk dipahami." - }, - { - "iconName": "tabler:code", - "title": "Proyek Praktis", - "description": "Terapkan pengetahuan Anda dalam proyek nyata dengan panduan langkah demi langkah." - } -] diff --git a/apps/landing/src/data/hero-content.json b/apps/landing/src/data/hero-content.json new file mode 100644 index 0000000..82d86cd --- /dev/null +++ b/apps/landing/src/data/hero-content.json @@ -0,0 +1,12 @@ +{ + "communityLabel": "Komunitas Programmer Indonesia", + "headingLine1": "Programmer Handal", + "headingLine2": "Enggan Ngoding", + "description": "Komunitas programmer terbesar di Indonesia, tempat berbagi meme, tutorial, pengalaman dan yapping", + "buttons": { + "join": "Join Komunitas", + "joinUrl": "#community", + "explore": "Explore Event", + "exploreUrl": "/events" + } +} diff --git a/apps/landing/src/data/hero-stats.json b/apps/landing/src/data/hero-stats.json index 2e98bf5..70232a3 100644 --- a/apps/landing/src/data/hero-stats.json +++ b/apps/landing/src/data/hero-stats.json @@ -1,5 +1,5 @@ [ { "value": "180K+", "label": "Member" }, - { "value": "500+", "label": "Tutorial" }, + { "value": "500+", "label": "Meme Harian" }, { "value": "24/7", "label": "Yapping" } ] diff --git a/apps/landing/src/data/learning-resources.json b/apps/landing/src/data/learning-resources.json deleted file mode 100644 index ec8dd98..0000000 --- a/apps/landing/src/data/learning-resources.json +++ /dev/null @@ -1,30 +0,0 @@ -[ - { - "icon": "tabler:video", - "title": "Video Tutorial", - "description": "Belajar melalui tutorial video dari langkah awal hingga mahir.", - "buttonText": "Lihat Semua Video", - "buttonLink": "#" - }, - { - "icon": "tabler:article", - "title": "Artikel & Tutorial", - "description": "Pelajari konsep programming melalui artikel yang disusun secara terstruktur.", - "buttonText": "Baca Artikel", - "buttonLink": "#" - }, - { - "icon": "tabler:brand-vscode", - "title": "Tantangan Koding", - "description": "Uji kemampuan koding kamu dengan tantangan yang menyenangkan dan menantang.", - "buttonText": "Mulai Tantangan", - "buttonLink": "#" - }, - { - "icon": "tabler:device-desktop-share", - "title": "Sharing Session", - "description": "Ikuti sesi berbagi pengalaman dari programmer berpengalaman dan belajar dari pengalaman mereka.", - "buttonText": "Jadwal Session", - "buttonLink": "#" - } -] diff --git a/apps/landing/src/data/navigations.json b/apps/landing/src/data/navigations.json new file mode 100644 index 0000000..2db1c6d --- /dev/null +++ b/apps/landing/src/data/navigations.json @@ -0,0 +1,22 @@ +[ + { + "title": "Home", + "link": "/" + }, + { + "title": "Event", + "link": "/events" + }, + { + "title": "Testimoni", + "link": "/testimonials" + }, + { + "title": "Roadmap", + "link": "/roadmaps" + }, + { + "title": "Artikel", + "link": "/articles" + } +] diff --git a/apps/landing/src/data/socials.json b/apps/landing/src/data/socials.json new file mode 100644 index 0000000..448b2b6 --- /dev/null +++ b/apps/landing/src/data/socials.json @@ -0,0 +1,37 @@ +[ + { + "name": "Facebook", + "icon": "FaFacebook", + "color": "#1877F2", + "description": "Join discussions and share knowledge", + "link": "#" + }, + { + "name": "Discord", + "icon": "FaDiscord", + "color": "#5865F2", + "description": "Real-time collaboration chat", + "link": "#" + }, + { + "name": "Instagram", + "icon": "FaInstagram", + "color": "#E4405F", + "description": "Daily coding tips & showcases", + "link": "#" + }, + { + "name": "TikTok", + "icon": "FaTiktok", + "color": "#000000", + "description": "Short coding tutorials", + "link": "#" + }, + { + "name": "LinkedIn", + "icon": "FaLinkedin", + "color": "#0A66C2", + "description": "Professional networking", + "link": "#" + } +] diff --git a/apps/landing/src/data/testimonial-stats.json b/apps/landing/src/data/testimonial-stats.json deleted file mode 100644 index 0e77dc9..0000000 --- a/apps/landing/src/data/testimonial-stats.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - { "value": "98%", "label": "Tingkat Kemalasan" }, - { "value": "4.9/5", "label": "Rating Drama" }, - { "value": "85%", "label": "Mendapat Pekerjaan" }, - { "value": "24/7", "label": "Yapping" } -] diff --git a/apps/landing/src/data/testimonials.json b/apps/landing/src/data/testimonials.json index 2c8ef6b..a587c16 100644 --- a/apps/landing/src/data/testimonials.json +++ b/apps/landing/src/data/testimonials.json @@ -1,20 +1,44 @@ [ { - "quote": "Sebagai seseorang yang awalnya buta sama sekali tentang programming, kini saya bisa membuat website sendiri dengan percaya diri. IMPHNEN benar-benar jadi pintu gerbang saya ke dunia web development!", - "name": "Ega", - "role": "Web Developer", - "avatar": "https://avatars.githubusercontent.com/u/97678571?v=4" + "id": 1, + "name": "Andi Pratama", + "role": "Backend Developer", + "text": "Komunitas ini sangat membantu perkembangan karir saya. Saya bisa belajar teknologi terbaru dan berkolaborasi dengan developer lain.", + "image": "https://picsum.photos/100/100?random=1" }, { - "quote": "IMPHNEN bukan cuma komunitas, tapi juga jadi tempat saya berkembang sebagai backend engineer. Diskusi teknisnya berbobot, dan respon dari komunitas selalu cepat dan tepat sasaran.", - "name": "Maulana", - "role": "Backend Engineer", - "avatar": "https://avatars.githubusercontent.com/u/53475078?v=4" + "id": 2, + "name": "Sarah Wijaya", + "role": "Frontend Engineer", + "text": "Acara sharing session-nya sangat inspiratif. Saya jadi termotivasi untuk terus mengembangkan skill di bidang frontend development.", + "image": "https://picsum.photos/100/100?random=2" }, { - "quote": "Sebagai fullstack engineer pemula, saya merasa sangat terbantu dengan komunitas Discord IMPHNEN. Materinya praktikal, pembahasannya jelas, dan member-nya selalu siap membantu.", - "name": "Rasyid", - "role": "Fullstack Engineer", - "avatar": "https://avatars.githubusercontent.com/u/49753444?v=4" + "id": 3, + "name": "Rizal Fauzi", + "role": "Fullstack Developer", + "text": "Bergabung di komunitas ini membuka banyak kesempatan networking dan project menarik. Sangat recommended untuk developer semua level!", + "image": "https://picsum.photos/100/100?random=5" + }, + { + "id": 4, + "name": "Dewi Lestari", + "role": "Mobile Developer", + "text": "Materi workshop-nya praktis dan langsung applicable. Mentor-mentornya juga berpengalaman di industri.", + "image": "https://picsum.photos/100/100?random=9" + }, + { + "id": 5, + "name": "Fajar Setiawan", + "role": "DevOps Engineer", + "text": "Komunitas yang solid dan saling support. Tidak pernah ragu untuk bertanya karena semua anggota sangat responsif.", + "image": "https://picsum.photos/100/100?random=10" + }, + { + "id": 6, + "name": "Budi Santoso", + "role": "UI/UX Designer", + "text": "Kolaborasi antara designer dan developer di komunitas ini sangat smooth. Banyak belajar best practices untuk workflow yang lebih baik.", + "image": "https://picsum.photos/100/100?random=6" } ] diff --git a/apps/landing/src/lib/cookies.ts b/apps/landing/src/lib/cookies.ts new file mode 100644 index 0000000..c6ddfea --- /dev/null +++ b/apps/landing/src/lib/cookies.ts @@ -0,0 +1,24 @@ +import { cookies } from 'next/headers'; + +const REFRESH_TOKEN_NAME = '__imphnen_refresh_token__'; +const ACCESS_TOKEN_NAME = '__imphnen_access_token__'; + +export async function setRefreshToken(refreshToken: string) { + const cookieStore = await cookies(); + cookieStore.set(REFRESH_TOKEN_NAME, refreshToken); +} + +export async function setAccessToken(accessToken: string) { + const cookieStore = await cookies(); + cookieStore.set(ACCESS_TOKEN_NAME, accessToken); +} + +export async function getRefreshToken() { + const cookieStore = await cookies(); + return cookieStore.get(REFRESH_TOKEN_NAME)?.value; +} + +export async function getAccessToken() { + const cookieStore = await cookies(); + return cookieStore.get(ACCESS_TOKEN_NAME)?.value; +} diff --git a/apps/landing/src/lib/fetcher.ts b/apps/landing/src/lib/fetcher.ts new file mode 100644 index 0000000..41264c1 --- /dev/null +++ b/apps/landing/src/lib/fetcher.ts @@ -0,0 +1,6 @@ +import type { paths } from '@/openapi-types'; +import createClient from 'openapi-fetch'; + +export const fetcher = createClient({ + baseUrl: process.env.NEXT_PUBLIC_API_URL, +}); diff --git a/apps/landing/src/lib/fonts.ts b/apps/landing/src/lib/fonts.ts new file mode 100644 index 0000000..f6296c1 --- /dev/null +++ b/apps/landing/src/lib/fonts.ts @@ -0,0 +1,13 @@ +import { Bai_Jamjuree, Poppins } from 'next/font/google'; + +export const baiJamjureeFont = Bai_Jamjuree({ + subsets: ['latin'], + weight: ['300', '400', '500', '600', '700'], + display: 'swap', +}); + +export const poppinsFont = Poppins({ + subsets: ['latin'], + weight: ['300', '400', '500', '600', '700'], + display: 'swap', +}); diff --git a/apps/landing/src/lib/headers.ts b/apps/landing/src/lib/headers.ts new file mode 100644 index 0000000..fdf0bdb --- /dev/null +++ b/apps/landing/src/lib/headers.ts @@ -0,0 +1,11 @@ +import { headers } from 'next/headers'; + +export async function getRemoteIp() { + const hdrs = await headers(); + const xff = hdrs.get('x-forwarded-for'); + if (!xff) return undefined; + + // 'x-forwarded-for' can be a comma-separated list of IPs + const ips = xff.split(',').map((ip) => ip.trim()); + return ips[0] || undefined; +} diff --git a/apps/landing/src/lib/rpc.ts b/apps/landing/src/lib/rpc.ts new file mode 100644 index 0000000..96f1b19 --- /dev/null +++ b/apps/landing/src/lib/rpc.ts @@ -0,0 +1,8 @@ +import type { paths } from '@/openapi-types'; +import createFetchClient from 'openapi-fetch'; +import createClient from 'openapi-react-query'; + +const fetchClient = createFetchClient({ + baseUrl: process.env.NEXT_PUBLIC_API_URL, +}); +export const rpc = createClient(fetchClient); diff --git a/apps/landing/src/openapi-types.ts b/apps/landing/src/openapi-types.ts new file mode 100644 index 0000000..8b0ed89 --- /dev/null +++ b/apps/landing/src/openapi-types.ts @@ -0,0 +1,2109 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/v1/auth/forgot": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_forgot_password"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_login"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/auth/new-password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_new_password"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/auth/refresh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_refresh_token"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/auth/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_register"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/auth/send-otp": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_resend_otp"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/auth/verify-email": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_verify_email"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/cms/landing/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_event_list"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/cms/landing/events/create": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_create_event"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/cms/landing/events/delete/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["delete_event"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/cms/landing/events/detail/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_event_by_id"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/cms/landing/events/update/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["patch_update_event"]; + trace?: never; + }; + "/v1/gacha/claims/create": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_create_gacha_claim"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/gacha/claims/detail/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_detail_gacha_claim"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/gacha/items": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_gacha_item_list"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/gacha/items/create": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_create_gacha_item"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/gacha/items/delete/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["delete_gacha_item"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/gacha/items/detail/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_gacha_item_by_id"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/gacha/items/update/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_update_gacha_item"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/gacha/rolls/create": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_create_gacha_roll"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/gacha/rolls/detail/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_detail_gacha_roll"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/gacha/rolls/execute": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_execute_gacha_roll"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/permissions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_permission_list"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/permissions/create": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_create_permission"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/permissions/delete/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["delete_permission"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/permissions/detail/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_permission_by_id"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/permissions/update/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_update_permission"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/roles": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_role_list"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/roles/create": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_create_role"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/roles/delete/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["delete_role"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/roles/detail/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_role_by_id"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/roles/update/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_update_role"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_user_list"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/users/activate/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["patch_user_active_status"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/users/create": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["post_create_user"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/users/delete/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["delete_user"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/users/detail/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_user_by_id"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/users/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_user_me"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/users/update/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_update_user_me"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/users/update/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["put_update_user"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + AuthLoginRequestDto: { + email: string; + password: string; + }; + AuthLoginResponsetDto: { + token: components["schemas"]["TokenDto"]; + user: components["schemas"]["UsersDetailItemDto"]; + }; + AuthNewPasswordRequestDto: { + password: string; + token: string; + }; + AuthRefreshTokenRequestDto: { + refresh_token: string; + }; + AuthRegisterRequestDto: { + email: string; + fullname: string; + password: string; + phone_number: string; + }; + AuthResendOtpRequestDto: { + email: string; + }; + AuthVerifyEmailRequestDto: { + email: string; + /** Format: int32 */ + otp: number; + }; + EventsCreateRequestDto: { + description: string; + detail_link: string; + /** @example 2025-09-20T13:00:00Z */ + end_date: string; + is_online: boolean; + location?: string | null; + name: string; + /** Format: double */ + price: number; + /** @example 2025-09-20T13:00:00Z */ + start_date: string; + }; + EventsDetailItemDto: { + created_at: string; + description: string; + detail_link: string; + end_date: string; + id: string; + is_online: boolean; + location?: string | null; + name: string; + /** Format: double */ + price: number; + start_date: string; + updated_at: string; + }; + EventsListItemDto: { + created_at: string; + description: string; + detail_link: string; + end_date: string; + id: string; + is_deleted: boolean; + is_online: boolean; + location?: string | null; + name: string; + /** Format: double */ + price: number; + start_date: string; + }; + EventsUpdateRequestDto: { + description: string; + detail_link: string; + /** @example 2025-09-20T13:00:00Z */ + end_date: string; + is_online: boolean; + location?: string | null; + name: string; + /** Format: double */ + price: number; + /** @example 2025-09-20T13:00:00Z */ + start_date: string; + }; + GachaClaimItemDto: { + created_at?: string | null; + id: string; + is_deleted: boolean; + item: components["schemas"]["GachaItemDto"]; + updated_at?: string | null; + user: components["schemas"]["UsersDetailItemDto"]; + }; + GachaClaimRequestDto: { + item_id: string; + user_id: string; + }; + GachaItemDto: { + created_at?: string | null; + id: string; + is_deleted: boolean; + name: string; + updated_at?: string | null; + }; + GachaItemRequestDto: { + image_url: string; + name: string; + }; + GachaRollItemDto: { + created_at?: string | null; + id: string; + is_deleted: boolean; + item: components["schemas"]["GachaItemDto"]; + /** Format: int32 */ + quantity: number; + updated_at?: string | null; + /** Format: float */ + weight: number; + }; + GachaRollRequestDto: { + item_id: string; + /** Format: int32 */ + quantity: number; + /** Format: float */ + weight: number; + }; + MessageResponseDto: { + message: string; + version: string; + }; + MetaRequestDto: { + filter?: string | null; + filter_by?: string | null; + order?: string | null; + /** Format: int64 */ + page?: number | null; + /** Format: int64 */ + per_page?: number | null; + search?: string | null; + sort_by?: string | null; + }; + MetaResponseDto: { + /** Format: int64 */ + page?: number | null; + /** Format: int64 */ + per_page?: number | null; + /** Format: int64 */ + total?: number | null; + }; + PermissionsItemDto: { + created_at?: string | null; + id: string; + name: string; + updated_at?: string | null; + }; + PermissionsRequestDto: { + name: string; + }; + ResponseListSuccessDto_Vec_EventsListItemDto: { + data: { + created_at: string; + description: string; + detail_link: string; + end_date: string; + id: string; + is_deleted: boolean; + is_online: boolean; + location?: string | null; + name: string; + /** Format: double */ + price: number; + start_date: string; + }[]; + meta?: null | components["schemas"]["MetaResponseDto"]; + }; + ResponseListSuccessDto_Vec_GachaItemDto: { + data: { + created_at?: string | null; + id: string; + is_deleted: boolean; + name: string; + updated_at?: string | null; + }[]; + meta?: null | components["schemas"]["MetaResponseDto"]; + }; + ResponseListSuccessDto_Vec_PermissionsItemDto: { + data: { + created_at?: string | null; + id: string; + name: string; + updated_at?: string | null; + }[]; + meta?: null | components["schemas"]["MetaResponseDto"]; + }; + ResponseListSuccessDto_Vec_RolesListItemDto: { + data: { + created_at?: string | null; + id: string; + name: string; + permissions_count: number; + updated_at?: string | null; + }[]; + meta?: null | components["schemas"]["MetaResponseDto"]; + }; + ResponseListSuccessDto_Vec_UsersListItemDto: { + data: { + avatar?: string | null; + created_at: string; + email: string; + fullname: string; + id: string; + is_active: boolean; + phone_number: string; + role: string; + updated_at: string; + }[]; + meta?: null | components["schemas"]["MetaResponseDto"]; + }; + ResponseSuccessDto_AuthLoginResponsetDto: { + data: { + token: components["schemas"]["TokenDto"]; + user: components["schemas"]["UsersDetailItemDto"]; + }; + }; + ResponseSuccessDto_EventsDetailItemDto: { + data: { + created_at: string; + description: string; + detail_link: string; + end_date: string; + id: string; + is_online: boolean; + location?: string | null; + name: string; + /** Format: double */ + price: number; + start_date: string; + updated_at: string; + }; + }; + ResponseSuccessDto_GachaClaimItemDto: { + data: { + created_at?: string | null; + id: string; + is_deleted: boolean; + item: components["schemas"]["GachaItemDto"]; + updated_at?: string | null; + user: components["schemas"]["UsersDetailItemDto"]; + }; + }; + ResponseSuccessDto_GachaItemDto: { + data: { + created_at?: string | null; + id: string; + is_deleted: boolean; + name: string; + updated_at?: string | null; + }; + }; + ResponseSuccessDto_GachaRollItemDto: { + data: { + created_at?: string | null; + id: string; + is_deleted: boolean; + item: components["schemas"]["GachaItemDto"]; + /** Format: int32 */ + quantity: number; + updated_at?: string | null; + /** Format: float */ + weight: number; + }; + }; + ResponseSuccessDto_PermissionsItemDto: { + data: { + created_at?: string | null; + id: string; + name: string; + updated_at?: string | null; + }; + }; + ResponseSuccessDto_RolesDetailItemDto: { + data: { + created_at?: string | null; + id: string; + is_deleted: boolean; + name: string; + permissions: components["schemas"]["PermissionsItemDto"][]; + updated_at?: string | null; + }; + }; + ResponseSuccessDto_TokenDto: { + data: { + access_token: string; + refresh_token: string; + }; + }; + ResponseSuccessDto_UsersDetailItemDto: { + data: { + avatar?: string | null; + birthdate?: string | null; + created_at: string; + email: string; + fullname: string; + gender?: string | null; + id: string; + is_active: boolean; + phone_number: string; + role: components["schemas"]["RolesDetailItemDto"]; + updated_at: string; + }; + }; + RolesDetailItemDto: { + created_at?: string | null; + id: string; + is_deleted: boolean; + name: string; + permissions: components["schemas"]["PermissionsItemDto"][]; + updated_at?: string | null; + }; + RolesListItemDto: { + created_at?: string | null; + id: string; + name: string; + permissions_count: number; + updated_at?: string | null; + }; + RolesRequestCreateDto: { + name: string; + permissions: string[]; + }; + RolesRequestUpdateDto: { + name?: string | null; + overwrite?: boolean | null; + permissions?: string[] | null; + }; + TokenDto: { + access_token: string; + refresh_token: string; + }; + UsersActiveInactiveRequestDto: { + is_active: boolean; + }; + UsersCreateRequestDto: { + email: string; + fullname: string; + is_active: boolean; + password: string; + phone_number: string; + role_id: string; + }; + UsersDetailItemDto: { + avatar?: string | null; + birthdate?: string | null; + created_at: string; + email: string; + fullname: string; + gender?: string | null; + id: string; + is_active: boolean; + phone_number: string; + role: components["schemas"]["RolesDetailItemDto"]; + updated_at: string; + }; + UsersListItemDto: { + avatar?: string | null; + created_at: string; + email: string; + fullname: string; + id: string; + is_active: boolean; + phone_number: string; + role: string; + updated_at: string; + }; + UsersUpdateRequestDto: { + avatar?: string | null; + birthdate?: string | null; + email: string; + fullname: string; + gender?: string | null; + is_active: boolean; + phone_number: string; + role_id: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + post_forgot_password: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AuthResendOtpRequestDto"]; + }; + }; + responses: { + /** @description Forgot password request successful */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + /** @description Forgot password request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + post_login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AuthLoginRequestDto"]; + }; + }; + responses: { + /** @description Login successful */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResponseSuccessDto_AuthLoginResponsetDto"]; + }; + }; + /** @description Login failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + post_new_password: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AuthNewPasswordRequestDto"]; + }; + }; + responses: { + /** @description New password request successful */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + /** @description New password request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + post_refresh_token: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AuthRefreshTokenRequestDto"]; + }; + }; + responses: { + /** @description Refresh token request successful */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + /** @description Refresh token request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + post_register: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AuthRegisterRequestDto"]; + }; + }; + responses: { + /** @description Register successful */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + /** @description Register failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + post_resend_otp: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AuthResendOtpRequestDto"]; + }; + }; + responses: { + /** @description Resend otp successful */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + /** @description Resend otp failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + post_verify_email: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AuthVerifyEmailRequestDto"]; + }; + }; + responses: { + /** @description Verify email successful */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + /** @description Verify email failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + get_event_list: { + parameters: { + query?: { + /** @description Page number */ + page?: number; + /** @description Items per page */ + per_page?: number; + /** @description Search keyword */ + search?: string; + /** @description Sort by field */ + sort_by?: string; + /** @description Order ASC or DESC */ + order?: string; + /** @description Filter value */ + filter?: string; + /** @description Field to filter by */ + filter_by?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Get event list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResponseListSuccessDto_Vec_EventsListItemDto"]; + }; + }; + }; + }; + post_create_event: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["EventsCreateRequestDto"]; + }; + }; + responses: { + /** @description Create new event */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + delete_event: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Event ID */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Soft delete event */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + get_event_by_id: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Event ID */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Get event by ID */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResponseSuccessDto_EventsDetailItemDto"]; + }; + }; + }; + }; + patch_update_event: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Event ID */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["EventsUpdateRequestDto"]; + }; + }; + responses: { + /** @description Update event */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + post_create_gacha_claim: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GachaClaimRequestDto"]; + }; + }; + responses: { + /** @description Create new gacha claim */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + get_detail_gacha_claim: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Gacha Claim ID */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Get Gacha Claim by ID */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResponseSuccessDto_GachaClaimItemDto"]; + }; + }; + }; + }; + get_gacha_item_list: { + parameters: { + query?: { + /** @description Page number */ + page?: number; + /** @description Items per page */ + per_page?: number; + /** @description Search keyword */ + search?: string; + /** @description Sort by field */ + sort_by?: string; + /** @description Order ASC or DESC */ + order?: string; + /** @description Filter value */ + filter?: string; + /** @description Field to filter by */ + filter_by?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Get gacha item list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResponseListSuccessDto_Vec_GachaItemDto"]; + }; + }; + }; + }; + post_create_gacha_item: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GachaItemRequestDto"]; + }; + }; + responses: { + /** @description Create gacha item */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + delete_gacha_item: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Delete gacha item */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + get_gacha_item_by_id: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Gacha Item ID */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Get gacha item by ID */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResponseSuccessDto_GachaItemDto"]; + }; + }; + }; + }; + put_update_gacha_item: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GachaItemRequestDto"]; + }; + }; + responses: { + /** @description Update gacha item */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + post_create_gacha_roll: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GachaRollRequestDto"]; + }; + }; + responses: { + /** @description Create new gacha roll */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + get_detail_gacha_roll: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Gacha Roll ID */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Get Gacha Roll by ID */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResponseSuccessDto_GachaRollItemDto"]; + }; + }; + }; + }; + post_execute_gacha_roll: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Execute and get 1 gacha result */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResponseSuccessDto_GachaRollItemDto"]; + }; + }; + }; + }; + get_permission_list: { + parameters: { + query?: { + /** @description Page number */ + page?: number; + /** @description Items per page */ + per_page?: number; + /** @description Search keyword */ + search?: string; + /** @description Sort by field */ + sort_by?: string; + /** @description Order ASC or DESC */ + order?: string; + /** @description Filter value */ + filter?: string; + /** @description Field to filter by */ + filter_by?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Get permission list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResponseListSuccessDto_Vec_PermissionsItemDto"]; + }; + }; + }; + }; + post_create_permission: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PermissionsRequestDto"]; + }; + }; + responses: { + /** @description Create new permission */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + delete_permission: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Delete permission */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + get_permission_by_id: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Permission ID */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Get permission by ID */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResponseSuccessDto_PermissionsItemDto"]; + }; + }; + }; + }; + put_update_permission: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PermissionsRequestDto"]; + }; + }; + responses: { + /** @description Update permission */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + get_role_list: { + parameters: { + query?: { + /** @description Page number */ + page?: number; + /** @description Items per page */ + per_page?: number; + /** @description Search keyword */ + search?: string; + /** @description Sort by field */ + sort_by?: string; + /** @description Order ASC or DESC */ + order?: string; + /** @description Filter value */ + filter?: string; + /** @description Field to filter by */ + filter_by?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Get role list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResponseListSuccessDto_Vec_RolesListItemDto"]; + }; + }; + }; + }; + post_create_role: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RolesRequestCreateDto"]; + }; + }; + responses: { + /** @description Create new role */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + delete_role: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Delete role */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + get_role_by_id: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Role ID */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Get role by ID */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResponseSuccessDto_RolesDetailItemDto"]; + }; + }; + }; + }; + put_update_role: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RolesRequestUpdateDto"]; + }; + }; + responses: { + /** @description Update role */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + get_user_list: { + parameters: { + query?: { + /** @description Page number */ + page?: number; + /** @description Items per page */ + per_page?: number; + /** @description Search keyword */ + search?: string; + /** @description Sort by field */ + sort_by?: string; + /** @description Order ASC or DESC */ + order?: string; + /** @description Filter value */ + filter?: string; + /** @description Field to filter by */ + filter_by?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Get user list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResponseListSuccessDto_Vec_UsersListItemDto"]; + }; + }; + }; + }; + patch_user_active_status: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UsersActiveInactiveRequestDto"]; + }; + }; + responses: { + /** @description Set user active/inactive */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + post_create_user: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UsersCreateRequestDto"]; + }; + }; + responses: { + /** @description Create new user */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + delete_user: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Soft delete user */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + get_user_by_id: { + parameters: { + query?: never; + header?: never; + path: { + /** @description User ID */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Get user by ID */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResponseSuccessDto_UsersDetailItemDto"]; + }; + }; + }; + }; + get_user_me: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Get user by ID */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResponseSuccessDto_UsersDetailItemDto"]; + }; + }; + }; + }; + put_update_user_me: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UsersUpdateRequestDto"]; + }; + }; + responses: { + /** @description Update user me */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; + put_update_user: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UsersUpdateRequestDto"]; + }; + }; + responses: { + /** @description Update user */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponseDto"]; + }; + }; + }; + }; +} diff --git a/libs/shadcn-ui/package-lock.json b/libs/shadcn-ui/package-lock.json index 652dc97..50115cc 100644 --- a/libs/shadcn-ui/package-lock.json +++ b/libs/shadcn-ui/package-lock.json @@ -8,14 +8,18 @@ "name": "@imphnen-frontend-service/shadcn-ui", "version": "0.0.1", "dependencies": { - "@radix-ui/react-slot": "^1.2.0", + "@hookform/resolvers": "^5.0.1", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-slot": "^1.2.3", "@tailwindcss/vite": "^4.1.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.503.0", + "react-hook-form": "^7.56.4", "react-icons": "^5.5.0", "tailwind-merge": "^3.2.0", - "tailwindcss": "^4.1.4" + "tailwindcss": "^4.1.4", + "zod": "^3.25.28" }, "devDependencies": { "tw-animate-css": "^1.2.8" @@ -446,6 +450,18 @@ "node": ">=18" } }, + "node_modules/@hookform/resolvers": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.0.1.tgz", + "integrity": "sha512-u/+Jp83luQNx9AdyW2fIPGY6Y7NG68eN2ZW8FOJYL+M0i4s49+refdJdOp/A9n9HFQtQs3HIDHQvX3ZET2o7YA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -461,10 +477,56 @@ } } }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", - "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -759,6 +821,12 @@ ], "peer": true }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.4.tgz", @@ -1464,6 +1532,35 @@ "node": ">=0.10.0" } }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-hook-form": { + "version": "7.56.4", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.4.tgz", + "integrity": "sha512-Rob7Ftz2vyZ/ZGsQZPaRdIefkgOSrQSPXfqBdvOPwJfoGnjwRJUs7EM7Kc1mcoDv3NOtqBzPGbcMB8CGn9CKgw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-icons": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", @@ -1513,6 +1610,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT", + "peer": true + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1649,6 +1753,15 @@ "optional": true } } + }, + "node_modules/zod": { + "version": "3.25.28", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.28.tgz", + "integrity": "sha512-/nt/67WYKnr5by3YS7LroZJbtcCBurDKKPBPWWzaxvVCGuG/NOsiKkrjoOhI8mJ+SQUXEbUzeB3S+6XDUEEj7Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/libs/shadcn-ui/package.json b/libs/shadcn-ui/package.json index 374ee3c..7616d4a 100644 --- a/libs/shadcn-ui/package.json +++ b/libs/shadcn-ui/package.json @@ -10,14 +10,18 @@ } }, "dependencies": { - "@radix-ui/react-slot": "^1.2.0", + "@hookform/resolvers": "^5.0.1", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-slot": "^1.2.3", "@tailwindcss/vite": "^4.1.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.503.0", + "react-hook-form": "^7.56.4", "react-icons": "^5.5.0", "tailwind-merge": "^3.2.0", - "tailwindcss": "^4.1.4" + "tailwindcss": "^4.1.4", + "zod": "^3.25.28" }, "devDependencies": { "tw-animate-css": "^1.2.8" diff --git a/libs/shadcn-ui/src/atoms/button.tsx b/libs/shadcn-ui/src/atoms/button.tsx index d8c89fc..6da2a3c 100644 --- a/libs/shadcn-ui/src/atoms/button.tsx +++ b/libs/shadcn-ui/src/atoms/button.tsx @@ -3,23 +3,21 @@ import { Slot } from '@radix-ui/react-slot'; import { cva, type VariantProps } from 'class-variance-authority'; import * as React from 'react'; -import { cn } from '../lib/cn'; +import { cn } from '../lib'; const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + 'inline-flex items-center justify-center font-[600] rounded-md px-[16px] py-[10px] transition-colors duration-200 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed', { variants: { variant: { - default: - 'bg-primary text-primary-foreground shadow hover:bg-primary/90', - destructive: - 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', - outline: - 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + primary: 'bg-primary-500 hover:bg-primary-600 text-white shadow-md', secondary: - 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', - ghost: 'hover:bg-accent hover:text-accent-foreground', - link: 'text-primary underline-offset-4 hover:underline', + 'bg-white hover:text-primary-600 hover:bg-gray-50 text-primary-500 shadow-md', + text: 'bg-transparent hover:text-primary-600 hover:bg-gray-50 text-primary-500', + bordered: + 'border border-primary-500 hover:border-primary-600 bg-transparent hover:text-primary-600 hover:bg-gray-50 text-primary-500', + success: 'bg-success-500 hover:bg-success-600 text-white shadow-md', + danger: 'bg-danger-100 hover:bg-danger-200 text-danger-500 shadow-md', }, size: { default: 'h-9 px-4 py-2', @@ -29,7 +27,7 @@ const buttonVariants = cva( }, }, defaultVariants: { - variant: 'default', + variant: 'primary', size: 'default', }, } diff --git a/libs/shadcn-ui/src/atoms/card.tsx b/libs/shadcn-ui/src/atoms/card.tsx new file mode 100644 index 0000000..78e70ea --- /dev/null +++ b/libs/shadcn-ui/src/atoms/card.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import { cn } from '../lib'; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +CardFooter.displayName = 'CardFooter'; + +export { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +}; diff --git a/libs/shadcn-ui/src/atoms/form.tsx b/libs/shadcn-ui/src/atoms/form.tsx new file mode 100644 index 0000000..cca75e1 --- /dev/null +++ b/libs/shadcn-ui/src/atoms/form.tsx @@ -0,0 +1,167 @@ +'use client'; + +import * as LabelPrimitive from '@radix-ui/react-label'; +import { Slot } from '@radix-ui/react-slot'; +import * as React from 'react'; +import { + Controller, + FormProvider, + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues, +} from 'react-hook-form'; +import { cn } from '../lib'; +import { Label } from './label'; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState } = useFormContext(); + const formState = useFormState({ name: fieldContext.name }); + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error('useFormField should be used within '); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue +); + +function FormItem({ className, ...props }: React.ComponentProps<'div'>) { + const id = React.useId(); + + return ( + +
    + + ); +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps) { + const { error, formItemId } = useFormField(); + + return ( +