Compare commits

..
113 changed files with 907 additions and 6215 deletions
+1 -2
View File
@@ -12,13 +12,12 @@ export const useLogin = () => {
mode: 'all',
});
const { signIn, isLoading } = useSession();
const { signIn } = useSession();
const onSubmit = form.handleSubmit((data) => signIn(data));
return {
form,
onSubmit,
isLoading
};
};
-24
View File
@@ -1,24 +0,0 @@
import { useForm } from 'react-hook-form';
import {
TVerifyOtpRequest,
verifyEmailSchema,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useOtp } from '@imphnen-frontend-service/utils';
export const useOtpHook = () => {
const form = useForm<TVerifyOtpRequest>({
resolver: zodResolver(verifyEmailSchema),
mode: 'all',
});
const { otp, isLoading } = useOtp();
const onSubmit = form.handleSubmit((data) => otp(data));
return {
form,
onSubmit,
isLoading
};
};
@@ -1,24 +0,0 @@
import { useForm } from 'react-hook-form';
import {
authRegisterSchema,
TRegisterRequest,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRegister } from '@imphnen-frontend-service/utils';
export const useRegisterHook = () => {
const form = useForm<TRegisterRequest>({
resolver: zodResolver(authRegisterSchema),
mode: 'all',
});
const { register, isLoading } = useRegister();
const onSubmit = form.handleSubmit((data) => register(data));
return {
form,
onSubmit,
isLoading
};
};
+2 -9
View File
@@ -5,7 +5,7 @@ import { useLogin } from '../../_hooks/use-login';
import { ArrowLeftOutlined } from '@ant-design/icons';
export const Components: FC = (): ReactElement => {
const { form, onSubmit, isLoading } = useLogin();
const { form, onSubmit } = useLogin();
return (
<div className="flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]">
@@ -42,7 +42,7 @@ export const Components: FC = (): ReactElement => {
Lupa Password ?
</a>
</div>
<Button className="w-full" type='submit' disabled={(!form.formState.isValid || isLoading)}>Enter Isekai</Button>
<Button className="w-full" type='submit'>Enter Isekai</Button>
</form>
<div className="flex my-3 gap-3 justify-center">
@@ -74,13 +74,6 @@ export const Components: FC = (): ReactElement => {
<ArrowLeftOutlined />
Kembali Ke Homepage
</Button>
<Button
className='xl:hidden w-full gap-3'
variant='secondary'
>
<ArrowLeftOutlined />
Kembali Ke Homepage
</Button>
</div>
</div>
</div>
@@ -1,12 +0,0 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
export const AppLayout: FC = (): ReactElement => {
return (
<main className="bg-primary-50 min-h-screen">
<Outlet />
</main>
);
};
export default AppLayout;
@@ -1,37 +0,0 @@
import { FC, ReactElement } from 'react';
import { ControlledInputField, RegisterResetBanner } from "@imphnen-frontend-service/ui/organisms";
import { useOtpHook } from '../../../_hooks/use-otp';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useSearchParams } from 'react-router-dom';
export const Components: FC = (): ReactElement => {
const { form, onSubmit, isLoading } = useOtpHook()
const [ searchParams ] = useSearchParams()
return (
<div className='flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]'>
<div className='bg-white min-w-[1120px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6'>
<RegisterResetBanner />
<div className='border-2 border-primary-500/50 w-[596px] rounded-lg py-[70px] px-[48px] flex flex-col justify-center'>
<h3 className='mt-5 text-3xl font-semibold text-primary-500'>Verifikasi Email</h3>
<h5 className='mt-2 text-xl font-medium text-primary-500'>Yeay~! Pesan dari dunia lain sudah dikirimkan ke {searchParams.get("email")}</h5>
<form onSubmit={onSubmit} className='mt-7'>
<ControlledInputField
label="OTP"
size="lg"
className="w-full"
placeholder="XXXXXX"
max={6}
name={'otp'}
maxLength={6}
control={form.control}
/>
<Button className="xl:w-full mt-2" disabled={(!form.formState.isValid || isLoading)}>Linked Start !!!</Button>
</form>
</div>
</div>
</div>
);
};
export default Components;
+77 -89
View File
@@ -1,108 +1,96 @@
import { FC, ReactElement } from 'react';
import { ControlledInputField, RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { useRegisterHook } from '../../_hooks/use-register';
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { InputField } from '@imphnen-frontend-service/ui/molecules';
export const Components: FC = (): ReactElement => {
const { form, onSubmit, isLoading } = useRegisterHook();
return (
<div className="flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]">
<div className="bg-white xl:min-w-[1130px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6">
<div className="bg-white min-w-[1120px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6">
<RegisterResetBanner />
<div className="xl:border-2 xl:border-primary-500/50 xl:w-[726px] rounded-lg py-[32px] px-[48px] flex justify-center">
<div className="xl:w-[714px]">
<Button
className='xl:hidden gap-3'
variant='secondary'
>
<ArrowLeftOutlined />
Login
</Button>
<div className="border-2 border-primary-500/50 w-[696px] rounded-lg py-[32px] px-[48px] flex justify-center">
<div className="w-[704px]">
<h3 className="mt-5 text-3xl font-semibold text-primary-500">
Register
</h3>
<h5 className="text-primary-500 font-medium">
Yosha~! Saatnya Bergabung dengan Dimentorin
</h5>
<form onSubmit={onSubmit}>
<div className='my-7'>
<ControlledInputField
label="Full name"
size="lg"
className="w-full"
placeholder="Nama Lengkap"
name={'fullname'}
control={form.control}
/>
</div>
<div className="grid grid-flow-row-dense my-7 lg:grid-cols-2 gap-2">
<ControlledInputField
label="Email"
size="lg"
className="w-full"
placeholder="Contoh : yourname@mail.com"
type='email'
control={form.control}
name={'email'}
/>
<ControlledInputField
label="No Hp"
size="lg"
className="w-full"
placeholder="Contoh : 088877665544"
control={form.control}
name={'phone_number'}
/>
</div>
<ControlledInputField
label="Password"
className="w-full"
<div className="grid grid-flow-row-dense my-7 grid-cols-2 gap-2">
<InputField
label="First Name"
size="lg"
type="password"
placeholder="Buat password sekeren jurus ultimate-mu!"
control={form.control}
name={'password'}
placeholder="Nama Depan"
/>
<h6 className="text-sm text-gray-500 mt-1">
"Senpai~! Pastikan password-mu sekuat pertahanan kastil!"
</h6>
<h6 className="text-sm text-gray-500 mt-2">
Tips membuat password yang OP:
</h6>
<h6 className="text-sm text-gray-500">
- Minimal 8 karakter (semakin panjang, semakin power-up!{' '}
<span role="img" aria-label="emoji">
</span>
)
</h6>
<h6 className="text-sm text-gray-500">
- Campur huruf besar, kecil, angka, dan simbol untuk kombinasi
ultimate!
<span role="img" aria-label="emoji">
🔥
</span>
</h6>
<h6 className="text-sm text-gray-500 mb-2">
- Jangan pakai password yang gampang ditebak, nanti ketahuan
musuh!
<span role="img" aria-label="emoji">
🚨
</span>
</h6>
<ControlledInputField
label="Repeat Password"
className="w-full"
<InputField
label="Last Name"
size="lg"
type="password"
placeholder="Ulangi password-mu, Senpai~!"
control={form.control}
name={"confirm_password"}
className="w-full"
placeholder="Nama Belakang"
/>
<Button className="xl:w-full mt-2" disabled={(!form.formState.isValid || isLoading)}>Linked Start !!!</Button>
</form>
<InputField
label="Email"
size="lg"
placeholder="Contoh : yourname@mail.com"
/>
<div className="flex gap-2">
<div className="flex flex-col gap-2">
<h4 className="font-medium">OTP Code</h4>
<Input
className="w-full"
size="lg"
placeholder="Kode OTP"
/>
</div>
<div className="flex flex-col">
<Button size="sm" className="mt-[30px]">
Kirim OTP
</Button>
</div>
</div>
</div>
<InputField
label="Password"
className="w-full"
size="lg"
type="password"
placeholder="Buat password sekeren jurus ultimate-mu!"
/>
<h6 className="text-sm text-gray-500 mt-1">
"Senpai~! Pastikan password-mu sekuat pertahanan kastil!"
</h6>
<h6 className="text-sm text-gray-500 mt-2">
Tips membuat password yang OP:
</h6>
<h6 className="text-sm text-gray-500">
- Minimal 8 karakter (semakin panjang, semakin power-up!{' '}
<span role="img" aria-label="emoji">
</span>
)
</h6>
<h6 className="text-sm text-gray-500">
- Campur huruf besar, kecil, angka, dan simbol untuk kombinasi
ultimate!
<span role="img" aria-label="emoji">
🔥
</span>
</h6>
<h6 className="text-sm text-gray-500 mb-2">
- Jangan pakai password yang gampang ditebak, nanti ketahuan
musuh!
<span role="img" aria-label="emoji">
🚨
</span>
</h6>
<InputField
label="Repeat Password"
className="w-full"
size="lg"
type="password"
placeholder="Ulangi password-mu, Senpai~!"
/>
<Button className="w-full mt-2">Linked Start !!!</Button>
</div>
</div>
</div>
@@ -1,21 +1,15 @@
import { FC, ReactElement, useEffect } from 'react';
import { FC, ReactElement } from 'react';
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { ArrowRightOutlined } from '@ant-design/icons';
export const Components: FC = (): ReactElement => {
useEffect(() => {
setTimeout(() => {
document.location.href = "/auth/login"
}, 10000)
}, [])
return (
<div className="flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]">
<div className="bg-white xl:min-w-[1220px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6">
<div className="bg-white min-w-[1220px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6">
<RegisterResetBanner />
<div className="border-2 border-primary-500/50 xl:w-[696px] rounded-lg py-[32px] px-[48px] flex justify-center bg-primary-50">
<div className="xl:w-[704px] flex flex-col justify-center items-center">
<div className="border-2 border-primary-500/50 w-[696px] rounded-lg py-[32px] px-[48px] flex justify-center bg-primary-50">
<div className="w-[704px] flex flex-col justify-center items-center">
<h2 className="text-4xl text-primary-500 font-semibold">
Register Successfull
</h2>
@@ -47,12 +41,10 @@ export const Components: FC = (): ReactElement => {
<h4 className="mt-10 text-primary-500 font-medium text-xl">
Kamu akan memasuki isekai dalam 10 dtk
</h4>
<a href="/auth/login">
<Button className="gap-3 mt-10" size="lg">
Masuk Isekai
<ArrowRightOutlined />
</Button>
</a>
<Button className="gap-3 mt-10" size="lg">
Masuk Isekai
<ArrowRightOutlined />
</Button>
</div>
</div>
</div>
-4
View File
@@ -9,10 +9,6 @@ const mappingPublicRoutes = [
'/',
'/auth/login',
'/auth/forgot',
'/auth/forgot/otp',
'/auth/register',
'/auth/register/otp',
'/auth/register/success',
'/auth/new-password',
];
@@ -2,7 +2,6 @@ import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { useLogin } from '../../_hooks/use-login';
import ModalFormVerifyEmail from './modal-form-verify-email';
interface IModalFormLogin {
isOpen: boolean;
@@ -17,87 +16,61 @@ const ModalFormLogin = ({
onForgotPassword,
setIsOpenRegisterModal,
}: IModalFormLogin) => {
const {
form,
onSubmit,
isLoading,
showVerifyModal,
verifyForm,
onVerifySubmit,
closeVerifyModal,
isVerifying,
emailToVerify,
} = useLogin();
const { form, onSubmit } = useLogin();
return (
<>
<Modal
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
isOpen={isOpen && !showVerifyModal}
onClose={onClose}
>
<Modal.Header className="space-y-4">
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
<h1 className="text-primary-500 text-p1 text-center font-semibold">
Login
</h1>
</Modal.Header>
<Modal.Content>
<form className="space-y-4" onSubmit={onSubmit}>
<ControlledInputField
control={form.control}
label="Email"
placeholder="Masukkan Email"
type="email"
name="email"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Password"
placeholder="Masukkan Password"
type="password"
name="password"
size="lg"
className="w-full"
/>
<h1 className="text-end text-primary-500 text-xl font-medium">
<Button variant="text" type="button" onClick={onForgotPassword}>
Lupa Password?
</Button>
</h1>
<Button
type="submit"
size="md"
className="w-full"
disabled={isLoading}
>
{isLoading ? 'Loading...' : 'Login'}
<Modal
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
isOpen={isOpen}
onClose={onClose}
>
<Modal.Header className="space-y-4">
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
<h1 className="text-primary-500 text-p1 text-center font-semibold">
Login
</h1>
</Modal.Header>
<Modal.Content>
<form className="space-y-4" onSubmit={onSubmit}>
<ControlledInputField
control={form.control}
label="Email"
placeholder="Masukkan Email"
type="email"
name="email"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Password"
placeholder="Masukkan Password"
type="password"
name="password"
size="lg"
className="w-full"
/>
<h1 className="text-end text-primary-500 text-xl font-medium">
<Button variant="text" onClick={onForgotPassword}>
Lupa Password?
</Button>
<div className="flex justify-center gap-2 pt-2.5 font-medium">
<p className="text-neutral-500">Belum punya akun?</p>
<Button
variant="text"
className="text-primary-500 hover:text-primary-600 m-0 p-0"
onClick={() => setIsOpenRegisterModal(true)}
>
Daftar
</Button>
</div>
</form>
</Modal.Content>
</Modal>
<ModalFormVerifyEmail
isOpen={showVerifyModal}
onClose={closeVerifyModal}
verifyForm={verifyForm}
onVerifySubmit={onVerifySubmit}
isVerifying={isVerifying}
email={emailToVerify}
/>
</>
</h1>
<Button type="submit" size="md" className="w-full">
Login
</Button>
<div className="flex justify-center gap-2 pt-2.5 font-medium">
<p className="text-neutral-500">Belum punya akun?</p>
<Button
variant="text"
className="text-primary-500 hover:text-primary-600 m-0 p-0"
onClick={() => setIsOpenRegisterModal(true)}
>
Daftar
</Button>
</div>
</form>
</Modal.Content>
</Modal>
);
};
@@ -1,8 +1,11 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { useQueryState } from '@imphnen-frontend-service/utils';
import { useRegister } from '../../_hooks/use-register';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import ModalFormVerifyEmail from './modal-form-verify-email';
import { UseFormReturn } from 'react-hook-form';
import { TRegisterRequest } from '@imphnen-frontend-service/service';
import { useEffect } from 'react';
interface IModalFormRegisterProps {
isOpen: boolean;
@@ -10,100 +13,168 @@ interface IModalFormRegisterProps {
}
const ModalFormRegister = ({ isOpen, onClose }: IModalFormRegisterProps) => {
const { form, onSubmit, isStepOneValid } = useRegister();
const {
form,
verifyForm,
onSubmit,
onVerifySubmit,
showVerifyModal,
closeVerifyModal,
isVerifying,
isRegistering,
registeredEmail,
} = useRegister();
step: currentStep,
nextStep,
prevStep,
resetStep,
} = useQueryState('registerStep', {
defaultValue: 1,
maxValue: 2,
minValue: 1,
});
useEffect(() => {
if (!isStepOneValid && currentStep === 2) {
prevStep();
}
}, [isStepOneValid, currentStep, prevStep]);
return (
<>
<Modal
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
isOpen={isOpen && !showVerifyModal}
onClose={onClose}
>
<Modal.Header className="space-y-4">
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
<h1 className="text-primary-500 text-p1 text-center font-semibold">
Register
</h1>
</Modal.Header>
<Modal.Content>
<form onSubmit={onSubmit} className="space-y-6">
<ControlledInputField
control={form.control}
name="fullname"
label="Nama Lengkap"
placeholder="Masukkan Nama Lengkap"
type="text"
size="lg"
className="w-full"
<Modal
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
isOpen={isOpen}
onClose={() => {
onClose();
resetStep();
}}
>
<Modal.Header className="space-y-4">
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
<h1 className="text-primary-500 text-p1 text-center font-semibold">
Register
</h1>
<h2 className="text-neutral-500 text-lg text-center">
{currentStep === 1 ? 'Info Akun' : 'Informasi Pengiriman Hadiah'}
</h2>
</Modal.Header>
<Modal.Content>
<form onSubmit={onSubmit} className="space-y-6">
{currentStep === 1 && (
<StepOne
form={form}
nextStep={nextStep}
isStepOneValid={isStepOneValid}
/>
<ControlledInputField
control={form.control}
label="Email"
placeholder="Masukkan Email Anda"
type="email"
name="email"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="password"
label="Password"
placeholder="Masukkan Password"
type="password"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="confirm_password"
label="Ulangi Password"
placeholder="Masukkan Ulang Password"
type="password"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="phone_number"
label="Nomor Telepon"
placeholder="Masukkan Nomor Telepon Aktif"
type="text"
size="lg"
className="w-full"
/>
<Button
size="md"
className="w-full"
type="submit"
disabled={isRegistering || !form.formState.isValid}
>
{isRegistering ? 'Mendaftar...' : 'Register'}
</Button>
</form>
</Modal.Content>
</Modal>
)}
{currentStep === 2 && <StepTwo form={form} prevStep={prevStep} />}
</form>
</Modal.Content>
</Modal>
);
};
<ModalFormVerifyEmail
isOpen={showVerifyModal}
onClose={closeVerifyModal}
verifyForm={verifyForm}
onVerifySubmit={onVerifySubmit}
isVerifying={isVerifying}
email={registeredEmail}
interface IStepOneProps {
form: UseFormReturn<TRegisterRequest, any, TRegisterRequest>;
nextStep: () => void;
isStepOneValid: boolean;
}
const StepOne = ({ form, nextStep, isStepOneValid }: IStepOneProps) => {
return (
<>
<ControlledInputField
control={form.control}
name="fullname"
label="Nama Lengkap"
placeholder="Masukkan Nama Lengkap"
type="text"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Email"
placeholder="Masukkan Email Anda"
type="email"
name="email"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="password"
label="Password"
placeholder="Masukkan Password"
type="password"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="confirm_password"
label="Ulangi Password"
placeholder="Masukkan Ulang Password"
type="password"
size="lg"
className="w-full"
/>
<Button
size="md"
className="w-full"
onClick={nextStep}
disabled={!isStepOneValid}
>
Selanjutnya
</Button>
</>
);
};
interface IStepTwoProps {
form: UseFormReturn<TRegisterRequest, any, TRegisterRequest>;
prevStep: () => void;
}
const StepTwo = ({ form, prevStep }: IStepTwoProps) => (
<>
<ControlledInputField
control={form.control}
name="phone_number"
label="Nomor Telepon"
placeholder="Masukkan Nomor Telepon Aktif"
type="text"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="referral_code"
label="Kode Referral"
placeholder="Masukkan Kode Referral"
type="text"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="referred_by"
label="Referral By"
placeholder="Masukkan Referral By"
type="text"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="student_type"
label="Tipe Pelajar"
placeholder="Masukkan Tipe Pelajar"
type="text"
size="lg"
className="w-full"
/>
<div className="flex gap-6">
<Button size="md" variant="text" className="w-[40%]" onClick={prevStep}>
Kembali
</Button>
<Button size="md" className="w-full" type="submit">
Gacha Sekarang
</Button>
</div>
</>
);
export default ModalFormRegister;
@@ -1,63 +0,0 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { UseFormReturn } from 'react-hook-form';
interface IModalFormVerifyEmailProps {
isOpen: boolean;
onClose: () => void;
verifyForm: UseFormReturn<{ otp: string }, any>;
onVerifySubmit: (e: React.FormEvent<HTMLFormElement>) => void;
isVerifying: boolean;
email?: string;
}
const ModalFormVerifyEmail = ({
isOpen,
onClose,
verifyForm,
onVerifySubmit,
isVerifying,
email,
}: IModalFormVerifyEmailProps) => {
return (
<Modal
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
isOpen={isOpen}
onClose={onClose}
>
<Modal.Header className="space-y-4">
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
<h1 className="text-primary-500 text-p1 text-center font-semibold">
Verifikasi Email
</h1>
<h2 className="text-neutral-500 text-lg text-center">
Masukkan kode OTP yang telah dikirim ke {email ? email : 'email Anda'}
</h2>
</Modal.Header>
<Modal.Content>
<form onSubmit={onVerifySubmit} className="space-y-6">
<ControlledInputField
control={verifyForm.control}
name="otp"
label="Kode OTP"
placeholder="Masukkan Kode OTP"
type="text"
size="lg"
className="w-full"
/>
<Button
size="md"
className="w-full"
type="submit"
disabled={isVerifying}
>
{isVerifying ? 'Memverifikasi...' : 'Verifikasi'}
</Button>
</form>
</Modal.Content>
</Modal>
);
};
export default ModalFormVerifyEmail;
+3 -58
View File
@@ -2,13 +2,9 @@ import { useForm } from 'react-hook-form';
import {
authLoginSchema,
TLoginRequest,
usePostLogin,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAuthStore } from '@imphnen-frontend-service/utils';
import { toast } from 'sonner';
import { useNavigate } from 'react-router';
import { useVerifyEmail } from './use-verify-email';
import { useSession } from '@imphnen-frontend-service/utils';
export const useLogin = () => {
const form = useForm<TLoginRequest>({
@@ -16,63 +12,12 @@ export const useLogin = () => {
mode: 'all',
});
const navigate = useNavigate();
const { mutate, isPending } = usePostLogin();
const { setLoading, setSession, clearSession } = useAuthStore();
const { signIn } = useSession();
const {
openVerifyModal,
showVerifyModal,
verifyForm,
onVerifySubmit,
closeVerifyModal,
isVerifying,
emailToVerify,
} = useVerifyEmail();
const onSubmit = form.handleSubmit((data) => {
setLoading(true);
mutate(data, {
onSuccess: (response) => {
toast.success('Login sukses');
if (response.data.token && response.data.user) {
setSession(response.data);
} else {
const { token, ...userData } = response.data;
const loginData = {
token,
userData,
};
setSession(loginData);
}
navigate(0);
},
onError: (error) => {
const errorMessage = error?.response?.data?.message;
if (errorMessage === 'Account not active, please verify your email') {
toast.error('Akun belum aktif, silakan verifikasi email Anda');
openVerifyModal(data.email);
} else {
toast.error(errorMessage ?? 'Terjadi Kesalahan yang tidak diketahui');
clearSession();
}
},
});
});
const onSubmit = form.handleSubmit((data) => signIn(data));
return {
form,
onSubmit,
isLoading: isPending,
showVerifyModal,
verifyForm,
onVerifySubmit,
closeVerifyModal,
isVerifying,
emailToVerify,
};
};
+2 -23
View File
@@ -6,20 +6,9 @@ import {
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { useVerifyEmail } from './use-verify-email';
export const useRegister = () => {
const postRegister = usePostRegister();
const {
verifyForm,
onVerifySubmit,
showVerifyModal,
openVerifyModal,
closeVerifyModal,
isVerifying,
emailToVerify,
} = useVerifyEmail();
const form = useForm<TRegisterRequest>({
resolver: zodResolver(authRegisterSchema),
mode: 'all',
@@ -34,23 +23,13 @@ export const useRegister = () => {
const onSubmit = form.handleSubmit((data) => {
postRegister.mutate(data, {
onSuccess: () => {
toast.success('Registrasi sukses. Silakan verifikasi email Anda.');
openVerifyModal(data.email);
},
onError: (error) => toast.error(error.message || 'Registrasi gagal'),
onSuccess: () => toast.success('Registrasi sukses'),
onError: (error) => toast.error(error.message),
});
});
return {
form,
verifyForm,
onSubmit,
onVerifySubmit,
showVerifyModal,
closeVerifyModal,
isVerifying,
isRegistering: postRegister.isPending,
registeredEmail: emailToVerify,
};
};
@@ -1,62 +0,0 @@
import { useForm } from 'react-hook-form';
import {
TVerifyEmailRequest,
usePostVerifyEmail,
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
export const useVerifyEmail = () => {
const [showVerifyModal, setShowVerifyModal] = useState(false);
const [emailToVerify, setEmailToVerify] = useState('');
const navigate = useNavigate();
const postVerifyEmail = usePostVerifyEmail();
const verifyForm = useForm<{ otp: string }>({
mode: 'all',
defaultValues: {
otp: '',
},
});
const onVerifySubmit = verifyForm.handleSubmit((data) => {
const otpNumber = data.otp;
const verifyData: TVerifyEmailRequest = {
email: emailToVerify,
otp: otpNumber,
};
postVerifyEmail.mutate(verifyData, {
onSuccess: () => {
toast.success('Verifikasi email sukses');
navigate(0);
},
onError: (error) =>
toast.error(error.message || 'Verifikasi email gagal'),
});
});
const openVerifyModal = (email: string) => {
setEmailToVerify(email);
setShowVerifyModal(true);
};
const closeVerifyModal = () => {
setShowVerifyModal(false);
verifyForm.reset();
};
return {
verifyForm,
onVerifySubmit,
showVerifyModal,
openVerifyModal,
closeVerifyModal,
isVerifying: postVerifyEmail.isPending,
emailToVerify,
};
};
+3 -5
View File
@@ -66,15 +66,11 @@ export const Components: FC = (): ReactElement => {
<div className="order-1 lg:order-none flex flex-col col-span-4 justify-center items-center mb-8 relative">
<div className="relative h-[150px] md:h-[230px]">
<img
width={400}
height={400}
src="/merch/1.png"
alt="Merch 1"
className="relative top-[3px] md:top-[6px] left-[4px] md:left-[7px] z-10 w-[165px] md:w-[266px]"
/>
<img
width={400}
height={400}
className="absolute top-0 left-0 min-w-[174px] md:min-w-[280px] z-0"
src="/merch/Vector-1.svg"
alt=""
@@ -92,7 +88,7 @@ export const Components: FC = (): ReactElement => {
~ 175k ~
</div>
</div>
<div className="order-2 flex flex-col col-span-4 justify-center items-center mb-8 relative">
<div className="order-2 flex flex-col col-span-4 justify-center items-center mb-8 relative overflow-hidden">
<div className="relative h-[130px] md:h-[230px]">
<img
width={400}
@@ -109,6 +105,8 @@ export const Components: FC = (): ReactElement => {
alt="Merch 3"
/>
<img
width={400}
height={400}
className="absolute hidden lg:block -bottom-[100px] -left-[55px]"
src="/landing-arrow-2.svg"
alt="Merch 1"
+1 -1
View File
@@ -35,7 +35,7 @@ if (!rootElement) throw new Error('Failed to find the root element');
createRoot(rootElement).render(
<StrictMode>
<QueryProvider>
<Toaster position="top-right"/>
<Toaster />
<RouterProvider router={router} />
</QueryProvider>
</StrictMode>
+103 -9
View File
@@ -1,15 +1,109 @@
// import { SessionToken, SessionUser } from '@imphnen-frontend-service/utils';
import { LoaderFunctionArgs } from 'react-router';
import {
PERMISSIONS,
SessionToken,
SessionUser,
} from '@imphnen-frontend-service/utils';
import { LoaderFunctionArgs, redirect } from 'react-router';
// const mappingPublicRoutes = ['/'];
const mappingPublicRoutes = [
'/',
'/auth/login',
'/auth/forgot',
'/auth/new-password',
];
// const mappingRoutePermissions = [
// {
// path: '/',
// permissions: [],
// },
// ];
const mappingRoutePermissions = [
{
path: '/dashboard',
permissions: [],
},
{
path: '/users',
permissions: [PERMISSIONS.USERS.READ_LIST],
},
{
path: '/users/create',
permissions: [PERMISSIONS.USERS.CREATE],
},
{
path: '/users/update',
permissions: [PERMISSIONS.USERS.UPDATE],
},
{
path: '/users/detail',
permissions: [PERMISSIONS.USERS.READ_DETAIL],
},
{
path: '/roles',
permissions: [PERMISSIONS.ROLES.READ_LIST],
},
{
path: '/roles/create',
permissions: [PERMISSIONS.ROLES.CREATE],
},
{
path: '/roles/update',
permissions: [PERMISSIONS.ROLES.UPDATE],
},
{
path: '/roles/detail',
permissions: [PERMISSIONS.ROLES.READ_DETAIL],
},
{
path: '/permissions',
permissions: [PERMISSIONS.PERMISSIONS.READ_LIST],
},
{
path: '/permissions/create',
permissions: [PERMISSIONS.PERMISSIONS.CREATE],
},
{
path: '/permissions/update',
permissions: [PERMISSIONS.PERMISSIONS.UPDATE],
},
{
path: '/permissions/detail',
permissions: [PERMISSIONS.PERMISSIONS.READ_DETAIL],
},
];
//TODO : Fix this later
// const redirectToFirstAccessibleRoute = (userPermissions: string[]) => {
// const fallback = mappingRoutePermissions.find((route) =>
// route.permissions.some((perm) => userPermissions.includes(perm))
// );
// return redirect(fallback?.path ?? '/auth/login');
// };
export const middleware = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const pathname = url.pathname;
const session = SessionUser.get();
const session_token = SessionToken.get();
const token = session_token?.token?.access_token;
const userPermissions =
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
if (mappingPublicRoutes.includes(pathname)) {
if (token) return redirect('/dashboard');
return null;
}
if (!session) return redirect('/auth/login');
const matchedRoute = mappingRoutePermissions.find(
(route) => route.path === pathname
);
if (matchedRoute) {
const hasPermission =
!matchedRoute.permissions ||
matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
if (!hasPermission) {
return '/dashboard';
}
}
return null;
};
+1 -2
View File
@@ -1,2 +1 @@
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_TURNSTILE_SITEKEY=1x00000000000000000000AA
NEXT_PUBLIC_API_URL=
@@ -1,86 +0,0 @@
'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 (
<motion.div
key={i}
className="pointer-events-none absolute"
style={{
top: `${getRandom(0, 100)}%`,
left: `${getRandom(0, 100)}%`,
fontSize: `${getRandom(16, 32)}px`,
color: color,
opacity: getRandom(0.1, 0.2),
rotate: getRandom(-180, 180),
}}
animate={{
y: [0, getRandom(-100, 100), 0],
x: [0, getRandom(-50, 50), 0],
rotate: getRandom(-180, 180),
}}
transition={{
duration: getRandom(15, 25),
repeat: Infinity,
repeatType: 'loop',
ease: 'easeInOut',
}}
>
<Icon className="h-full w-full" />
</motion.div>
);
});
}, [isClient, iconColorMap]);
if (!isClient) return null;
return (
<div className="fixed inset-0 z-0 overflow-hidden">{floatingIcons}</div>
);
}
@@ -1,25 +0,0 @@
'use server';
import { fetcher } from '@/lib/fetcher';
import {
forgotPasswordValidationSchema,
ForgotPasswordValidationType,
} from '../_validation/forgot-password-validation';
export async function ForgotPasswordAction(
request: ForgotPasswordValidationType
) {
const validRequest = forgotPasswordValidationSchema.parse(request);
const { data, error } = await fetcher.POST('/v1/auth/forgot', {
body: {
email: validRequest.email,
},
});
console.log(data, error);
if (error) throw new Error(error.message);
return data;
}
@@ -1,78 +0,0 @@
'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 { 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<TurnstileInstance | null>(null);
const form = useFormForgotPassword();
const { mutate, isPending, error } = usePostForgotPassowrd(form);
const onSubmit = (values: ForgotPasswordValidationType) => {
mutate(values);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="w-full space-y-4">
{error && (
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
{(error as Error).message}
</div>
)}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="emailmu@mail.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Turnstile
ref={ref}
siteKey={String(process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY)}
onSuccess={(token) => form.setValue('token', token)}
options={{
theme: 'light',
size: 'flexible',
language: 'id',
}}
/>
<Button
type="submit"
disabled={isPending}
className="w-full hover:bg-[#5fbaef] bg-[#22a5f1] font-bold"
>
{isPending ? (
<LuLoader className="h-5 w-5 animate-spin" />
) : (
'Reset password'
)}
</Button>
</form>
</Form>
);
}
@@ -1,16 +0,0 @@
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<ForgotPasswordValidationType>({
resolver: zodResolver(forgotPasswordValidationSchema),
defaultValues: {
email: '',
token: '',
},
});
}
@@ -1,16 +0,0 @@
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<typeof useFormForgotPassword>
) {
return useMutation({
mutationFn: ForgotPasswordAction,
onSuccess: ({ message }) => {
form.reset();
toast(message);
},
});
}
@@ -1,9 +0,0 @@
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
>;
@@ -1,23 +0,0 @@
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 (
<>
<div className="flex flex-col items-center gap-4">
<LogoSimple />
<p className="text-muted-foreground text-center text-sm sm:text-base">
Reset password akunmu
</p>
<ForgotPasswordForm />
</div>
</>
);
}
-25
View File
@@ -1,25 +0,0 @@
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 (
<div
className={cn(
baiJamjureeFont.className,
'bg-background/20 relative flex min-h-[100dvh] items-center justify-center p-4'
)}
>
<AnimatedBackground />
<div className="z-10 w-full">
<Card className="bg-background/90 mx-auto w-full max-w-[360px] p-6 shadow-xl backdrop-blur-lg sm:max-w-md sm:p-8">
<div className="flex flex-col items-center space-y-6">
<div className="w-full space-y-4">{children}</div>
</div>
</Card>
</div>
</div>
);
}
@@ -1,24 +0,0 @@
'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) {
console.log(request);
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;
}
@@ -1,78 +0,0 @@
'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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{error && (
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
{(error as Error).message}
</div>
)}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="emailmu@mail.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
<div className="text-right mt-1">
<Link
href="/forgot-password"
className="text-sm text-primary-500 font-bold hover:underline"
>
Lupa Password?
</Link>
</div>
</FormItem>
)}
/>
<Button type="submit" disabled={isPending} className="w-full font-bold">
{isPending ? <LuLoader className="h-5 w-5 animate-spin" /> : 'Masuk'}
</Button>
</form>
</Form>
);
}
@@ -1,16 +0,0 @@
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<SignInValidationType>({
resolver: zodResolver(signInValidationSchema),
defaultValues: {
email: '',
password: '',
},
});
}
@@ -1,18 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { SigninAction } from '../_actions/signin-action';
import { useFormSignin } from './use-form-signin';
export function usePostSignin(form: ReturnType<typeof useFormSignin>) {
const router = useRouter();
return useMutation({
mutationFn: SigninAction,
onSuccess: () => {
router.push('/');
},
onError: () => {
form.resetField('password');
},
});
}
@@ -1,24 +0,0 @@
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;
}
@@ -1,18 +0,0 @@
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<typeof signInValidationSchema>;
@@ -1,36 +0,0 @@
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 (
<>
<div className="flex flex-col items-center gap-4">
<LogoSimple />
<p className="text-muted-foreground text-center text-sm sm:text-base">
Masuk untuk mengakses akunmu
</p>
</div>
<SigninForm />
<div className="w-full space-y-4">
<p className="text-muted-foreground px-4 text-center text-xs leading-5 text-balance sm:text-sm">
Belum punya akun?{' '}
<Link
href="/signup"
className="font-bold hover:underline hover:underline-offset-4 transition-colors text-primary-500"
>
Daftar
</Link>
</p>
</div>
</>
);
}
@@ -1,22 +0,0 @@
'use server';
import { z } from 'zod';
import { fetchPostSignin } from '../_http/fetch-post-signup';
import { fetchPostverifyTurnstile } from '../_http/fetch-post-verify-turnstile';
import { signupValidationSchema } from '../_validation/signup-validation';
export async function SignupAction(
request: z.infer<typeof signupValidationSchema>
) {
const validRequest = signupValidationSchema.parse(request);
const isCapchaValidationValid = await fetchPostverifyTurnstile(
validRequest.token
);
if (!isCapchaValidationValid) throw new Error('Failed to verify captcha');
const data = await fetchPostSignin(validRequest);
return data;
}
@@ -1,223 +0,0 @@
'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<TurnstileInstance | null>(null);
const [step, setStep] = useState(1);
const [stepOneData, setStepOneData] = useState<z.infer<
typeof stepOneSignupValidationSchema
> | null>(null);
const firstForm = useForm<z.infer<typeof stepOneSignupValidationSchema>>({
resolver: zodResolver(stepOneSignupValidationSchema),
defaultValues: {
email: '',
phone_number: '',
fullname: '',
password: '',
confirm_password: '',
},
});
const secondForm = useForm<z.infer<typeof stepTwoSignupValidationSchema>>({
resolver: zodResolver(stepTwoSignupValidationSchema),
defaultValues: {
token: '',
},
});
const { mutate, isPending, error } = useMutation({
mutationFn: async (data: z.infer<typeof signupValidationSchema>) => {
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<typeof stepOneSignupValidationSchema>
) => {
setStepOneData(values);
setStep(2);
};
const handleSecondSubmit = (
values: z.infer<typeof stepTwoSignupValidationSchema>
) => {
if (stepOneData) {
mutate({ ...stepOneData, ...values });
}
};
return (
<>
{step === 1 && (
<Form {...firstForm}>
<form
onSubmit={firstForm.handleSubmit(handleFirstSubmit)}
className="space-y-4"
>
<FormField
control={firstForm.control}
name="fullname"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input placeholder="Nama Lengkap" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={firstForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="emailmu@mail.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={firstForm.control}
name="phone_number"
render={({ field }) => (
<FormItem>
<FormLabel>Nomor Telepon</FormLabel>
<FormControl>
<Input placeholder="08123456789" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={firstForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={firstForm.control}
name="confirm_password"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full bg-[#5fbaef] hover:bg-[#22a5f1]"
>
Selanjutnya
</Button>
</form>
</Form>
)}
{step === 2 && (
<Form {...secondForm}>
<form
onSubmit={secondForm.handleSubmit(handleSecondSubmit)}
className="space-y-4"
>
{error && (
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
{(error as Error).message}
</div>
)}
<Turnstile
ref={ref}
siteKey={String(process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY)}
onSuccess={(token) => secondForm.setValue('token', token)}
options={{
theme: 'light',
size: 'flexible',
language: 'id',
}}
/>
<div className="flex justify-between">
<Button
type="button"
variant="secondary"
onClick={() => setStep(1)}
>
Kembali
</Button>
<Button
type="submit"
disabled={isPending || !secondForm.watch('token')}
>
{isPending ? (
<LuLoader className="h-5 w-5 animate-spin" />
) : (
'Daftar'
)}
</Button>
</div>
</form>
</Form>
)}
</>
);
}
@@ -1,28 +0,0 @@
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;
}
@@ -1,41 +0,0 @@
export async function fetchPostverifyTurnstile(
token: string,
remoteIp?: string
): Promise<boolean> {
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[];
}
@@ -1,57 +0,0 @@
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<typeof signupValidationSchema>;
@@ -1,36 +0,0 @@
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 (
<>
<div className="flex flex-col items-center gap-4">
<LogoSimple />
<p className="text-muted-foreground text-center text-sm sm:text-base">
Buat akunmu sekarang
</p>
</div>
<SignupForm />
<div className="w-full space-y-4">
<p className="text-muted-foreground px-4 text-center text-xs leading-5 text-balance sm:text-sm">
Sudah punya akun?{' '}
<Link
href="/signin"
className="font-bold hover:underline hover:underline-offset-4 transition-colors text-primary-500"
>
Masuk
</Link>
</p>
</div>
</>
);
}
@@ -1,28 +0,0 @@
'use server';
import { fetcher } from '@/lib/fetcher';
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 isCapchaValidationValid = await fetchPostverifyTurnstile(
validRequest.token
);
if (!isCapchaValidationValid) throw new Error('Failed to verify captcha');
const { data } = await fetcher.POST('/v1/auth/send-otp', {
body: {
email: validRequest.email,
},
});
console.log(data);
return data?.message;
}
@@ -1,15 +0,0 @@
'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;
}
@@ -1,74 +0,0 @@
'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<TurnstileInstance | null>(null);
const form = useFormResendOTP();
const { mutate, isPending, error } = usePostResendOTP(form);
const onSubmit = (values: ResendOTPValidationType) => {
mutate(values);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{error && (
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
{(error as Error).message}
</div>
)}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="emailmu@mail.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Turnstile
ref={ref}
siteKey={String(process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY)}
onSuccess={(token) => form.setValue('token', token)}
options={{
theme: 'light',
size: 'flexible',
language: 'id',
}}
/>
<Button
type="submit"
className="w-full"
disabled={isPending || !form.watch('token')}
>
Kirim Ulang Kode OTP
</Button>
</form>
</Form>
);
}
@@ -1,41 +0,0 @@
'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 (
<>
<div className="flex space-x-2 border-b">
<button
className={cn(
'py-2 px-4 w-full',
activeTab === 'form'
? 'border-b-2 border-primary-500 font-semibold'
: 'text-gray-500'
)}
onClick={() => setActiveTab('form')}
>
Verifikasi Email
</button>
<button
className={cn(
'py-2 px-4 w-full',
activeTab === 'resend'
? 'border-b-2 border-primary-500 font-semibold'
: 'text-gray-500'
)}
onClick={() => setActiveTab('resend')}
>
Kirim Ulang OTP
</button>
</div>
{activeTab === 'form' ? <VerifyEmailForm /> : <ResendOTPForm />}
</>
);
}
@@ -1,77 +0,0 @@
'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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{error && (
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
{(error as Error).message}
</div>
)}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="emailmu@mail.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="otp"
render={({ field }) => (
<FormItem>
<FormLabel>OTP</FormLabel>
<FormControl>
<Input placeholder="123456" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={isPending}
className="w-full hover:bg-[#5fbaef] bg-[#22a5f1] font-bold"
>
{isPending ? (
<LuLoader className="h-5 w-5 animate-spin" />
) : (
'Verifikasi'
)}
</Button>
</form>
</Form>
);
}
@@ -1,20 +0,0 @@
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<ResendOTPValidationType>({
resolver: zodResolver(resendOTPValidationSchema),
defaultValues: {
email: email ?? '',
token: '',
},
});
}
@@ -1,20 +0,0 @@
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<VerifyEmailValidationType>({
resolver: zodResolver(verifyEmailValidationSchema),
defaultValues: {
email: email ?? '',
otp: '',
},
});
}
@@ -1,13 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { resendOTPAction } from '../_actions/resend-otp-action';
import { useFormResendOTP } from './use-form-resend-otp';
export function usePostResendOTP(form: ReturnType<typeof useFormResendOTP>) {
const router = useRouter();
return useMutation({
mutationFn: resendOTPAction,
onSuccess: () => router.replace('/verification?success=true'),
});
}
@@ -1,20 +0,0 @@
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<typeof useFormVerifyEmail>
) {
const router = useRouter();
return useMutation({
mutationFn: verifyEmailAction,
onSuccess: () => {
router.push('/');
},
onError: () => {
form.resetField('otp');
},
});
}
@@ -1,31 +0,0 @@
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;
}
@@ -1,41 +0,0 @@
export async function fetchPostverifyTurnstile(
token: string,
remoteIp?: string
): Promise<boolean> {
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[];
}
@@ -1,7 +0,0 @@
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<typeof resendOTPValidationSchema>;
@@ -1,12 +0,0 @@
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
>;
@@ -1,18 +0,0 @@
import { LogoSimple } from '../../_components/logo';
import { VerificationTabs } from './_components/verification-tabs';
export default function Page() {
return (
<>
<div className="flex flex-col items-center gap-4">
<LogoSimple />
<p className="text-muted-foreground text-center text-sm sm:text-base">
Verifikasi akun mu sekarang
</p>
</div>
<VerificationTabs />
</>
);
}
+19
View File
@@ -0,0 +1,19 @@
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 (
<>
<Hero />
<Features />
<Community />
<LearningResources />
<Testimonials />
<CallToAction />
</>
);
}
@@ -1,154 +0,0 @@
'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 (
<section id="community" className="w-full py-20 md:py-28">
<div className="container" ref={ref}>
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.4 }}
>
<h2 className="text-3xl font-semibold tracking-tight md:text-4xl">
Bergabung dengan Komunitas Kami di
<span className="block mt-2 text-primary-500">
Berbagai Platform
</span>
</h2>
<p className="max-w-[600px] mx-auto text-gray-600 md:text-lg/relaxed mt-4">
Terhubung dengan sesama developer di komunitas kami
</p>
</motion.div>
</div>
<motion.div
className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
variants={containerVariants}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
>
{SOCIALS.map((community, index) => {
const IconComponent = getIconComponent(community.icon);
const colors = getPlatformColors(community.icon);
return (
<motion.div
key={index}
variants={itemVariants}
className="group relative p-8 bg-white border border-gray-200 rounded-xl hover:border-gray-300 transition-all duration-300"
>
<div className="flex flex-col items-start gap-5">
<div className="flex items-center gap-4">
<IconComponent
className={`w-8 h-8 ${colors.iconColor} transition-colors`}
/>
<h3 className="text-xl font-semibold text-gray-900">
{community.name}
</h3>
</div>
<p className="text-gray-600 text-sm/relaxed">
{community.description}
</p>
<a
href={community.link}
className={`inline-flex items-center gap-2 mt-2 text-sm font-medium transition-colors`}
target="_blank"
rel="noopener noreferrer"
>
<span>Jelajahi Komunitas</span>
<FaArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</a>
</div>
</motion.div>
);
})}
</motion.div>
</div>
</section>
);
}
@@ -1,92 +0,0 @@
'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 (
<section className="w-full py-20 md:py-32 bg-gradient-to-br from-primary-500 to-primary-600 relative overflow-hidden">
{/* Background pattern */}
<div
className="absolute inset-0 opacity-10"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='52' height='26' viewBox='0 0 52 26' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M10 10c0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6h2c0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4v2c-3.314 0-6-2.686-6-6 0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6zm25.464-1.95l8.486 8.486-1.414 1.414-8.486-8.486 1.414-1.414z' /%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}}
></div>
<div className="container relative" ref={ref}>
<div className="flex flex-col lg:flex-row items-center justify-between gap-12">
{/* Text Content */}
<div className="flex-1 text-center lg:text-left space-y-8">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6 }}
>
<h2 className="text-4xl md:text-5xl font-bold text-white leading-tight">
<span className="block">LET&apos;S GO</span>
<span className="block text-5xl md:text-6xl mt-2">
<span className="text-yellow-300">SAAT</span>
<span className="text-white">NYA</span>
</span>
<span className="block mt-2">KAMU JOIN!</span>
</h2>
</motion.div>
<motion.p
initial={{ opacity: 0 }}
animate={isInView ? { opacity: 1 } : {}}
transition={{ delay: 0.3 }}
className="text-lg md:text-xl text-white/90 max-w-2xl mx-auto lg:mx-0"
>
Jadilah bagian dari komunitas developer terbesar di Indonesia.
Tingkatkan skill, perluas jaringan, dan raih kesempatan karir
bersama kami!
</motion.p>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={isInView ? { scale: 1, opacity: 1 } : {}}
transition={{ type: 'spring', stiffness: 100 }}
className="flex justify-center lg:justify-start"
>
<a
href="#community"
className="flex items-center gap-4 px-8 py-4 bg-yellow-300 hover:bg-yellow-400 text-gray-900 rounded-full text-lg font-semibold transition-all hover:gap-6 group"
>
<span>Join Sekarang</span>
<FaArrowRight className="w-5 h-5 transition-all group-hover:rotate-45" />
</a>
</motion.div>
</div>
{/* Illustration */}
<motion.div
initial={{ opacity: 0, x: 50 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ delay: 0.2 }}
className="flex-1 max-w-xl"
>
<div className="relative p-8">
<div className="absolute inset-0 bg-white/10 rounded-3xl transform rotate-6"></div>
<div className="relative bg-white/5 rounded-3xl p-8 backdrop-blur-lg border border-white/10">
<div className="flex flex-col items-center gap-6 text-white">
<LogoSimple className="w-[240px]" />
<div className="text-center space-y-2">
<h3 className="text-2xl font-bold">180.000+</h3>
<p className="text-lg">Programmer Sudah Bergabung</p>
</div>
</div>
</div>
</div>
</motion.div>
</div>
</div>
</section>
);
}
@@ -1,123 +0,0 @@
'use client';
import HERO_CONTENT from '@/data/hero-content.json';
import HERO_STATS from '@/data/hero-stats.json';
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 HeroSection() {
const router = useRouter();
const { communityLabel, headingLine1, headingLine2, description, buttons } =
HERO_CONTENT;
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handleScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<section className="relative w-full py-20 md:py-32 lg:py-40 overflow-hidden">
{/* Background gradients and motion */}
<div className="absolute inset-0 -z-10 overflow-hidden">
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-b from-background to-background/50" />
<div
className="absolute top-1/4 -left-20 w-80 h-80 rounded-full bg-gradient-to-r from-primary/20 to-blue-400/20 blur-3xl"
style={{
transform: `translate(${scrollY * 0.1}px, ${scrollY * -0.05}px)`,
opacity: Math.max(0.2, 1 - scrollY * 0.001),
}}
/>
<div
className="absolute bottom-1/3 -right-20 w-80 h-80 rounded-full bg-gradient-to-r from-blue-400/20 to-primary/20 blur-3xl"
style={{
transform: `translate(${scrollY * -0.1}px, ${scrollY * 0.05}px)`,
opacity: Math.max(0.2, 1 - scrollY * 0.001),
}}
/>
<div className="absolute inset-0 bg-[linear-gradient(rgba(59,130,246,0.05)_1px,transparent_1px),linear-gradient(to_right,rgba(59,130,246,0.05)_1px,transparent_1px)] bg-[size:40px_40px]" />
</div>
<div className="container px-4 md:px-6 relative">
<div className="grid gap-6 lg:grid-cols-2 lg:gap-12 items-center">
<motion.div
className="flex flex-col justify-center space-y-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<span className="inline-flex items-center rounded-full border px-3 py-1 text-sm w-fit">
{communityLabel}
</span>
<div className="space-y-4">
<h1 className="text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-foreground via-foreground to-foreground/70">
{headingLine1}{' '}
<span className="bg-clip-text bg-gradient-to-r text-primary-500">
{headingLine2}
</span>
</h1>
<p className="max-w-[600px] text-muted-foreground md:text-xl">
{description}
</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
<Button size="lg" onClick={() => router.push(buttons.joinUrl)}>
{buttons.join}
</Button>
<Button
size="lg"
variant="bordered"
className="w-full sm:w-auto group relative overflow-hidden"
onClick={() => router.push(buttons.exploreUrl)}
>
<span className="absolute inset-0 bg-gradient-to-r from-primary/10 to-blue-400/10 translate-y-full group-hover:translate-y-0 transition-transform duration-300" />
<span className="relative">{buttons.explore}</span>
</Button>
</div>
<div className="flex flex-wrap justify-center md:justify-start gap-6 sm:gap-8">
{HERO_STATS.map(({ value, label }, i) => (
<Fragment key={i}>
<div className="flex flex-col items-center">
<div className="text-2xl font-bold bg-clip-text text-primary-500">
{value}
</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
{i < HERO_STATS.length - 1 && (
<div className="hidden sm:block h-10 border-r border-border mx-4" />
)}
</Fragment>
))}
</div>
</motion.div>
<motion.div
className="relative w-full lg:w-auto mx-auto lg:ml-auto"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<Image
src="/logo.webp"
alt="logo"
width={600}
height={500}
className="w-full h-auto object-cover"
/>
</motion.div>
</div>
</div>
</section>
);
}
@@ -1,102 +0,0 @@
'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 (
<section className="w-full py-20 md:py-28 bg-gray-50">
<div className="container" ref={ref}>
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.4 }}
>
<h2 className="text-3xl font-semibold tracking-tight md:text-4xl">
Apa Kata Mereka Tentang
<span className="block mt-2 text-primary-500">
Komunitas Kami?
</span>
</h2>
</motion.div>
</div>
<motion.div
className="grid gap-8 md:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 mb-16"
variants={containerVariants}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
>
{TESTIMONIALS.map((testimonial) => (
<motion.div
key={testimonial.id}
variants={itemVariants}
className="p-6 bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300"
>
<div className="flex flex-col space-y-4">
<div className="flex items-center gap-4">
<Image
src={testimonial.image}
alt={testimonial.name}
width={48}
height={48}
className="w-12 h-12 rounded-full object-cover"
unoptimized
/>
<div>
<h4 className="font-semibold text-gray-900">
{testimonial.name}
</h4>
<p className="text-sm text-gray-600">{testimonial.role}</p>
</div>
</div>
<div className="text-gray-600 relative">
<FaQuoteLeft className="text-primary-500/30 w-6 h-6 mb-2" />
<p className="text-sm/relaxed">{testimonial.text}</p>
</div>
</div>
</motion.div>
))}
</motion.div>
<div className="flex justify-center">
<Link href="/testimonials" className={buttonVariants({ size: 'lg' })}>
Tulis Testimonimu
</Link>
</div>
</div>
</section>
);
}
@@ -1,14 +0,0 @@
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 (
<>
<HeroSection />
<CommunitySection />
<TestimonialSection />
<CTASection />
</>
);
}
@@ -1,121 +0,0 @@
import { LogoSimple } from '@/app/_components/logo';
import NAVIGATIONS from '@/data/navigations.json';
import SOCIALS from '@/data/socials.json';
import Link from 'next/link';
export default function Footer() {
return (
<footer className="w-full border-t bg-background py-12 md:py-16">
<div className="container px-4 md:px-6">
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
<div className="space-y-4">
<div className="flex items-center gap-2">
<LogoSimple />
</div>
<p className="text-sm text-muted-foreground">
Ingin Menjadi Programmer Handal Namun Enggan Ngoding
</p>
<div className="flex space-x-4">
<Link
href="#"
className="text-muted-foreground hover:text-foreground"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z" />
</svg>
</Link>
<Link
href="#"
className="text-muted-foreground hover:text-foreground"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 8h-3v4h3v12h5v-12h3.642l.358-4h-4v-1.667c0-.955.192-1.333 1.115-1.333h2.885v-5h-3.808c-3.596 0-5.192 1.583-5.192 4.615v3.385z" />
</svg>
</Link>
<Link
href="#"
className="text-muted-foreground hover:text-foreground"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z" />
</svg>
</Link>
<Link
href="#"
className="text-muted-foreground hover:text-foreground"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 3.993-8 4.007z" />
</svg>
</Link>
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-bold">Halaman</h3>
<ul className="space-y-2">
{NAVIGATIONS.map(({ link, title }) => (
<li key={link}>
<Link
href={link}
className="text-sm text-muted-foreground hover:text-foreground"
>
{title}
</Link>
</li>
))}
</ul>
</div>
<div className="space-y-4">
<h3 className="text-lg font-bold">Link</h3>
<ul className="space-y-2">
{SOCIALS.map(({ link, name }) => (
<li key={name}>
<Link
href={link}
className="text-sm text-muted-foreground hover:text-foreground"
>
{name}
</Link>
</li>
))}
</ul>
</div>
<div className="space-y-4">
<h3 className="text-lg font-bold">Community Patners</h3>
<ul className="space-y-2"></ul>
</div>
</div>
<div className="mt-8 border-t pt-8 text-center">
<p className="text-xs text-muted-foreground">
© {new Date().getFullYear()} IMPHNEN - Ingin Menjadi Programmer
Handal, Namun Enggan Ngoding. All rights reserved.
</p>
</div>
</div>
</footer>
);
}
@@ -1,110 +0,0 @@
'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 (
<header
className={cn(
'sticky top-0 z-50 w-full transition-all duration-300',
isScrolled
? 'bg-background/80 backdrop-blur-md border-b shadow-sm'
: 'bg-transparent'
)}
>
<div className="container flex h-16 items-center justify-between">
<div className="flex items-center gap-2">
<div className="relative overflow-hidden rounded">
<Link href="/">
<LogoSimple className="w-[80px]" />
</Link>
</div>
</div>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-8">
{NAVIGATIONS.map(({ link, title }) => (
<Link
key={link}
href={link}
className="text-sm font-medium relative group"
>
<span className="transition-colors hover:text-primary">
{title}
</span>
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-primary-500 transition-all duration-300 group-hover:w-full"></span>
</Link>
))}
</nav>
<div className="flex items-center gap-x-2">
<Button
onClick={() => router.push('/signin')}
className="hidden md:flex"
>
Masuk
</Button>
<Button
variant="secondary"
onClick={() => router.push('/signup')}
className="hidden md:flex"
>
Daftar
</Button>
{/* Mobile Menu Button */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="flex md:hidden"
>
{mobileMenuOpen ? (
<LuX className="size-5" />
) : (
<LuMenu className="size-5" />
)}
</button>
</div>
</div>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="md:hidden border-t bg-background/95 backdrop-blur-md">
<nav className="container flex flex-col py-4 text-center">
{NAVIGATIONS.map(({ link, title }) => (
<Link
key={link}
href={link}
className="py-3 text-sm font-medium border-b border-border/50"
onClick={() => router.push(link)}
>
{title}
</Link>
))}
<Button onClick={() => router.push('/signin')}>Login</Button>
</nav>
</div>
)}
</header>
);
}
@@ -1,192 +0,0 @@
'use client';
import events from '@/data/events.json';
import { buttonVariants } from '@components';
import { cn } from '@utils';
import Image from 'next/image';
import { HiCalendar, HiClock, HiLocationMarker } from 'react-icons/hi';
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
};
const formatTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'Asia/Jakarta',
});
};
const getEventStatus = (endDate: string) => {
const now = new Date();
const end = new Date(endDate);
return end > now ? 'upcoming' : 'past';
};
export default function EventsPage() {
const sortedEvents = [...events].sort(
(a, b) =>
new Date(b.start_date).getTime() - new Date(a.start_date).getTime()
);
return (
<section className="min-h-screen bg-background container py-10">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{/* Featured Card */}
<div className="col-span-full">
<div className="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-200 overflow-hidden bg-card">
<div className="grid md:grid-cols-2">
<div className="min-h-96 bg-muted relative">
<Image
src={sortedEvents[0].thumbnail}
alt={sortedEvents[0].name}
fill
className="object-cover object-top"
sizes="(max-width: 768px) 100vw, 50vw"
priority
unoptimized
/>
</div>
<div className="p-8 flex flex-col">
<div className="flex items-center gap-2 mb-4">
<span className="bg-primary/20 text-primary text-xs px-2.5 py-1 rounded-full">
Event Terbaru
</span>
<span
className={cn(
'px-2 py-1 rounded-full text-xs',
getEventStatus(sortedEvents[0].end_date) === 'upcoming'
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
)}
>
{getEventStatus(sortedEvents[0].end_date) === 'upcoming'
? 'Upcoming'
: 'Selesai'}
</span>
</div>
<h2 className="text-2xl font-bold mb-4 text-foreground">
{sortedEvents[0].name}
</h2>
<div className="space-y-3 mb-6 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<HiCalendar className="w-4 h-4" />
<span>{formatDate(sortedEvents[0].start_date)}</span>
</div>
<div className="flex items-center gap-2">
<HiClock className="w-4 h-4" />
<span>
{formatTime(sortedEvents[0].start_date)} -{' '}
{formatTime(sortedEvents[0].end_date)} WIB
</span>
</div>
<div className="flex items-center gap-2">
<HiLocationMarker className="w-4 h-4" />
<span>
{sortedEvents[0].type === 'online'
? 'Online'
: sortedEvents[0].location}
</span>
</div>
{sortedEvents[0].price > 0 && (
<div className="mt-1 font-medium">
Rp {sortedEvents[0].price.toLocaleString('id-ID')}
</div>
)}
</div>
<p className="text-muted-foreground mb-6 line-clamp-4">
{sortedEvents[0].description}
</p>
<a
href={sortedEvents[0].detail_link}
target="_blank"
className={cn(
buttonVariants({ variant: 'bordered' }),
'mt-auto w-full md:w-fit'
)}
>
Lihat Detail
</a>
</div>
</div>
</div>
</div>
{/* Regular Cards */}
{sortedEvents.slice(1).map((event) => (
<div
key={event.name}
className="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-200 bg-card"
>
<div className="h-48 bg-muted relative">
<Image
src={event.thumbnail}
alt={event.name}
fill
className="object-cover object-top rounded-t-xl"
sizes="(max-width: 768px) 100vw, 33vw"
unoptimized
/>
</div>
<div className="p-6">
<div className="flex justify-between items-start mb-4">
<span
className={cn(
'px-2 py-1 rounded-full text-xs',
getEventStatus(event.end_date) === 'upcoming'
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
)}
>
{getEventStatus(event.end_date) === 'upcoming'
? 'Upcoming'
: 'Selesai'}
</span>
</div>
<h3 className="text-lg font-semibold mb-3 text-foreground">
{event.name}
</h3>
<div className="space-y-2 mb-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<HiCalendar className="w-4 h-4" />
<span>{formatDate(event.start_date)}</span>
</div>
<div className="flex items-center gap-2">
<HiLocationMarker className="w-4 h-4" />
<span>
{event.type === 'online' ? 'Online' : event.location}
</span>
</div>
{event.price > 0 && (
<div className="font-medium">
Rp {event.price.toLocaleString('id-ID')}
</div>
)}
</div>
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
{event.description}
</p>
<a
href={event.detail_link}
target="_blank"
className={cn(
buttonVariants({ variant: 'bordered' }),
'w-full text-sm'
)}
>
Lihat Detail
</a>
</div>
</div>
))}
</div>
</section>
);
}
-12
View File
@@ -1,12 +0,0 @@
import Footer from './_components/footer';
import { Header } from './_components/header';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
);
}
@@ -1,56 +0,0 @@
import TESTIMONIALS from '@/data/testimonials.json';
import { Button } from '@components';
import Image from 'next/image';
import Link from 'next/link';
import { BsChatLeftQuote } from 'react-icons/bs';
import { FaQuoteLeft } from 'react-icons/fa';
export default function Page() {
return (
<div className="min-h-screen">
<div className="py-16 px-4 text-center border-b border-border">
<BsChatLeftQuote className="size-14 mx-auto my-4 text-foreground" />
<h1 className="text-4xl font-bold mb-4 text-foreground">Testimonial</h1>
<p className="max-w-2xl mx-auto text-lg mb-8 text-balance text-muted-foreground">
Menampilkan pengalaman dan cerita para member yang telah join ke dalam
komunitas kami
</p>
<Link href="/testimonials/submit">
<Button>Tulis Testimonialmu</Button>
</Link>
</div>
<div className="grid gap-8 md:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 my-16 container">
{TESTIMONIALS.map((testimonial) => (
<div
key={testimonial.id}
className="p-6 bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300"
>
<div className="flex flex-col space-y-4">
<div className="flex items-center gap-4">
<Image
src={testimonial.image}
alt={testimonial.name}
width={48}
height={48}
className="w-12 h-12 rounded-full object-cover"
unoptimized
/>
<div>
<h4 className="font-semibold text-gray-900">
{testimonial.name}
</h4>
<p className="text-sm text-gray-600">{testimonial.role}</p>
</div>
</div>
<div className="text-gray-600 relative">
<FaQuoteLeft className="text-primary-500/30 w-6 h-6 mb-2" />
<p className="text-sm/relaxed">{testimonial.text}</p>
</div>
</div>
</div>
))}
</div>
</div>
);
}
+210
View File
@@ -0,0 +1,210 @@
import Link from 'next/link';
export default function Footer() {
return (
<footer className="w-full border-t bg-background py-12 md:py-16">
<div className="container px-4 md:px-6">
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
IMPHNEN
</span>
</div>
<p className="text-sm text-muted-foreground">
Ingin Menjadi Programmer Handal merupakan komunitas Programmer
Indonesia.
</p>
<div className="flex space-x-4">
<Link
href="#"
className="text-muted-foreground hover:text-foreground"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z" />
</svg>
</Link>
<Link
href="#"
className="text-muted-foreground hover:text-foreground"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 8h-3v4h3v12h5v-12h3.642l.358-4h-4v-1.667c0-.955.192-1.333 1.115-1.333h2.885v-5h-3.808c-3.596 0-5.192 1.583-5.192 4.615v3.385z" />
</svg>
</Link>
<Link
href="#"
className="text-muted-foreground hover:text-foreground"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z" />
</svg>
</Link>
<Link
href="#"
className="text-muted-foreground hover:text-foreground"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 3.993-8 4.007z" />
</svg>
</Link>
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-bold">Tautan Cepat</h3>
<ul className="space-y-2">
<li>
<Link
href="#fitur"
className="text-sm text-muted-foreground hover:text-foreground"
>
Fitur
</Link>
</li>
<li>
<Link
href="#komunitas"
className="text-sm text-muted-foreground hover:text-foreground"
>
Komunitas
</Link>
</li>
<li>
<Link
href="#sumber-belajar"
className="text-sm text-muted-foreground hover:text-foreground"
>
Sumber Belajar
</Link>
</li>
<li>
<Link
href="#testimoni"
className="text-sm text-muted-foreground hover:text-foreground"
>
Testimoni
</Link>
</li>
</ul>
</div>
<div className="space-y-4">
<h3 className="text-lg font-bold">Sumber Belajar</h3>
<ul className="space-y-2">
<li>
<Link
href="#"
className="text-sm text-muted-foreground hover:text-foreground"
>
Video Tutorial
</Link>
</li>
<li>
<Link
href="#"
className="text-sm text-muted-foreground hover:text-foreground"
>
Artikel
</Link>
</li>
<li>
<Link
href="#"
className="text-sm text-muted-foreground hover:text-foreground"
>
Tantangan Koding
</Link>
</li>
<li>
<Link
href="#"
className="text-sm text-muted-foreground hover:text-foreground"
>
Sharing Session
</Link>
</li>
</ul>
</div>
<div className="space-y-4">
<h3 className="text-lg font-bold">Bahasa Pemrograman</h3>
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-600/10 dark:bg-blue-900/30 dark:text-blue-400 dark:ring-blue-400/20">
PHP
</span>
<span className="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-yellow-600/10 dark:bg-yellow-900/30 dark:text-yellow-400 dark:ring-yellow-400/20">
JavaScript
</span>
<span className="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/10 dark:bg-green-900/30 dark:text-green-400 dark:ring-green-400/20">
Python
</span>
<span className="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-600/10 dark:bg-blue-900/30 dark:text-blue-400 dark:ring-blue-400/20">
C#
</span>
<span className="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-600/10 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-400/20">
Java
</span>
<span className="inline-flex items-center rounded-md bg-cyan-50 px-2 py-1 text-xs font-medium text-cyan-700 ring-1 ring-inset ring-cyan-600/10 dark:bg-cyan-900/30 dark:text-cyan-400 dark:ring-cyan-400/20">
Go
</span>
<span className="inline-flex items-center rounded-md bg-orange-50 px-2 py-1 text-xs font-medium text-orange-700 ring-1 ring-inset ring-orange-600/10 dark:bg-orange-900/30 dark:text-orange-400 dark:ring-orange-400/20">
Rust
</span>
<span className="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-600/10 dark:bg-blue-900/30 dark:text-blue-400 dark:ring-blue-400/20">
HTML
</span>
</div>
<div className="mt-6">
<h3 className="text-lg font-bold mb-2">Newsletter</h3>
<p className="text-sm text-muted-foreground mb-2">
Dapatkan update terbaru dari kami
</p>
<div className="flex gap-2">
<input
type="email"
placeholder="Email Anda"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
<button
className="inline-flex items-center justify-center rounded-md bg-primary px-3 py-1 text-sm shadow hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50
font-bold text-black hover:text-white cursor-pointer"
>
Daftar
</button>
</div>
</div>
</div>
</div>
<div className="mt-8 border-t pt-8 text-center">
<p className="text-xs text-muted-foreground">
© {new Date().getFullYear()} IMPHNEN - Ingin Menjadi Programmer
Handal, Namun Enggan Ngoding. All rights reserved.
</p>
</div>
</div>
</footer>
);
}
+154
View File
@@ -0,0 +1,154 @@
'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 (
<header
className={cn(
'sticky top-0 z-50 w-full transition-all duration-300',
isScrolled
? 'bg-background/80 backdrop-blur-md border-b shadow-sm'
: 'bg-transparent'
)}
>
<div className="container flex h-16 items-center justify-between">
<div className="flex items-center gap-2">
<div className="relative overflow-hidden rounded">
<Link href="/">
<Image
src="/logo.webp"
alt="IMPHNEN"
width={64}
height={64}
className="object-cover"
/>
</Link>
</div>
</div>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-8">
<Link href="#fitur" className="text-sm font-medium relative group">
<span className="transition-colors hover:text-primary">Fitur</span>
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-primary transition-all duration-300 group-hover:w-full"></span>
</Link>
<Link
href="#komunitas"
className="text-sm font-medium relative group"
>
<span className="transition-colors hover:text-primary">
Komunitas
</span>
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-primary transition-all duration-300 group-hover:w-full"></span>
</Link>
<Link
href="#sumber-belajar"
className="text-sm font-medium relative group"
>
<span className="transition-colors hover:text-primary">
Sumber Belajar
</span>
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-primary transition-all duration-300 group-hover:w-full"></span>
</Link>
<Link
href="#testimoni"
className="text-sm font-medium relative group"
>
<span className="transition-colors hover:text-primary">
Testimoni
</span>
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-primary transition-all duration-300 group-hover:w-full"></span>
</Link>
</nav>
<div className="flex items-center gap-x-2">
<SimpleThemeToggle />
<Button
className="group relative w-full sm:w-auto bg-gradient-to-r from-primary to-blue-600 hover:from-primary/90 hover:to-blue-600/90 transition-all duration-300 font-bold text-white hover:text-white/90 shadow-lg cursor-pointer"
onClick={() => router.push('/signin')}
>
Login
</Button>
{/* Mobile Menu Button */}
<Button
variant="ghost"
size="icon"
className="md:hidden"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? (
<XIcon className="h-6 w-6" />
) : (
<MenuIcon className="h-6 w-6" />
)}
</Button>
</div>
</div>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="md:hidden border-t bg-background/95 backdrop-blur-md">
<nav className="container flex flex-col py-4 text-center">
<Link
href="#fitur"
className="py-3 text-sm font-medium border-b border-border/50"
onClick={() => setMobileMenuOpen(false)}
>
Fitur
</Link>
<Link
href="#komunitas"
className="py-3 text-sm font-medium border-b border-border/50"
onClick={() => setMobileMenuOpen(false)}
>
Komunitas
</Link>
<Link
href="#sumber-belajar"
className="py-3 text-sm font-medium border-b border-border/50"
onClick={() => setMobileMenuOpen(false)}
>
Sumber Belajar
</Link>
<Link
href="#testimoni"
className="py-3 text-sm font-medium"
onClick={() => setMobileMenuOpen(false)}
>
Testimoni
</Link>
<Button
onClick={() => router.push('/signin')}
className="group relative w-full sm:w-auto bg-gradient-to-r from-primary to-blue-600 hover:from-primary/90 hover:to-blue-600/90 transition-all duration-300 font-bold text-white hover:text-white/90 shadow-lg cursor-pointer"
>
Login
</Button>
</nav>
</div>
)}
</header>
);
}
File diff suppressed because one or more lines are too long
@@ -1,17 +0,0 @@
'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 <QueryProvider>{children}</QueryProvider>;
}
export const queryClient = new QueryClient();
function QueryProvider({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
@@ -4,7 +4,7 @@ import { Button, MoonIcon, SunIcon } from '@components';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
export function ThemeToggle() {
export function SimpleThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
@@ -22,7 +22,7 @@ export function ThemeToggle() {
return (
<Button
variant="bordered"
variant="outline"
size="icon"
onClick={toggleTheme}
className="focus-visible:ring-0 cursor-pointer"
@@ -0,0 +1,8 @@
'use client';
import type { ThemeProviderProps } from 'next-themes';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
@@ -1,28 +0,0 @@
'use client';
import { Toaster as Sonner } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="light"
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton:
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton:
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
);
};
export { Toaster };
+14 -9
View File
@@ -1,9 +1,11 @@
import { poppinsFont } from '@/lib/fonts';
import '@/styles/globals.css';
import { cn } from '@utils';
import { type Metadata } from 'next';
import { Providers } from './_components/providers';
import { Toaster } from './_components/toaster';
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'] });
export const metadata: Metadata = {
title: 'IMPHNEN - Ingin Menjadi Programmer Handal?',
@@ -17,16 +19,19 @@ export default function RootLayout({
}) {
return (
<html lang="id" suppressHydrationWarning>
<body className={cn(poppinsFont.className, 'antialiased')}>
<Providers
<body className={inter.className}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
<Toaster richColors />
</Providers>
<div className="flex min-h-screen flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
</ThemeProvider>
</body>
</html>
);
-46
View File
@@ -1,46 +0,0 @@
[
{
"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 well explore how AI is reshaping the payment industry. Whether youre 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"
}
]
-12
View File
@@ -1,12 +0,0 @@
{
"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"
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
[
{ "value": "180K+", "label": "Member" },
{ "value": "500+", "label": "Meme Harian" },
{ "value": "500+", "label": "Tutorial" },
{ "value": "24/7", "label": "Yapping" }
]
-14
View File
@@ -1,14 +0,0 @@
[
{
"title": "Home",
"link": "/"
},
{
"title": "Event",
"link": "/events"
},
{
"title": "Testimoni",
"link": "/testimonials"
}
]
-37
View File
@@ -1,37 +0,0 @@
[
{
"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": "#"
}
]
+12 -36
View File
@@ -1,44 +1,20 @@
[
{
"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": "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": 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": "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": 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"
"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"
}
]
-24
View File
@@ -1,24 +0,0 @@
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;
}
-6
View File
@@ -1,6 +0,0 @@
import type { paths } from '@/openapi-types';
import createClient from 'openapi-fetch';
export const fetcher = createClient<paths>({
baseUrl: process.env.NEXT_PUBLIC_API_URL,
});
-13
View File
@@ -1,13 +0,0 @@
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',
});
-8
View File
@@ -1,8 +0,0 @@
import type { paths } from '@/openapi-types';
import createFetchClient from 'openapi-fetch';
import createClient from 'openapi-react-query';
const fetchClient = createFetchClient<paths>({
baseUrl: process.env.NEXT_PUBLIC_API_URL,
});
export const rpc = createClient(fetchClient);
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -34,8 +34,8 @@ export const postVerifyEmail = async (
): Promise<TResponseMessage> => {
const { data } = await api({
method: 'POST',
url: '/auth/verify-email',
data: { otp: parseInt(payload.otp), email: payload.email },
url: '/auth/verify',
data: payload,
});
return data;
};
-9
View File
@@ -57,12 +57,3 @@ export const authRegisterSchema = z
message: 'Password dan Konfirmasi Password harus sama',
path: ['confirm_password'],
});
export const verifyEmailSchema = z
.object({
otp: z
.string({
required_error: 'OTP tidak boleh kosong',
})
.min(6, 'Masukkan kode 6 digit yang dikirimkan ke email')
})
+2 -2
View File
@@ -20,10 +20,10 @@ export type TRegisterRequest = z.infer<typeof authRegisterSchema>;
export type TRegisterResponse = TResponseDetail<TResponseMessage>;
export type TVerifyOtpRequest = {
otp: string;
otp: number;
};
export type TVerifyEmailRequest = {
email: string;
otp: string;
otp: number;
};
+5 -118
View File
@@ -8,18 +8,14 @@
"name": "@imphnen-frontend-service/shadcn-ui",
"version": "0.0.1",
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-slot": "^1.2.0",
"@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",
"zod": "^3.25.28"
"tailwindcss": "^4.1.4"
},
"devDependencies": {
"tw-animate-css": "^1.2.8"
@@ -450,18 +446,6 @@
"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",
@@ -477,56 +461,10 @@
}
}
},
"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.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
"integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
@@ -821,12 +759,6 @@
],
"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",
@@ -1532,35 +1464,6 @@
"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",
@@ -1610,13 +1513,6 @@
"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",
@@ -1753,15 +1649,6 @@
"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"
}
}
}
}
+2 -6
View File
@@ -10,18 +10,14 @@
}
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-slot": "^1.2.0",
"@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",
"zod": "^3.25.28"
"tailwindcss": "^4.1.4"
},
"devDependencies": {
"tw-animate-css": "^1.2.8"
+12 -10
View File
@@ -3,21 +3,23 @@
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '../lib';
import { cn } from '../lib/cn';
const buttonVariants = cva(
'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',
'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',
{
variants: {
variant: {
primary: 'bg-primary-500 hover:bg-primary-600 text-white shadow-md',
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',
secondary:
'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',
'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',
},
size: {
default: 'h-9 px-4 py-2',
@@ -27,7 +29,7 @@ const buttonVariants = cva(
},
},
defaultVariants: {
variant: 'primary',
variant: 'default',
size: 'default',
},
}
-82
View File
@@ -1,82 +0,0 @@
import * as React from 'react';
import { cn } from '../lib';
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-xl border bg-card text-card-foreground shadow',
className
)}
{...props}
/>
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
));
CardFooter.displayName = 'CardFooter';
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};
-167
View File
@@ -1,167 +0,0 @@
'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<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
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 <FormField>');
}
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<FormItemContextValue>(
{} as FormItemContextValue
);
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn('grid gap-2', className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn('data-[error=true]:text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? '') : props.children;
if (!body) {
return null;
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn('text-destructive text-sm', className)}
{...props}
>
{body}
</p>
);
}
export {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField,
};

Some files were not shown because too many files have changed in this diff Show More