From b655de92c03ac4d6ec71844d6440916e3bae2707 Mon Sep 17 00:00:00 2001 From: arraysid Date: Tue, 27 May 2025 16:22:24 +0700 Subject: [PATCH] feat: implement verification page --- .../_actions/resend-otp-action.ts | 28 +++++++ .../_actions/verify-email-action.ts | 15 ++++ .../_components/resend-otp-form.tsx | 74 ++++++++++++++++++ .../_components/verification-tabs.tsx | 41 ++++++++++ .../_components/verify-email-form.tsx | 77 +++++++++++++++++++ .../_hooks/use-form-resend-otp.ts | 20 +++++ .../_hooks/use-form-verify-email.ts | 20 +++++ .../_hooks/use-post-resend-otp.ts | 13 ++++ .../_hooks/use-post-verify-email.ts | 20 +++++ .../_http/fetch-post-verify-email.ts | 31 ++++++++ .../_http/fetch-post-verify-turnstile.ts | 41 ++++++++++ .../_validation/resend-otp-validation.ts | 7 ++ .../_validation/verify-email-validation.ts | 12 +++ .../src/app/(auth)/verification/page.tsx | 18 +++++ 14 files changed, 417 insertions(+) create mode 100644 apps/landing/src/app/(auth)/verification/_actions/resend-otp-action.ts create mode 100644 apps/landing/src/app/(auth)/verification/_actions/verify-email-action.ts create mode 100644 apps/landing/src/app/(auth)/verification/_components/resend-otp-form.tsx create mode 100644 apps/landing/src/app/(auth)/verification/_components/verification-tabs.tsx create mode 100644 apps/landing/src/app/(auth)/verification/_components/verify-email-form.tsx create mode 100644 apps/landing/src/app/(auth)/verification/_hooks/use-form-resend-otp.ts create mode 100644 apps/landing/src/app/(auth)/verification/_hooks/use-form-verify-email.ts create mode 100644 apps/landing/src/app/(auth)/verification/_hooks/use-post-resend-otp.ts create mode 100644 apps/landing/src/app/(auth)/verification/_hooks/use-post-verify-email.ts create mode 100644 apps/landing/src/app/(auth)/verification/_http/fetch-post-verify-email.ts create mode 100644 apps/landing/src/app/(auth)/verification/_http/fetch-post-verify-turnstile.ts create mode 100644 apps/landing/src/app/(auth)/verification/_validation/resend-otp-validation.ts create mode 100644 apps/landing/src/app/(auth)/verification/_validation/verify-email-validation.ts create mode 100644 apps/landing/src/app/(auth)/verification/page.tsx diff --git a/apps/landing/src/app/(auth)/verification/_actions/resend-otp-action.ts b/apps/landing/src/app/(auth)/verification/_actions/resend-otp-action.ts new file mode 100644 index 0000000..1364109 --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_actions/resend-otp-action.ts @@ -0,0 +1,28 @@ +'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; +} diff --git a/apps/landing/src/app/(auth)/verification/_actions/verify-email-action.ts b/apps/landing/src/app/(auth)/verification/_actions/verify-email-action.ts new file mode 100644 index 0000000..8e18021 --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_actions/verify-email-action.ts @@ -0,0 +1,15 @@ +'use server'; + +import { fetchPostVerifyEmail } from '../_http/fetch-post-verify-email'; +import { + type VerifyEmailValidationType, + verifyEmailValidationSchema, +} from '../_validation/verify-email-validation'; + +export async function verifyEmailAction(request: VerifyEmailValidationType) { + const validRequest = verifyEmailValidationSchema.parse(request); + + const data = await fetchPostVerifyEmail(validRequest); + + return data; +} diff --git a/apps/landing/src/app/(auth)/verification/_components/resend-otp-form.tsx b/apps/landing/src/app/(auth)/verification/_components/resend-otp-form.tsx new file mode 100644 index 0000000..7731608 --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_components/resend-otp-form.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { + Button, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from '@components'; +import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile'; +import { useRef } from 'react'; +import { useFormResendOTP } from '../_hooks/use-form-resend-otp'; +import { usePostResendOTP } from '../_hooks/use-post-resend-otp'; +import { ResendOTPValidationType } from '../_validation/resend-otp-validation'; + +export function ResendOTPForm() { + const ref = useRef(null); + + const form = useFormResendOTP(); + + const { mutate, isPending, error } = usePostResendOTP(form); + + const onSubmit = (values: ResendOTPValidationType) => { + mutate(values); + }; + + return ( +
+ + {error && ( +
+ {(error as Error).message} +
+ )} + + ( + + Email + + + + + + )} + /> + + form.setValue('token', token)} + options={{ + theme: 'light', + size: 'flexible', + language: 'id', + }} + /> + + + + + ); +} diff --git a/apps/landing/src/app/(auth)/verification/_components/verification-tabs.tsx b/apps/landing/src/app/(auth)/verification/_components/verification-tabs.tsx new file mode 100644 index 0000000..39d8bba --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_components/verification-tabs.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { cn } from '@utils'; +import { useState } from 'react'; +import { ResendOTPForm } from './resend-otp-form'; +import { VerifyEmailForm } from './verify-email-form'; + +export function VerificationTabs() { + const [activeTab, setActiveTab] = useState<'form' | 'resend'>('form'); + + return ( + <> +
+ + +
+ + {activeTab === 'form' ? : } + + ); +} diff --git a/apps/landing/src/app/(auth)/verification/_components/verify-email-form.tsx b/apps/landing/src/app/(auth)/verification/_components/verify-email-form.tsx new file mode 100644 index 0000000..895bb10 --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_components/verify-email-form.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { + Button, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from '@components'; +import { LuLoader } from 'react-icons/lu'; +import { useFormVerifyEmail } from '../_hooks/use-form-verify-email'; +import { usePostVerifyEmail } from '../_hooks/use-post-verify-email'; +import { type VerifyEmailValidationType } from '../_validation/verify-email-validation'; + +export function VerifyEmailForm() { + const form = useFormVerifyEmail(); + const { mutate, isPending, error } = usePostVerifyEmail(form); + + const onSubmit = (values: VerifyEmailValidationType) => { + mutate(values); + }; + + return ( +
+ + {error && ( +
+ {(error as Error).message} +
+ )} + + ( + + Email + + + + + + )} + /> + + ( + + OTP + + + + + + )} + /> + + + + + ); +} diff --git a/apps/landing/src/app/(auth)/verification/_hooks/use-form-resend-otp.ts b/apps/landing/src/app/(auth)/verification/_hooks/use-form-resend-otp.ts new file mode 100644 index 0000000..8578cda --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_hooks/use-form-resend-otp.ts @@ -0,0 +1,20 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useSearchParams } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { + resendOTPValidationSchema, + type ResendOTPValidationType, +} from '../_validation/resend-otp-validation'; + +export function useFormResendOTP() { + const searchParams = useSearchParams(); + const email = searchParams.get('ref'); + + return useForm({ + resolver: zodResolver(resendOTPValidationSchema), + defaultValues: { + email: email ?? '', + token: '', + }, + }); +} diff --git a/apps/landing/src/app/(auth)/verification/_hooks/use-form-verify-email.ts b/apps/landing/src/app/(auth)/verification/_hooks/use-form-verify-email.ts new file mode 100644 index 0000000..859a6a8 --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_hooks/use-form-verify-email.ts @@ -0,0 +1,20 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useSearchParams } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { + verifyEmailValidationSchema, + type VerifyEmailValidationType, +} from '../_validation/verify-email-validation'; + +export function useFormVerifyEmail() { + const searchParams = useSearchParams(); + const email = searchParams.get('ref'); + + return useForm({ + resolver: zodResolver(verifyEmailValidationSchema), + defaultValues: { + email: email ?? '', + otp: '', + }, + }); +} diff --git a/apps/landing/src/app/(auth)/verification/_hooks/use-post-resend-otp.ts b/apps/landing/src/app/(auth)/verification/_hooks/use-post-resend-otp.ts new file mode 100644 index 0000000..8d50f69 --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_hooks/use-post-resend-otp.ts @@ -0,0 +1,13 @@ +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) { + const router = useRouter(); + + return useMutation({ + mutationFn: resendOTPAction, + onSuccess: () => router.replace('/verification?success=true'), + }); +} diff --git a/apps/landing/src/app/(auth)/verification/_hooks/use-post-verify-email.ts b/apps/landing/src/app/(auth)/verification/_hooks/use-post-verify-email.ts new file mode 100644 index 0000000..e7da35d --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_hooks/use-post-verify-email.ts @@ -0,0 +1,20 @@ +import { useMutation } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; +import { verifyEmailAction } from '../_actions/verify-email-action'; +import { useFormVerifyEmail } from './use-form-verify-email'; + +export function usePostVerifyEmail( + form: ReturnType +) { + const router = useRouter(); + + return useMutation({ + mutationFn: verifyEmailAction, + onSuccess: () => { + router.push('/'); + }, + onError: () => { + form.resetField('otp'); + }, + }); +} diff --git a/apps/landing/src/app/(auth)/verification/_http/fetch-post-verify-email.ts b/apps/landing/src/app/(auth)/verification/_http/fetch-post-verify-email.ts new file mode 100644 index 0000000..e0a57c8 --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_http/fetch-post-verify-email.ts @@ -0,0 +1,31 @@ +import { fetcher } from '@/lib/fetcher'; +import { VerifyEmailValidationType } from '../_validation/verify-email-validation'; + +export async function fetchPostVerifyEmail({ + email, + otp, +}: VerifyEmailValidationType) { + const formattedOTP = parseInt(otp); + + const { data, error, response } = await fetcher.POST( + '/v1/auth/verify-email', + { + body: { + email, + otp: formattedOTP, + }, + } + ); + + if (error) { + throw new Error(error.message); + } + + if (!response.ok) { + throw new Error('Something went wrong, please try again later'); + } + + console.log(data); + + return data; +} diff --git a/apps/landing/src/app/(auth)/verification/_http/fetch-post-verify-turnstile.ts b/apps/landing/src/app/(auth)/verification/_http/fetch-post-verify-turnstile.ts new file mode 100644 index 0000000..aefeb2d --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_http/fetch-post-verify-turnstile.ts @@ -0,0 +1,41 @@ +export async function fetchPostverifyTurnstile( + token: string, + remoteIp?: string +): Promise { + const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; + const params = new URLSearchParams({ + secret: String(process.env.TURNSTILE_SECRET_KEY), + response: token, + }); + if (remoteIp) { + params.append('remoteip', remoteIp); + } + + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + + if (!res.ok) { + console.error('Turnstile verify HTTP error', res.status); + return false; + } + + const data = (await res.json()) as TurnstileVerifyResponse; + if (!data.success) { + console.warn('Turnstile failure', data['error-codes']); + return false; + } + + return true; +} + +interface TurnstileVerifyResponse { + success: boolean; + challenge_ts: string; + hostname: string; + 'error-codes'?: string[]; +} diff --git a/apps/landing/src/app/(auth)/verification/_validation/resend-otp-validation.ts b/apps/landing/src/app/(auth)/verification/_validation/resend-otp-validation.ts new file mode 100644 index 0000000..2b4bc20 --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_validation/resend-otp-validation.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const resendOTPValidationSchema = z.object({ + email: z.string().email({ message: 'Format email tidak valid' }), + token: z.string().min(1, { message: 'OTP harus terdiri dari 1 digit' }), +}); +export type ResendOTPValidationType = z.infer; diff --git a/apps/landing/src/app/(auth)/verification/_validation/verify-email-validation.ts b/apps/landing/src/app/(auth)/verification/_validation/verify-email-validation.ts new file mode 100644 index 0000000..7ace2c8 --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/_validation/verify-email-validation.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const verifyEmailValidationSchema = z.object({ + email: z.string().email({ message: 'Format email tidak valid' }), + otp: z + .string() + .min(6, { message: 'OTP harus terdiri dari 6 digit' }) + .max(6, { message: 'OTP harus terdiri dari 6 digit' }), +}); +export type VerifyEmailValidationType = z.infer< + typeof verifyEmailValidationSchema +>; diff --git a/apps/landing/src/app/(auth)/verification/page.tsx b/apps/landing/src/app/(auth)/verification/page.tsx new file mode 100644 index 0000000..f77fd77 --- /dev/null +++ b/apps/landing/src/app/(auth)/verification/page.tsx @@ -0,0 +1,18 @@ +import { LogoSimple } from '../../_components/logo'; +import { VerificationTabs } from './_components/verification-tabs'; + +export default function Page() { + return ( + <> +
+ + +

+ Verifikasi akun mu sekarang +

+
+ + + + ); +}