Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46f1e43114 | ||
|
|
84c60dcffa | ||
|
|
f432085eb8 | ||
|
|
a4e543d30e | ||
|
|
69b32f7a46 | ||
|
|
37f926a74d | ||
|
|
88e8649b2a | ||
|
|
031256ec4e | ||
|
|
d96df63660 | ||
|
|
8fb3e9a37f |
@@ -1,2 +1,4 @@
|
|||||||
NEXT_PUBLIC_API_URL=
|
NEXT_PUBLIC_API_URL=
|
||||||
NEXT_PUBLIC_TURNSTILE_SITEKEY=1x00000000000000000000AA
|
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
NEXT_PUBLIC_TURNSTILE_SITEKEY=
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { fetcher } from '@/lib/fetcher';
|
import { fetcher } from '@/lib/fetcher';
|
||||||
|
import { getRemoteIp } from '@/lib/headers';
|
||||||
|
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
|
||||||
import {
|
import {
|
||||||
forgotPasswordValidationSchema,
|
forgotPasswordValidationSchema,
|
||||||
ForgotPasswordValidationType,
|
ForgotPasswordValidationType,
|
||||||
@@ -10,6 +12,14 @@ export async function ForgotPasswordAction(
|
|||||||
request: ForgotPasswordValidationType
|
request: ForgotPasswordValidationType
|
||||||
) {
|
) {
|
||||||
const validRequest = forgotPasswordValidationSchema.parse(request);
|
const validRequest = forgotPasswordValidationSchema.parse(request);
|
||||||
|
const remoteIp = await getRemoteIp();
|
||||||
|
|
||||||
|
const isCapchaValid = await fetchPostverifyTurnstile(
|
||||||
|
validRequest.token,
|
||||||
|
remoteIp
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isCapchaValid) throw new Error('Failed to verify captcha');
|
||||||
|
|
||||||
const { data, error } = await fetcher.POST('/v1/auth/forgot', {
|
const { data, error } = await fetcher.POST('/v1/auth/forgot', {
|
||||||
body: {
|
body: {
|
||||||
@@ -17,8 +27,6 @@ export async function ForgotPasswordAction(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(data, error);
|
|
||||||
|
|
||||||
if (error) throw new Error(error.message);
|
if (error) throw new Error(error.message);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
} from '@components';
|
} from '@components';
|
||||||
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
|
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
|
||||||
import { useRef } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { LuLoader } from 'react-icons/lu';
|
import { LuLoader } from 'react-icons/lu';
|
||||||
import { useFormForgotPassword } from '../_hooks/use-form-forgot-password';
|
import { useFormForgotPassword } from '../_hooks/use-form-forgot-password';
|
||||||
import { usePostForgotPassowrd } from '../_hooks/use-post-forgot-password';
|
import { usePostForgotPassowrd } from '../_hooks/use-post-forgot-password';
|
||||||
@@ -20,54 +20,74 @@ import { ForgotPasswordValidationType } from '../_validation/forgot-password-val
|
|||||||
export function ForgotPasswordForm() {
|
export function ForgotPasswordForm() {
|
||||||
const ref = useRef<TurnstileInstance | null>(null);
|
const ref = useRef<TurnstileInstance | null>(null);
|
||||||
|
|
||||||
|
const [step, setStep] = useState<number>(1);
|
||||||
|
const [emailValue, setEmailValue] = useState<string>('');
|
||||||
|
|
||||||
const form = useFormForgotPassword();
|
const form = useFormForgotPassword();
|
||||||
const { mutate, isPending, error } = usePostForgotPassowrd(form);
|
const { mutate, isPending, error } = usePostForgotPassowrd(form);
|
||||||
|
|
||||||
const onSubmit = (values: ForgotPasswordValidationType) => {
|
const handleFirstStep = (values: ForgotPasswordValidationType) => {
|
||||||
mutate(values);
|
setEmailValue(values.email);
|
||||||
|
setStep(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSecondStep = () => {
|
||||||
|
mutate({ email: emailValue, token: form.getValues('token') });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="w-full space-y-4">
|
<form
|
||||||
|
onSubmit={
|
||||||
|
step === 1
|
||||||
|
? form.handleSubmit(handleFirstStep)
|
||||||
|
: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSecondStep();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
className="w-full space-y-4"
|
||||||
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
|
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
|
||||||
{(error as Error).message}
|
{(error as Error).message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField
|
{step === 1 && (
|
||||||
control={form.control}
|
<FormField
|
||||||
name="email"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="email"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>Email</FormLabel>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>Email</FormLabel>
|
||||||
<Input placeholder="emailmu@mail.com" {...field} />
|
<FormControl>
|
||||||
</FormControl>
|
<Input placeholder="emailmu@mail.com" {...field} />
|
||||||
<FormMessage />
|
</FormControl>
|
||||||
</FormItem>
|
<FormMessage />
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Turnstile
|
{step === 2 && (
|
||||||
ref={ref}
|
<Turnstile
|
||||||
siteKey={String(process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY)}
|
ref={ref}
|
||||||
onSuccess={(token) => form.setValue('token', token)}
|
siteKey={String(process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY)}
|
||||||
options={{
|
onSuccess={(token) => form.setValue('token', token)}
|
||||||
theme: 'light',
|
options={{ theme: 'light', size: 'flexible', language: 'id' }}
|
||||||
size: 'flexible',
|
/>
|
||||||
language: 'id',
|
)}
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending}
|
disabled={isPending || (step === 2 && !form.watch('token'))}
|
||||||
className="w-full hover:bg-[#5fbaef] bg-[#22a5f1] font-bold"
|
className="w-full hover:bg-[#5fbaef] bg-[#22a5f1] font-bold"
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<LuLoader className="h-5 w-5 animate-spin" />
|
<LuLoader className="h-5 w-5 animate-spin" />
|
||||||
|
) : step === 1 ? (
|
||||||
|
'Selanjutnya'
|
||||||
) : (
|
) : (
|
||||||
'Reset password'
|
'Reset password'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ export function usePostForgotPassowrd(
|
|||||||
mutationFn: ForgotPasswordAction,
|
mutationFn: ForgotPasswordAction,
|
||||||
onSuccess: ({ message }) => {
|
onSuccess: ({ message }) => {
|
||||||
form.reset();
|
form.reset();
|
||||||
toast(message);
|
toast.success(message);
|
||||||
|
},
|
||||||
|
onError: ({ message }) => {
|
||||||
|
form.reset();
|
||||||
|
toast.error(message);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { fetcher } from '@/lib/fetcher';
|
||||||
|
import {
|
||||||
|
resetPasswordValidationSchema,
|
||||||
|
ResetPasswordValidationSchema,
|
||||||
|
} from '../_validation/reset-password-validation';
|
||||||
|
|
||||||
|
export async function resetPasswordAction(
|
||||||
|
request: ResetPasswordValidationSchema
|
||||||
|
) {
|
||||||
|
const validRequest = resetPasswordValidationSchema.parse(request);
|
||||||
|
|
||||||
|
if (validRequest.confirm_password !== validRequest.confirm_password) {
|
||||||
|
throw new Error('Password missmatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await fetcher.POST('/v1/auth/new-password', {
|
||||||
|
body: {
|
||||||
|
password: validRequest.password,
|
||||||
|
token: validRequest.token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
Input,
|
||||||
|
} from '@components';
|
||||||
|
import { LuLoaderCircle } from 'react-icons/lu';
|
||||||
|
import { usePostResetPassword } from '../_hooks/use-post-reset-password';
|
||||||
|
import { useResetPasswordForm } from '../_hooks/use-reset-password-form';
|
||||||
|
import { ResetPasswordValidationSchema } from '../_validation/reset-password-validation';
|
||||||
|
|
||||||
|
export function ResetPasswordForm() {
|
||||||
|
const form = useResetPasswordForm();
|
||||||
|
const { mutate, error, isPending } = usePostResetPassword(form);
|
||||||
|
|
||||||
|
const onSubmit = (values: ResetPasswordValidationSchema) => {
|
||||||
|
mutate(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
{error && (
|
||||||
|
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm w-full">
|
||||||
|
{(error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 w-full">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>New Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="••••••••" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="confirm_password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Confirm New Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="••••••••" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isPending}>
|
||||||
|
{isPending ? (
|
||||||
|
<LuLoaderCircle className="size-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
'Ubah password'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { resetPasswordAction } from '../_actions/reset-password-actions';
|
||||||
|
import { useResetPasswordForm } from './use-reset-password-form';
|
||||||
|
|
||||||
|
export function usePostResetPassword(
|
||||||
|
form: ReturnType<typeof useResetPasswordForm>
|
||||||
|
) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: resetPasswordAction,
|
||||||
|
onSuccess: ({ message }) => {
|
||||||
|
form.reset();
|
||||||
|
router.replace('/signin');
|
||||||
|
toast.success(message);
|
||||||
|
},
|
||||||
|
onError: ({ message }) => {
|
||||||
|
form.reset();
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import {
|
||||||
|
resetPasswordValidationSchema,
|
||||||
|
ResetPasswordValidationSchema,
|
||||||
|
} from '../_validation/reset-password-validation';
|
||||||
|
|
||||||
|
export function useResetPasswordForm() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const tokenFromQuery = searchParams.get('token') ?? '';
|
||||||
|
|
||||||
|
return useForm<ResetPasswordValidationSchema>({
|
||||||
|
resolver: zodResolver(resetPasswordValidationSchema),
|
||||||
|
defaultValues: {
|
||||||
|
password: '',
|
||||||
|
confirm_password: '',
|
||||||
|
token: tokenFromQuery,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const resetPasswordValidationSchema = z
|
||||||
|
.object({
|
||||||
|
password: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Password tidak boleh kosong',
|
||||||
|
invalid_type_error: 'Password harus berupa string',
|
||||||
|
})
|
||||||
|
.min(8, 'Password harus minimal 8 karakter')
|
||||||
|
.max(50, 'Password tidak boleh lebih dari 50 karakter')
|
||||||
|
.regex(
|
||||||
|
/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*\W).+$/,
|
||||||
|
'Password harus mengandung setidaknya satu huruf kapital, satu huruf kecil, satu angka, dan satu karakter spesial'
|
||||||
|
),
|
||||||
|
confirm_password: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Konfirmasi password tidak boleh kosong',
|
||||||
|
})
|
||||||
|
.min(8, 'Konfirmasi password harus minimal 8 karakter')
|
||||||
|
.max(50, 'Konfirmasi password tidak boleh lebih dari 50 karakter'),
|
||||||
|
token: z.string(),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirm_password, {
|
||||||
|
message: 'Password dan Konfirmasi Password harus sama',
|
||||||
|
path: ['confirm_password'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ResetPasswordValidationSchema = z.infer<
|
||||||
|
typeof resetPasswordValidationSchema
|
||||||
|
>;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { LogoSimple } from '@/app/_components/logo';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { use } from 'react';
|
||||||
|
import { ResetPasswordForm } from './_components/reset-password-form';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default function Page({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ token?: string }>;
|
||||||
|
}) {
|
||||||
|
const { token } = use(searchParams);
|
||||||
|
|
||||||
|
if (!token) redirect('/signin');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<LogoSimple />
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-center text-sm sm:text-base">
|
||||||
|
Ubah passwordmu
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ResetPasswordForm />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,8 +8,6 @@ import {
|
|||||||
} from '../_validation/signin-validation';
|
} from '../_validation/signin-validation';
|
||||||
|
|
||||||
export async function SigninAction(request: SignInValidationType) {
|
export async function SigninAction(request: SignInValidationType) {
|
||||||
console.log(request);
|
|
||||||
|
|
||||||
const validRequest = signInValidationSchema.parse(request);
|
const validRequest = signInValidationSchema.parse(request);
|
||||||
|
|
||||||
const { data } = await fetchPostSignin(validRequest);
|
const { data } = await fetchPostSignin(validRequest);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { SigninAction } from '../_actions/signin-action';
|
import { SigninAction } from '../_actions/signin-action';
|
||||||
import { useFormSignin } from './use-form-signin';
|
import { useFormSignin } from './use-form-signin';
|
||||||
|
|
||||||
@@ -8,11 +9,20 @@ export function usePostSignin(form: ReturnType<typeof useFormSignin>) {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: SigninAction,
|
mutationFn: SigninAction,
|
||||||
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
router.push('/');
|
router.push('/');
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (error, variables) => {
|
||||||
form.resetField('password');
|
form.resetField('password');
|
||||||
|
|
||||||
|
if (error.message.includes('not active')) {
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push(`/verification?ref=${variables.email}`);
|
||||||
|
}, 750);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error(error.message);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
import { getRemoteIp } from '@/lib/headers';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
|
||||||
import { fetchPostSignin } from '../_http/fetch-post-signup';
|
import { fetchPostSignin } from '../_http/fetch-post-signup';
|
||||||
import { fetchPostverifyTurnstile } from '../_http/fetch-post-verify-turnstile';
|
|
||||||
import { signupValidationSchema } from '../_validation/signup-validation';
|
import { signupValidationSchema } from '../_validation/signup-validation';
|
||||||
|
|
||||||
export async function SignupAction(
|
export async function SignupAction(
|
||||||
request: z.infer<typeof signupValidationSchema>
|
request: z.infer<typeof signupValidationSchema>
|
||||||
) {
|
) {
|
||||||
const validRequest = signupValidationSchema.parse(request);
|
const validRequest = signupValidationSchema.parse(request);
|
||||||
|
const remoteIp = await getRemoteIp();
|
||||||
|
|
||||||
const isCapchaValidationValid = await fetchPostverifyTurnstile(
|
const isCapchaValid = await fetchPostverifyTurnstile(
|
||||||
validRequest.token
|
validRequest.token,
|
||||||
|
remoteIp
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isCapchaValidationValid) throw new Error('Failed to verify captcha');
|
if (!isCapchaValid) throw new Error('Failed to verify captcha');
|
||||||
|
|
||||||
const data = await fetchPostSignin(validRequest);
|
const data = await fetchPostSignin(validRequest);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { fetcher } from '@/lib/fetcher';
|
import { fetcher } from '@/lib/fetcher';
|
||||||
import { fetchPostverifyTurnstile } from '../_http/fetch-post-verify-turnstile';
|
import { getRemoteIp } from '@/lib/headers';
|
||||||
|
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
|
||||||
import {
|
import {
|
||||||
resendOTPValidationSchema,
|
resendOTPValidationSchema,
|
||||||
type ResendOTPValidationType,
|
type ResendOTPValidationType,
|
||||||
@@ -9,20 +10,22 @@ import {
|
|||||||
|
|
||||||
export async function resendOTPAction(request: ResendOTPValidationType) {
|
export async function resendOTPAction(request: ResendOTPValidationType) {
|
||||||
const validRequest = resendOTPValidationSchema.parse(request);
|
const validRequest = resendOTPValidationSchema.parse(request);
|
||||||
|
const remoteIp = await getRemoteIp();
|
||||||
|
|
||||||
const isCapchaValidationValid = await fetchPostverifyTurnstile(
|
const isCapchaValid = await fetchPostverifyTurnstile(
|
||||||
validRequest.token
|
validRequest.token,
|
||||||
|
remoteIp
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isCapchaValidationValid) throw new Error('Failed to verify captcha');
|
if (!isCapchaValid) throw new Error('Failed to verify captcha');
|
||||||
|
|
||||||
const { data } = await fetcher.POST('/v1/auth/send-otp', {
|
const { data, error } = await fetcher.POST('/v1/auth/send-otp', {
|
||||||
body: {
|
body: {
|
||||||
email: validRequest.email,
|
email: validRequest.email,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(data);
|
if (error) throw new Error(error.message);
|
||||||
|
|
||||||
return data?.message;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function VerificationTabs() {
|
|||||||
)}
|
)}
|
||||||
onClick={() => setActiveTab('resend')}
|
onClick={() => setActiveTab('resend')}
|
||||||
>
|
>
|
||||||
Kirim Ulang OTP
|
Resend OTP
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { useRouter } from 'next/navigation';
|
import { toast } from 'sonner';
|
||||||
import { resendOTPAction } from '../_actions/resend-otp-action';
|
import { resendOTPAction } from '../_actions/resend-otp-action';
|
||||||
import { useFormResendOTP } from './use-form-resend-otp';
|
import { useFormResendOTP } from './use-form-resend-otp';
|
||||||
|
|
||||||
export function usePostResendOTP(form: ReturnType<typeof useFormResendOTP>) {
|
export function usePostResendOTP(form: ReturnType<typeof useFormResendOTP>) {
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: resendOTPAction,
|
mutationFn: resendOTPAction,
|
||||||
onSuccess: () => router.replace('/verification?success=true'),
|
onSuccess: ({ message }) => {
|
||||||
|
form.reset();
|
||||||
|
toast.success(message);
|
||||||
|
},
|
||||||
|
onError: ({ message }) => {
|
||||||
|
form.reset();
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,3 +1,4 @@
|
|||||||
|
import { Suspense } from 'react';
|
||||||
import { LogoSimple } from '../../_components/logo';
|
import { LogoSimple } from '../../_components/logo';
|
||||||
import { VerificationTabs } from './_components/verification-tabs';
|
import { VerificationTabs } from './_components/verification-tabs';
|
||||||
|
|
||||||
@@ -12,7 +13,9 @@ export default function Page() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VerificationTabs />
|
<Suspense>
|
||||||
|
<VerificationTabs />
|
||||||
|
</Suspense>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Button } from '@components';
|
|
||||||
import { motion, useInView } from 'framer-motion';
|
|
||||||
import { useRef } from 'react';
|
|
||||||
|
|
||||||
export function CallToAction() {
|
|
||||||
const ref = useRef(null);
|
|
||||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="w-full py-20 md:py-32 relative overflow-hidden">
|
|
||||||
{/* Background Elements */}
|
|
||||||
<div className="absolute inset-0 -z-10">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-background to-primary/20" />
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(59,130,246,0.2),transparent_70%)]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="container px-4 md:px-6" ref={ref}>
|
|
||||||
<motion.div
|
|
||||||
className="max-w-4xl mx-auto rounded-2xl overflow-hidden border shadow-lg"
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
>
|
|
||||||
<div className="relative p-8 md:p-12 lg:p-16 bg-background">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-blue-400/5" />
|
|
||||||
|
|
||||||
<div className="relative z-10 text-center">
|
|
||||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-6">
|
|
||||||
Siap Menjadi{' '}
|
|
||||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
|
||||||
Programmer Handal?
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg text-muted-foreground mb-8 max-w-2xl mx-auto">
|
|
||||||
Bergabunglah dengan komunitas IMPHNEN sekarang dan mulai
|
|
||||||
perjalanan programming mu dengan cara yang menyenangkan!
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
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={() =>
|
|
||||||
window.open('https://discord.gg/imphnen', '_blank')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Gabung Discord
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
variant="outline"
|
|
||||||
className="border-primary hover:bg-primary/10"
|
|
||||||
onClick={() =>
|
|
||||||
window.open(
|
|
||||||
'https://facebook.com/groups/programmerhandal',
|
|
||||||
'_blank'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Join Facebook Group
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Decorative Elements */}
|
|
||||||
<div className="absolute -top-12 -left-12 w-24 h-24 rounded-full bg-primary/10 blur-2xl" />
|
|
||||||
<div className="absolute -bottom-12 -right-12 w-24 h-24 rounded-full bg-blue-400/10 blur-2xl" />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import COMMUNITIES_STATS from '@/data/communities-stats.json';
|
|
||||||
import COMMUNITIES from '@/data/communities.json';
|
|
||||||
import { Button } from '@components';
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import { motion, useInView } from 'framer-motion';
|
|
||||||
import { useRef } from 'react';
|
|
||||||
|
|
||||||
export function Community() {
|
|
||||||
const ref = useRef(null);
|
|
||||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
|
||||||
|
|
||||||
const containerVariants = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemVariants = {
|
|
||||||
hidden: { y: 20, opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
y: 0,
|
|
||||||
opacity: 1,
|
|
||||||
transition: { duration: 0.5 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
id="komunitas"
|
|
||||||
className="w-full py-20 md:py-32 bg-muted relative overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 -z-10">
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(59,130,246,0.1),transparent_70%)]" />
|
|
||||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(59,130,246,0.01)_1px,transparent_1px),linear-gradient(to_bottom,rgba(59,130,246,0.01)_1px,transparent_1px)] bg-[size:14px_14px]" />
|
|
||||||
</div>
|
|
||||||
<div className="container px-4 md:px-6" ref={ref}>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
>
|
|
||||||
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl text-center mb-4">
|
|
||||||
Komunitas{' '}
|
|
||||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
|
||||||
Kami
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
<p className="max-w-[800px] mx-auto text-muted-foreground text-center md:text-lg">
|
|
||||||
Bergabunglah dengan ribuan programmer Indonesia yang saling membantu
|
|
||||||
dan berbagi pengalaman.
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
className="grid gap-8 md:grid-cols-3 mt-12"
|
|
||||||
variants={containerVariants}
|
|
||||||
initial="hidden"
|
|
||||||
animate={isInView ? 'visible' : 'hidden'}
|
|
||||||
>
|
|
||||||
{COMMUNITIES.map((c, i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
className="group relative overflow-hidden rounded-xl border bg-background p-6 transition-all hover:shadow-xl"
|
|
||||||
variants={itemVariants}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-blue-400/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
||||||
<div className="relative z-10">
|
|
||||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
|
|
||||||
<Icon
|
|
||||||
icon={c.iconName}
|
|
||||||
className="h-6 w-6 text-blue-600 dark:text-blue-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h3 className="mb-2 text-xl font-bold">{c.title}</h3>
|
|
||||||
<p className="mb-6 text-muted-foreground">{c.description}</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full group-hover:bg-primary group-hover:text-primary-foreground transition-colors duration-300"
|
|
||||||
onClick={() => window.open(c.buttonLink, '_blank')}
|
|
||||||
>
|
|
||||||
{c.buttonText}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="absolute -bottom-1 -right-1 w-20 h-20 bg-gradient-to-tl from-primary/20 to-transparent rounded-tl-full opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
className="mt-20 grid grid-cols-2 md:grid-cols-4 gap-8 text-center"
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.5, delay: 0.3 }}
|
|
||||||
>
|
|
||||||
{COMMUNITIES_STATS.map((s, i) => (
|
|
||||||
<div key={i} className="space-y-2">
|
|
||||||
<div className="text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
|
||||||
{s.value}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">{s.label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import FEATURES from '@/data/features.json';
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import { motion, useInView } from 'framer-motion';
|
|
||||||
import { useRef } from 'react';
|
|
||||||
|
|
||||||
export function Features() {
|
|
||||||
const ref = useRef(null);
|
|
||||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
|
||||||
|
|
||||||
const containerVariants = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemVariants = {
|
|
||||||
hidden: { y: 20, opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
y: 0,
|
|
||||||
opacity: 1,
|
|
||||||
transition: { duration: 0.5 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
id="fitur"
|
|
||||||
className="w-full py-20 md:py-32 relative overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 -z-10">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-background via-background to-muted/50" />
|
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] rounded-full bg-gradient-to-tr from-primary/5 to-blue-400/5 blur-3xl" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="container px-4 md:px-6" ref={ref}>
|
|
||||||
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
|
|
||||||
<motion.div
|
|
||||||
className="space-y-2"
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
>
|
|
||||||
<div className="inline-block rounded-full bg-primary/10 px-4 py-1.5 text-sm font-medium text-primary">
|
|
||||||
Fitur Unggulan
|
|
||||||
</div>
|
|
||||||
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl">
|
|
||||||
Belajar programming dengan cara yang lebih baik
|
|
||||||
</h2>
|
|
||||||
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg">
|
|
||||||
IMPHNEN hadir dengan berbagai fitur untuk membantu kamu menjadi
|
|
||||||
programmer handal tanpa harus pusing dengan coding.
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="grid gap-8 md:grid-cols-2 lg:grid-cols-4"
|
|
||||||
variants={containerVariants}
|
|
||||||
initial="hidden"
|
|
||||||
animate={isInView ? 'visible' : 'hidden'}
|
|
||||||
>
|
|
||||||
{FEATURES.map((feature, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={index}
|
|
||||||
className="group relative overflow-hidden rounded-xl border bg-background/50 backdrop-blur-sm p-6 transition-all hover:shadow-md hover:shadow-primary/5 hover:border-primary/50"
|
|
||||||
variants={itemVariants}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-blue-400/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
||||||
|
|
||||||
<div className="relative z-10">
|
|
||||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 group-hover:bg-primary/20 transition-colors">
|
|
||||||
<Icon
|
|
||||||
icon={feature.iconName}
|
|
||||||
className="h-6 w-6 text-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h3 className="mb-2 text-xl font-bold">{feature.title}</h3>
|
|
||||||
<p className="text-muted-foreground">{feature.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute bottom-0 left-0 h-1 w-0 bg-gradient-to-r from-primary to-blue-400 group-hover:w-full transition-all duration-300" />
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import HERO_STATS from '@/data/hero-stats.json';
|
|
||||||
import { Button, SparklesIcon } from '@components';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { Fragment, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
export function Hero() {
|
|
||||||
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">
|
|
||||||
<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 }}
|
|
||||||
>
|
|
||||||
<div className="inline-flex items-center rounded-full border px-3 py-1 text-sm w-fit">
|
|
||||||
<SparklesIcon className="mr-1 h-3.5 w-3.5 text-primary" />
|
|
||||||
<span>Komunitas Programmer Indonesia</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
Programmer Handal, <br />
|
|
||||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
|
||||||
Tanpa Ribet
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-[600px] text-muted-foreground md:text-xl">
|
|
||||||
Temukan potensi programming Anda bersama komunitas yang
|
|
||||||
mendukung, tutorial interaktif, dan sumber daya berkualitas
|
|
||||||
tinggi.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
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={() =>
|
|
||||||
window.open(
|
|
||||||
'https://facebook.com/groups/programmerhandal',
|
|
||||||
'_blank'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Mulai Belajar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full sm:w-auto group relative overflow-hidden border-primary"
|
|
||||||
onClick={() =>
|
|
||||||
window.open('https://discord.com/invite/imphnen', '_blank')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<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">Gabung Discord</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-transparent bg-gradient-to-r from-primary to-blue-400">
|
|
||||||
{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,141 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import LEARNING_RESOURCES from '@/data/learning-resources.json';
|
|
||||||
import { Button } from '@components';
|
|
||||||
import { Icon } from '@iconify/react';
|
|
||||||
import { motion, useInView } from 'framer-motion';
|
|
||||||
import { useRef } from 'react';
|
|
||||||
|
|
||||||
export function LearningResources() {
|
|
||||||
const ref = useRef(null);
|
|
||||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
|
||||||
|
|
||||||
const containerVariants = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemVariants = {
|
|
||||||
hidden: { y: 20, opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
y: 0,
|
|
||||||
opacity: 1,
|
|
||||||
transition: { duration: 0.5 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
id="sumber-belajar"
|
|
||||||
className="w-full py-20 md:py-32 relative overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 -z-10">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-background via-background to-background" />
|
|
||||||
<div className="absolute top-0 right-0 w-1/2 h-1/2 bg-gradient-to-bl from-primary/5 to-transparent blur-3xl" />
|
|
||||||
<div className="absolute bottom-0 left-0 w-1/2 h-1/2 bg-gradient-to-tr from-blue-400/5 to-transparent blur-3xl" />
|
|
||||||
</div>
|
|
||||||
<div className="container px-4 md:px-6" 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.5 }}
|
|
||||||
>
|
|
||||||
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl">
|
|
||||||
Sumber Belajar
|
|
||||||
</h2>
|
|
||||||
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg">
|
|
||||||
Akses berbagai materi belajar yang akan membantu kamu menguasai
|
|
||||||
konsep programming dengan cara yang menyenangkan.
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
<motion.div
|
|
||||||
className="grid gap-8 md:grid-cols-2 lg:grid-cols-4"
|
|
||||||
variants={containerVariants}
|
|
||||||
initial="hidden"
|
|
||||||
animate={isInView ? 'visible' : 'hidden'}
|
|
||||||
>
|
|
||||||
{LEARNING_RESOURCES.map((r, i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
className="group relative overflow-hidden rounded-xl border bg-background p-6 hover:shadow-lg"
|
|
||||||
variants={itemVariants}
|
|
||||||
>
|
|
||||||
<div className="absolute top-0 left-0 h-1 w-full bg-gradient-to-r from-primary/50 to-blue-400/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
|
||||||
<div className="relative z-10">
|
|
||||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
|
|
||||||
<Icon
|
|
||||||
icon={r.icon}
|
|
||||||
className="h-6 w-6 text-blue-600 dark:text-blue-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h3 className="mb-2 text-xl font-bold">{r.title}</h3>
|
|
||||||
<p className="mb-6 text-muted-foreground">{r.description}</p>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
className="p-0 h-auto font-medium text-primary hover:text-primary/80"
|
|
||||||
onClick={() => window.open(r.buttonLink, '_blank')}
|
|
||||||
>
|
|
||||||
{r.buttonText}
|
|
||||||
<Icon icon="tabler:arrow-right" className="h-6 w-6" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="absolute -bottom-32 -right-32 w-64 h-64 bg-gradient-to-tl from-primary/10 to-transparent rounded-full opacity-0 group-hover:opacity-100 transition-all duration-500 group-hover:-translate-y-10 group-hover:-translate-x-10" />
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
className="mt-20 rounded-xl overflow-hidden border bg-background/50 backdrop-blur-sm"
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.5, delay: 0.3 }}
|
|
||||||
>
|
|
||||||
<div className="grid md:grid-cols-2 gap-0">
|
|
||||||
<div className="p-8 md:p-12 flex flex-col justify-center">
|
|
||||||
<div className="inline-block rounded-full bg-primary/10 px-4 py-1.5 text-sm font-medium text-primary mb-4 w-fit">
|
|
||||||
Rekomendasi Terbaik
|
|
||||||
</div>
|
|
||||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">
|
|
||||||
Kursus Lengkap Web Development
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground mb-6">
|
|
||||||
Pelajari HTML, CSS, JavaScript, React, dan Node.js dalam satu
|
|
||||||
kursus komprehensif yang dirancang untuk pemula hingga tingkat
|
|
||||||
menengah.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-4">
|
|
||||||
<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={() => window.open('/', '_blank')}
|
|
||||||
>
|
|
||||||
Mulai Kursus
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => window.open('/', '_blank')}
|
|
||||||
>
|
|
||||||
Lihat Silabus
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative h-64 md:h-auto">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-blue-400/20" />
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<div className="w-16 h-16 rounded-full bg-background/80 backdrop-blur-sm flex items-center justify-center cursor-pointer hover:bg-background transition-colors">
|
|
||||||
<div className="w-0 h-0 border-t-8 border-t-transparent border-l-12 border-l-primary border-b-8 border-b-transparent ml-1" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import TESTIMONIAL_STATS from '@/data/testimonial-stats.json';
|
|
||||||
import TESTIMONIALS from '@/data/testimonials.json';
|
|
||||||
import { QuoteIcon } from '@components';
|
|
||||||
import { motion, useInView } from 'framer-motion';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { useRef } from 'react';
|
|
||||||
|
|
||||||
export function Testimonials() {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
|
||||||
|
|
||||||
const containerVariants = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: { opacity: 1, transition: { staggerChildren: 0.1 } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemVariants = {
|
|
||||||
hidden: { y: 20, opacity: 0 },
|
|
||||||
visible: { y: 0, opacity: 1, transition: { duration: 0.5 } },
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
id="testimoni"
|
|
||||||
className="w-full py-20 md:py-32 bg-muted relative overflow-hidden"
|
|
||||||
ref={ref}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 -z-10">
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.1),transparent_50%)]" />
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom,rgba(96,165,250,0.1),transparent_50%)]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="container px-4 md:px-6">
|
|
||||||
<motion.div
|
|
||||||
className="flex flex-col items-center justify-center space-y-4 text-center mb-16"
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
>
|
|
||||||
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl lg:text-5xl">
|
|
||||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
|
||||||
Testimoni
|
|
||||||
</span>
|
|
||||||
<span> Member</span>
|
|
||||||
</h2>
|
|
||||||
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg">
|
|
||||||
Apa kata mereka yang telah bergabung dengan komunitas IMPHNEN?
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="grid gap-8 md:grid-cols-3"
|
|
||||||
variants={containerVariants}
|
|
||||||
initial="hidden"
|
|
||||||
animate={isInView ? 'visible' : 'hidden'}
|
|
||||||
>
|
|
||||||
{TESTIMONIALS.map((t, idx) => (
|
|
||||||
<motion.div
|
|
||||||
key={idx}
|
|
||||||
className="group relative overflow-hidden rounded-xl border bg-background p-6 transition-all hover:shadow-lg"
|
|
||||||
variants={itemVariants}
|
|
||||||
>
|
|
||||||
<div className="absolute top-6 right-6 text-primary/20 group-hover:text-primary/40 transition-colors">
|
|
||||||
<QuoteIcon className="h-8 w-8" />
|
|
||||||
</div>
|
|
||||||
<div className="relative z-10">
|
|
||||||
<p className="mb-6 text-muted-foreground italic">
|
|
||||||
“{t.quote}”
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="relative h-12 w-12 overflow-hidden rounded-full border-2 border-primary/20">
|
|
||||||
<Image
|
|
||||||
src={t.avatar}
|
|
||||||
alt={t.name}
|
|
||||||
width={600}
|
|
||||||
height={500}
|
|
||||||
className="object-cover"
|
|
||||||
unoptimized
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-bold">{t.name}</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">{t.role}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute -bottom-1 -left-1 w-20 h-20 bg-gradient-to-tr from-primary/10 to-transparent rounded-tr-full opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="mt-20 rounded-xl overflow-hidden border bg-background/50 backdrop-blur-sm p-8 md:p-12"
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.5, delay: 0.3 }}
|
|
||||||
>
|
|
||||||
<div className="grid md:grid-cols-2 gap-8 items-center">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">
|
|
||||||
Bergabunglah dengan 10,000+ programmer Indonesia lainnya
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Komunitas kami terus berkembang dengan programmer dari berbagai
|
|
||||||
latar belakang dan tingkat keahlian. Bersama-sama, kita belajar,
|
|
||||||
berbagi, dan tumbuh sebagai profesional.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
{TESTIMONIAL_STATS.map((s, idx) => (
|
|
||||||
<div key={idx} className="rounded-lg border p-4 text-center">
|
|
||||||
<div className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
|
||||||
{s.value}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">{s.label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,13 @@ import { LogoSimple } from '@/app/_components/logo';
|
|||||||
import NAVIGATIONS from '@/data/navigations.json';
|
import NAVIGATIONS from '@/data/navigations.json';
|
||||||
import SOCIALS from '@/data/socials.json';
|
import SOCIALS from '@/data/socials.json';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
FaDiscord,
|
||||||
|
FaFacebook,
|
||||||
|
FaInstagram,
|
||||||
|
FaLinkedinIn,
|
||||||
|
FaTiktok,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
return (
|
return (
|
||||||
@@ -20,57 +27,31 @@ export default function Footer() {
|
|||||||
href="#"
|
href="#"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<svg
|
<FaFacebook className="size-6" />
|
||||||
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>
|
||||||
<Link
|
<Link
|
||||||
href="#"
|
href="#"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<svg
|
<FaDiscord className="size-6" />
|
||||||
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>
|
||||||
<Link
|
<Link
|
||||||
href="#"
|
href="#"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<svg
|
<FaInstagram className="size-6" />
|
||||||
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>
|
||||||
<Link
|
<Link
|
||||||
href="#"
|
href="#"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<svg
|
<FaTiktok className="size-6" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
</Link>
|
||||||
width="20"
|
<Link
|
||||||
height="20"
|
href="#"
|
||||||
fill="currentColor"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
viewBox="0 0 24 24"
|
>
|
||||||
>
|
<FaLinkedinIn className="size-6" />
|
||||||
<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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,7 +86,7 @@ export default function Footer() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-bold">Community Patners</h3>
|
<h3 className="text-lg font-bold">Patners</h3>
|
||||||
<ul className="space-y-2"></ul>
|
<ul className="space-y-2"></ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
[
|
|
||||||
{ "value": "100K+", "label": "Member Aktif" },
|
|
||||||
{ "value": "50+", "label": "Event Bulanan" },
|
|
||||||
{ "value": "100+", "label": "Mentor Profesional" },
|
|
||||||
{ "value": "5K+", "label": "Diskusi Mingguan" }
|
|
||||||
]
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"iconName": "tabler:brand-facebook",
|
|
||||||
"title": "Facebook Group",
|
|
||||||
"description": "Bergabunglah dengan grup Facebook kami untuk diskusi santai dan berbagi artikel menarik.",
|
|
||||||
"buttonText": "Gabung Sekarang",
|
|
||||||
"buttonLink": "https://facebook.com/groups/programmerhandal"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iconName": "tabler:brand-instagram",
|
|
||||||
"title": "Instagram",
|
|
||||||
"description": "Ikuti kami di Instagram untuk tips programming, konten inspiratif, dan info event terbaru.",
|
|
||||||
"buttonText": "Follow Kami",
|
|
||||||
"buttonLink": "https://www.instagram.com/imphnen.dev"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iconName": "tabler:brand-discord-filled",
|
|
||||||
"title": "Discord Server",
|
|
||||||
"description": "Diskusikan langsung dengan sesama programmer dan dapatkan bantuan langsung dari para ahli.",
|
|
||||||
"buttonText": "Join Server",
|
|
||||||
"buttonLink": "https://discord.com/invite/imphnen"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"iconName": "tabler:device-laptop",
|
|
||||||
"title": "Belajar Tanpa Koding",
|
|
||||||
"description": "Pelajari konsep programming dengan cara yang mudah dipahami tanpa harus menulis kode yang rumit."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iconName": "tabler:users",
|
|
||||||
"title": "Komunitas Supportif",
|
|
||||||
"description": "Bergabunglah dengan komunitas programmer Indonesia yang siap membantu dan berbagi pengalaman."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iconName": "tabler:book",
|
|
||||||
"title": "Tutorial Interaktif",
|
|
||||||
"description": "Akses tutorial interaktif yang membuat konsep programming lebih mudah untuk dipahami."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iconName": "tabler:code",
|
|
||||||
"title": "Proyek Praktis",
|
|
||||||
"description": "Terapkan pengetahuan Anda dalam proyek nyata dengan panduan langkah demi langkah."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"icon": "tabler:video",
|
|
||||||
"title": "Video Tutorial",
|
|
||||||
"description": "Belajar melalui tutorial video dari langkah awal hingga mahir.",
|
|
||||||
"buttonText": "Lihat Semua Video",
|
|
||||||
"buttonLink": "#"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"icon": "tabler:article",
|
|
||||||
"title": "Artikel & Tutorial",
|
|
||||||
"description": "Pelajari konsep programming melalui artikel yang disusun secara terstruktur.",
|
|
||||||
"buttonText": "Baca Artikel",
|
|
||||||
"buttonLink": "#"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"icon": "tabler:brand-vscode",
|
|
||||||
"title": "Tantangan Koding",
|
|
||||||
"description": "Uji kemampuan koding kamu dengan tantangan yang menyenangkan dan menantang.",
|
|
||||||
"buttonText": "Mulai Tantangan",
|
|
||||||
"buttonLink": "#"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"icon": "tabler:device-desktop-share",
|
|
||||||
"title": "Sharing Session",
|
|
||||||
"description": "Ikuti sesi berbagi pengalaman dari programmer berpengalaman dan belajar dari pengalaman mereka.",
|
|
||||||
"buttonText": "Jadwal Session",
|
|
||||||
"buttonLink": "#"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -10,5 +10,13 @@
|
|||||||
{
|
{
|
||||||
"title": "Testimoni",
|
"title": "Testimoni",
|
||||||
"link": "/testimonials"
|
"link": "/testimonials"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Roadmap",
|
||||||
|
"link": "/roadmaps"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Artikel",
|
||||||
|
"link": "/articles"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
[
|
|
||||||
{ "value": "98%", "label": "Tingkat Kemalasan" },
|
|
||||||
{ "value": "4.9/5", "label": "Rating Drama" },
|
|
||||||
{ "value": "85%", "label": "Mendapat Pekerjaan" },
|
|
||||||
{ "value": "24/7", "label": "Yapping" }
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
|
export async function getRemoteIp() {
|
||||||
|
const hdrs = await headers();
|
||||||
|
const xff = hdrs.get('x-forwarded-for');
|
||||||
|
if (!xff) return undefined;
|
||||||
|
|
||||||
|
// 'x-forwarded-for' can be a comma-separated list of IPs
|
||||||
|
const ips = xff.split(',').map((ip) => ip.trim());
|
||||||
|
return ips[0] || undefined;
|
||||||
|
}
|
||||||
Generated
+51
-4
@@ -13,6 +13,7 @@
|
|||||||
"@hookform/resolvers": "^5.0.1",
|
"@hookform/resolvers": "^5.0.1",
|
||||||
"@iconify/react": "^6.0.0",
|
"@iconify/react": "^6.0.0",
|
||||||
"@marsidev/react-turnstile": "^1.1.0",
|
"@marsidev/react-turnstile": "^1.1.0",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.0",
|
"@radix-ui/react-slot": "^1.2.0",
|
||||||
"@redocly/ajv": "^8.11.2",
|
"@redocly/ajv": "^8.11.2",
|
||||||
"@tanstack/react-query": "^5.74.4",
|
"@tanstack/react-query": "^5.74.4",
|
||||||
@@ -7605,10 +7606,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-label": {
|
||||||
|
"version": "2.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
|
||||||
|
"integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-slot": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
"integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-compose-refs": "1.1.2"
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
@@ -10946,7 +10993,7 @@
|
|||||||
"version": "19.1.2",
|
"version": "19.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz",
|
||||||
"integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==",
|
"integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.0.0"
|
"@types/react": "^19.0.0"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"@hookform/resolvers": "^5.0.1",
|
"@hookform/resolvers": "^5.0.1",
|
||||||
"@iconify/react": "^6.0.0",
|
"@iconify/react": "^6.0.0",
|
||||||
"@marsidev/react-turnstile": "^1.1.0",
|
"@marsidev/react-turnstile": "^1.1.0",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.0",
|
"@radix-ui/react-slot": "^1.2.0",
|
||||||
"@redocly/ajv": "^8.11.2",
|
"@redocly/ajv": "^8.11.2",
|
||||||
"@tanstack/react-query": "^5.74.4",
|
"@tanstack/react-query": "^5.74.4",
|
||||||
|
|||||||
Reference in New Issue
Block a user