Compare commits

...
8 Commits
Author SHA1 Message Date
euxzy b266aa8796 feat(landing): add animation on hero and what we offer section 2025-06-28 20:22:32 +07:00
euxzy 57222b655c feat(landing): slicing what we offer section 2025-06-28 15:42:20 +07:00
euxzy 72888bd090 feat(landing): slicing hero section 2025-06-28 11:05:06 +07:00
euxzy a5bde58bc8 feat(landing): slicing navigation bar 2025-06-28 08:38:01 +07:00
I Made Ocy Darma Putra 302ee37ca1 feat(docker): all projects (#36)
* fix: docker

* fix landing

* chore(landing): add outputFileTracingRoot to next config

* ci: add github workflow for landing deployment via ssh and docker compose

* chore: add docker-compose file for landing app build and run

* feat docker
2025-06-19 11:17:21 +07:00
Naufal Azmi b7282f9c99 Resend OTP (#34)
* feat: responsive

* fix: success register

* feat: auth login responsive && auth login implementation

* fix: login page button disabled & register page

* fix: conflict

* fix: button disabled when loading

* feat: register and otp integration

* Update middleware.ts

* feat: OTP Resend
2025-06-18 18:50:06 +07:00
Rasyid 2c347d1cc5 feat: add ui for roadmap ui (#35)
* fix: correct link for Roadmap navigation item

* feat: add feature request and projects voting components

* fix: correct spelling of 'Certificate' in ProjectsVote component

* feat: add Request Feature popup with responsive dialog and drawer components

* feat: enhance Header component with mobile menu animations and improved navigation styling

* refactor: simplify Header component by removing unused scroll handling and improving mobile menu structure

* fix: replace motion span with static span for header underline

* fix: update FeatureRequestCTA text for clarity and adjust page layout margins

* fix: remove DialogDescription from RequestFeaturePopup and update textarea placeholder for clarity

* fix: remove DrawerDescription from RequestFeaturePopup for cleaner UI

* fix: remove unused icon exports from icons

* fix: replace XIcon with LuX in DialogClose for consistency

* fix: remove unused icon export from index for cleaner module structure
2025-06-18 18:40:19 +07:00
Rasyid 791f43986d feat: landing auth integration & landing ui improvements (#33)
* feat: setup openapi libs for landing

* feat: seperate landing layouts

* feat: movee landing providers

* feat: add auth layout

* feat: implement cookie management for access and refresh tokens

* feat: replace Inter font with Bai Jamjuree in layout and add font utility

* feat: add signin

* feat: add Poppins font to the font utility

* feat: expand theme colors in index.css for improved styling options

* feat: update dependencies and enhance button and form components with new variants and context

* feat: move old components to __old__ dir

* feat: add react query support

* feat: hide the footer

* fix: header style

* feat: replace Image component with LogoSimple in Header and update Logo component to accept props

* fix: update label for tutorial stat in hero-stats.json

* feat: implement EventsPage component with event listing and details

* feat: add signup button and page

* feat: add HERO_CONTENT data and integrate into Hero component

* fix: update navigations import to uppercase and add missing Testimoni entry

* feat: implement testimonials display and add submission page

* fix: correct link path for testimonial submission

* feat: add communites component and integrate social links from JSON data

* fix: adjust grid gap in Communities component for better layout

* fix: update font imports in layout components for consistency

* fix: replace text logo with LogoSimple component in Footer and restore Footer in Layout

* feat: add TestimonialSection component and update testimonials data

* fix: update section ID from 'komunitas' to 'community' for consistency

* feat: add CTASection component with interactive elements and background pattern

* fix: update join URL in hero content for consistency

* fix: update text in CTASection for improved emphasis and consistency

* feat: add CommunitySection component and integrate it into the main page

* feat: update Footer component to use dynamic navigation and social links

* feat: implement HeroSection component with dynamic content and animations

* refactor: replace hardcoded testimonials with dynamic data from JSON

* feat: add signup page

* feat: integrate Turnstile captcha verification in signup process

* feat: implement verification page

* feat: add signin link to the signup page

* feat: add "Forgot Password?" link to the signin form

* refactor: clean up unused ThemeProvider code and CSS variables

* feat: add base styles for borders and background to improve UI consistency

* feat: implement forgot password functionality with validation and UI components

* fix: update password validation rules for clarity and consistency

* feat: enable rich colors for Toaster component in layout

* feat: implement reset password functionality with validation and UI components

* feat: implement Turnstile captcha verification for forgot password and signup actions

* refactor: remove console log and enhance error handling in signin and resend OTP actions

* feat: add navigation entries for Roadmap and Artikel sections

* feat: create initial Page component for articles section

* feat: update footer component with additional social media links and icons

* feat: remove all unused components in landing

* chore: add @radix-ui/react-label deps

* feat: wrap VerificationTabs in Suspense

* feat: update .env.example to include TURNSTILE_SECRET_KEY and correct NEXT_PUBLIC_TURNSTILE_SITEKEY
2025-05-30 11:57:17 +07:00
133 changed files with 7847 additions and 1197 deletions
+33
View File
@@ -0,0 +1,33 @@
name: Deploy Landing
on:
push:
paths:
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
set -e
if [ ! -d ~/imphnen-frontend-service ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
else
cd ~/imphnen-frontend-service && git pull
fi
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" > ~/imphnen-frontend-service/apps/landing/.env
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
docker image prune -af
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 351 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 511 KiB

@@ -0,0 +1,73 @@
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { motion } from 'framer-motion'
export function HeroSection() {
return (
<section className="relative w-full py-20 md:py-28 lg:py-32">
{/* Background gradients and shapes */}
<div className="absolute inset-0 -z-10 overflow-x-clip hidden md:block">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
className="absolute top-10 -left-64 size-[448px] bg-primary-200 rounded-full justify-center items-center hidden lg:flex"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.3, delay: 0.1 }}
viewport={{ once: true }}
className="size-[296px] bg-primary-50 rounded-full"
/>
</motion.div>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
className="absolute top-3/5 -right-56 size-96 bg-primary-500 rounded-full flex justify-center items-center lg:top-1/2 lg:-right-64 lg:size-[600px]"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.3, delay: 0.1 }}
viewport={{ once: true }}
className="size-64 bg-primary-50 rounded-full lg:size-[400px]"
/>
</motion.div>
<div className="absolute -top-28 -left-20 size-[248px] bg-radial from-primary-300 rounded-full blur-3xl" />
<div className="absolute top-0 -right-72 size-[480px] bg-radial from-primary-400 rounded-full blur-[80px]" />
</div>
<div className="flex flex-col items-center gap-y-10">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
className="flex flex-col items-center gap-y-6"
>
<h1 className="text-2xl font-semibold text-primary-500 md:text-4xl md:font-bold lg:text-5xl">DIMENTORIN</h1>
<p className="max-w-52 text-center font-semibold text-xl leading-tight text-primary-500 md:max-w-72 lg:text-4xl lg:max-w-lg">
Learn IT smarter with AI & expert mentors!
</p>
<p className="max-w-3xs text-xs text-neutral-500 text-center font-medium md:max-w-lg md:text-base lg:text-lg lg:max-w-[646px]">
Apakah kamu siap untuk grind skill TI-mu ke level Ultimate? Dengan AI sebagai navigator dan mentor profesional sebagai sensei-mu, perjalanan belajarmu akan lebih cepat, efektif, dan penuh EXP!
</p>
</motion.div>
<motion.div initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: 0.2 }} viewport={{ once: true }}>
<Button
type="button"
size="sm"
className="text-[10px] h-auto px-2.5 py-2 md:text-sm"
>
Belajar Dengan AI Sekarang
</Button>
</motion.div>
</div>
</section>
)
}
@@ -0,0 +1,53 @@
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { FC } from "react";
import { motion } from 'framer-motion'
import { For } from "../../../_components/logic/for";
const OFFERS: { title: string; description: string; image: string }[] = [
{ title: "AI-Powered Learning", description: "Level Up Instantly!", image: "/image/what-we-offer/ai-powered-learning.webp" },
{ title: "1-on-1 Mentoring", description: "Sensei dari Dunia Nyata!", image: "/image/what-we-offer/1-on-1-mentoring.webp" },
{ title: "Community & Resources", description: "Connect dengan Fellow Devs!", image: "/image/what-we-offer/community.webp" },
]
export const WhatWeOfferSection: FC = () => {
return (
<section className="w-full mx-auto px-8 pb-4 md:px-[60px] lg:px-20 lg:pt-4">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="mb-6 flex justify-center lg:mb-10"
>
<Button
type="button"
size="sm"
variant="bordered"
className="h-auto px-3 py-2 font-semibold bg-primary-100 md:text-base md:px-5 md:py-2.5 lg:text-2xl lg:px-6 lg:py-3"
>
Apa yang Kami Tawarkan
</Button>
</motion.div>
<div className="grid gap-6 max-w-7xl mx-auto md:grid-cols-3">
<For data={OFFERS}>
{(offer) => (
<motion.div
className="relative aspect-[3/2] overflow-hidden rounded-2xl md:aspect-[3/4] lg:aspect-[5/6]"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<img src={offer.image} alt={offer.title} className="size-full object-cover" />
<div
className="absolute bottom-0 inset-x-0 p-4 h-1/2 text-primary-50 bg-gradient-to-t from-primary-500 via-primary-500/80 flex flex-col items-center justify-end gap-y-1 md:items-start lg:px-6 lg:pb-8"
>
<p className="font-semibold lg:text-[26px] lg:font-bold">{offer.title}</p>
<p className="text-xs lg:text-[22px] lg:font-medium">{offer.description}</p>
</div>
</motion.div>
)}
</For>
</div>
</section>
)
}
@@ -0,0 +1,14 @@
import { FC, ReactElement } from 'react';
import { HeroSection } from './_components/hero-section';
import { WhatWeOfferSection } from './_components/what-we-offer-section';
export const Components: FC = (): ReactElement => {
return (
<>
<HeroSection />
<WhatWeOfferSection />
</>
);
};
export default Components;
@@ -0,0 +1,77 @@
import { CloseOutlined, MenuOutlined } from "@ant-design/icons";
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { cn } from "@imphnen-frontend-service/utils";
import { FC, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { For } from "../../_components/logic/for";
import { Show } from "../../_components/logic/show";
const MENUS: { label: string; href: string }[] = [
{ label: "Home", href: "/" },
{ label: 'Mentoring', href: '/mentoring' },
{ label: 'Resources', href: '/resources' },
{ label: 'Articles', href: '/articles' },
]
export const Header: FC = () => {
const location = useLocation();
const [expandMenu, setExpandMenu] = useState(false);
return (
<div className="w-full px-8 pt-8 top-0 z-50 md:px-[60px] md:pt-[60px] lg:px-20 sticky">
<header
className="bg-white shadow-lg rounded-lg h-12 flex justify-between w-full max-w-7xl xl:mx-auto md:h-[60px] lg:h-[71px]"
aria-roledescription="nav"
>
<div className="flex w-full items-center justify-between p-4 md:px-8 md:py-2.5">
<div className="flex items-center">
<img
src="/logos/simple.svg"
alt="IMPHNEN Logo"
className="h-8 md:h-10 lg:h-[52px] w-auto"
/>
</div>
<nav
className={cn(
"flex flex-col items-center py-2 fixed top-24 bg-white shadow-lg rounded-lg transition-all duration-300",
"md:top-32 lg:static lg:py-0 lg:flex-row lg:bg-transparent lg:shadow-none lg:gap-x-4",
!expandMenu ? "-right-96" : "right-8 md:right-[60px]"
)}
>
<For data={MENUS}>
{(menu) => (
<Button
key={menu.label}
type="button"
variant="text"
className={cn(
"px-10 py-1.5 text-neutral-300 hover:text-neutral-400 lg:px-2.5 lg:py-2",
location.pathname === menu.href && "text-primary-500 hover:text-primary-500"
)}
>
<Link to={menu.href}>{menu.label}</Link>
</Button>
)}
</For>
<Button type="button" className="px-12 py-1 md:hidden">Login</Button>
</nav>
<div>
<Button type="button" className="px-5 py-2 hidden lg:block">Login</Button>
<Button
type="button"
variant="text"
className={cn("transition-transform duration-300 rotate-0 lg:hidden", expandMenu && "rotate-90")}
onClick={() => setExpandMenu(prev => !prev)}
>
<Show condition={!expandMenu} fallback={<CloseOutlined />}>
<MenuOutlined />
</Show>
</Button>
</div>
</div>
</header>
</div>
);
}
@@ -0,0 +1,13 @@
import { Outlet } from "react-router-dom";
import { Header } from "./_components/header";
export default function Layout() {
return (
<div className="flex min-h-screen flex-col bg-primary-50">
<Header />
<main className="flex-1 z-0">
<Outlet />
</main>
</div>
);
}
@@ -0,0 +1,27 @@
export interface ForProps<T, U> {
data: readonly T[]
children: (item: T, index: number) => U | null
fallback?: React.ReactNode | null
}
/**
* A functional component that renders a list of children components from a given
* array of data.
*
* @param data - The array of data to render
* @param children - A function that takes the current item and index and returns
* the child component to render
* @param fallback - An optional fallback component to render when the data is empty
*
* @returns An array of rendered child components if the data is not empty, otherwise
* the fallback component if it is provided, null otherwise
*/
export function For<T, U extends React.JSX.Element>({
data,
children,
fallback = null
}: ForProps<T, U>): (U | null)[] | React.ReactNode | null {
if (!Array.isArray(data) || !data?.length) return fallback
return data.map((item, idx) => children(item, idx))
}
@@ -0,0 +1,19 @@
export interface ShowProps {
condition: boolean
children: React.ReactNode
fallback?: React.ReactNode | null
}
/**
* Conditionally renders the `children` component if `condition` is true. If
* `condition` is false, renders the `fallback` component instead.
*
* If `fallback` is `null`, renders nothing.
*
* @param condition - The condition to check
* @param children - The component to render if `condition` is true
* @param fallback - The component to render if `condition` is false
*/
export function Show({ condition, children, fallback = null }: ShowProps) {
return condition ? children : fallback
}
@@ -0,0 +1,10 @@
import { useSendOTP } from '@imphnen-frontend-service/utils';
export const useResendOtpHook = () => {
const { resendOTP, isLoading } = useSendOTP();
return {
resendOTP
};
};
@@ -1,12 +1,38 @@
import { FC, ReactElement } from 'react'; import { FC, ReactElement, useEffect, useState } from 'react';
import { ControlledInputField, RegisterResetBanner } from "@imphnen-frontend-service/ui/organisms"; import { ControlledInputField, RegisterResetBanner } from "@imphnen-frontend-service/ui/organisms";
import { useOtpHook } from '../../../_hooks/use-otp'; import { useOtpHook } from '../../../_hooks/use-otp';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { useResendOtpHook } from '../../../_hooks/use-resend-otp';
export const Components: FC = (): ReactElement => { export const Components: FC = (): ReactElement => {
const { form, onSubmit, isLoading } = useOtpHook() const { form, onSubmit, isLoading } = useOtpHook()
const [ searchParams ] = useSearchParams() const [ searchParams ] = useSearchParams()
const [ disabled, setDisabled] = useState(true);
const [ timeLeft, setTimeLeft ] = useState(5 * 60);
const { resendOTP } = useResendOtpHook()
useEffect(() => {
if (disabled) {
const interval = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 1) {
clearInterval(interval);
setDisabled(false);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}
}, [disabled]);
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
};
return ( return (
<div className='flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]'> <div className='flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]'>
@@ -26,6 +52,13 @@ export const Components: FC = (): ReactElement => {
maxLength={6} maxLength={6}
control={form.control} control={form.control}
/> />
<Button type="button" className="mt-2" disabled={disabled} onClick={
() => {
resendOTP({email: searchParams.get("email") || ""})
setDisabled(true)
setTimeLeft(5 * 60)
}
}>Kirim ulang otp {disabled? `(${formatTime(timeLeft)})`: ""}</Button>
<Button className="xl:w-full mt-2" disabled={(!form.formState.isValid || isLoading)}>Linked Start !!!</Button> <Button className="xl:w-full mt-2" disabled={(!form.formState.isValid || isLoading)}>Linked Start !!!</Button>
</form> </form>
</div> </div>
-7
View File
@@ -1,7 +0,0 @@
import { FC, ReactElement } from 'react';
export const Components: FC = (): ReactElement => {
return <>Hallo</>;
};
export default Components;
+3
View File
@@ -1 +1,4 @@
NEXT_PUBLIC_API_URL= NEXT_PUBLIC_API_URL=
TURNSTILE_SECRET_KEY=
NEXT_PUBLIC_TURNSTILE_SITEKEY=
+2
View File
@@ -1,8 +1,10 @@
const { composePlugins, withNx } = require('@nx/next'); const { composePlugins, withNx } = require('@nx/next');
const path = require('path');
/** @type {import('@nx/next/plugins/with-nx').WithNxOptions} */ /** @type {import('@nx/next/plugins/with-nx').WithNxOptions} */
const nextConfig = { const nextConfig = {
nx: { svgr: false }, nx: { svgr: false },
output: 'standalone',
}; };
module.exports = composePlugins(withNx)(nextConfig); module.exports = composePlugins(withNx)(nextConfig);
@@ -0,0 +1,86 @@
'use client';
import { motion } from 'framer-motion';
import { useEffect, useMemo, useState } from 'react';
import {
SiCplusplus,
SiCss3,
SiGo,
SiHtml5,
SiJavascript,
SiPhp,
SiPython,
SiRuby,
SiRust,
SiSwift,
SiTypescript,
} from 'react-icons/si';
export function AnimatedBackground() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
const iconColorMap = useMemo(
() => [
{ Icon: SiJavascript, color: '#F7DF1E' },
{ Icon: SiTypescript, color: '#3178C6' },
{ Icon: SiPython, color: '#3776AB' },
{ Icon: SiCplusplus, color: '#00599C' },
{ Icon: SiRuby, color: '#CC342D' },
{ Icon: SiSwift, color: '#F05138' },
{ Icon: SiRust, color: '#000000' },
{ Icon: SiGo, color: '#00ADD8' },
{ Icon: SiPhp, color: '#777BB4' },
{ Icon: SiHtml5, color: '#E34F26' },
{ Icon: SiCss3, color: '#1572B6' },
],
[]
);
const getRandom = (min: number, max: number) =>
Math.random() * (max - min) + min;
const floatingIcons = useMemo(() => {
if (!isClient) return [];
return Array.from({ length: 40 }).map((_, i) => {
const { Icon, color } = iconColorMap[i % iconColorMap.length];
return (
<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>
);
}
@@ -0,0 +1,41 @@
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[];
}
@@ -0,0 +1,33 @@
'use server';
import { fetcher } from '@/lib/fetcher';
import { getRemoteIp } from '@/lib/headers';
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
import {
forgotPasswordValidationSchema,
ForgotPasswordValidationType,
} from '../_validation/forgot-password-validation';
export async function ForgotPasswordAction(
request: ForgotPasswordValidationType
) {
const validRequest = forgotPasswordValidationSchema.parse(request);
const remoteIp = await getRemoteIp();
const isCapchaValid = await fetchPostverifyTurnstile(
validRequest.token,
remoteIp
);
if (!isCapchaValid) throw new Error('Failed to verify captcha');
const { data, error } = await fetcher.POST('/v1/auth/forgot', {
body: {
email: validRequest.email,
},
});
if (error) throw new Error(error.message);
return data;
}
@@ -0,0 +1,98 @@
'use client';
import {
Button,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@components';
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
import { useRef, useState } from 'react';
import { LuLoader } from 'react-icons/lu';
import { useFormForgotPassword } from '../_hooks/use-form-forgot-password';
import { usePostForgotPassowrd } from '../_hooks/use-post-forgot-password';
import { ForgotPasswordValidationType } from '../_validation/forgot-password-validation';
export function ForgotPasswordForm() {
const ref = useRef<TurnstileInstance | null>(null);
const [step, setStep] = useState<number>(1);
const [emailValue, setEmailValue] = useState<string>('');
const form = useFormForgotPassword();
const { mutate, isPending, error } = usePostForgotPassowrd(form);
const handleFirstStep = (values: ForgotPasswordValidationType) => {
setEmailValue(values.email);
setStep(2);
};
const handleSecondStep = () => {
mutate({ email: emailValue, token: form.getValues('token') });
};
return (
<Form {...form}>
<form
onSubmit={
step === 1
? form.handleSubmit(handleFirstStep)
: (e) => {
e.preventDefault();
handleSecondStep();
}
}
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>
)}
{step === 1 && (
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="emailmu@mail.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{step === 2 && (
<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 || (step === 2 && !form.watch('token'))}
className="w-full hover:bg-[#5fbaef] bg-[#22a5f1] font-bold"
>
{isPending ? (
<LuLoader className="h-5 w-5 animate-spin" />
) : step === 1 ? (
'Selanjutnya'
) : (
'Reset password'
)}
</Button>
</form>
</Form>
);
}
@@ -0,0 +1,16 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import {
forgotPasswordValidationSchema,
type ForgotPasswordValidationType,
} from '../_validation/forgot-password-validation';
export function useFormForgotPassword() {
return useForm<ForgotPasswordValidationType>({
resolver: zodResolver(forgotPasswordValidationSchema),
defaultValues: {
email: '',
token: '',
},
});
}
@@ -0,0 +1,20 @@
import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';
import { ForgotPasswordAction } from '../_actions/forgot-password-action';
import { useFormForgotPassword } from './use-form-forgot-password';
export function usePostForgotPassowrd(
form: ReturnType<typeof useFormForgotPassword>
) {
return useMutation({
mutationFn: ForgotPasswordAction,
onSuccess: ({ message }) => {
form.reset();
toast.success(message);
},
onError: ({ message }) => {
form.reset();
toast.error(message);
},
});
}
@@ -0,0 +1,9 @@
import { z } from 'zod';
export const forgotPasswordValidationSchema = z.object({
email: z.string().email({ message: 'Format email tidak valid' }),
token: z.string(),
});
export type ForgotPasswordValidationType = z.infer<
typeof forgotPasswordValidationSchema
>;
@@ -0,0 +1,23 @@
import { LogoSimple } from '@/app/_components/logo';
import { Metadata } from 'next';
import { ForgotPasswordForm } from './_components/forgot-password-form';
export const metadata: Metadata = {
title: 'IMPHNEN - Signin',
};
export default function Page() {
return (
<>
<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
@@ -0,0 +1,25 @@
import { baiJamjureeFont } from '@/lib/fonts';
import { Card } from '@components';
import { cn } from '@utils';
import { ReactNode } from 'react';
import { AnimatedBackground } from './_components/animated-background';
export default function Layout({ children }: { children: ReactNode }) {
return (
<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>
);
}
@@ -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>
</>
);
}
@@ -0,0 +1,22 @@
'use server';
import { setAccessToken, setRefreshToken } from '@/lib/cookies';
import { fetchPostSignin } from '../_http/fetch-post-signin';
import {
type SignInValidationType,
signInValidationSchema,
} from '../_validation/signin-validation';
export async function SigninAction(request: SignInValidationType) {
const validRequest = signInValidationSchema.parse(request);
const { data } = await fetchPostSignin(validRequest);
const accessToken = data.token.access_token;
const refreshToken = data.token.refresh_token;
await setAccessToken(accessToken);
await setRefreshToken(refreshToken);
return data;
}
@@ -0,0 +1,78 @@
'use client';
import {
Button,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@components';
import Link from 'next/link';
import { LuLoader } from 'react-icons/lu';
import { useFormSignin } from '../_hooks/use-form-signin';
import { usePostSignin } from '../_hooks/use-post-signin';
import { type SignInValidationType } from '../_validation/signin-validation';
export function SigninForm() {
const form = useFormSignin();
const { mutate, isPending, error } = usePostSignin(form);
const onSubmit = (values: SignInValidationType) => {
mutate(values);
};
return (
<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>
);
}
@@ -0,0 +1,16 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import {
signInValidationSchema,
type SignInValidationType,
} from '../_validation/signin-validation';
export function useFormSignin() {
return useForm<SignInValidationType>({
resolver: zodResolver(signInValidationSchema),
defaultValues: {
email: '',
password: '',
},
});
}
@@ -0,0 +1,28 @@
import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { SigninAction } from '../_actions/signin-action';
import { useFormSignin } from './use-form-signin';
export function usePostSignin(form: ReturnType<typeof useFormSignin>) {
const router = useRouter();
return useMutation({
mutationFn: SigninAction,
onSuccess: () => {
router.push('/');
},
onError: (error, variables) => {
form.resetField('password');
if (error.message.includes('not active')) {
setTimeout(() => {
router.push(`/verification?ref=${variables.email}`);
}, 750);
}
toast.error(error.message);
},
});
}
@@ -0,0 +1,24 @@
import { fetcher } from '@/lib/fetcher';
import { SignInValidationType } from '../_validation/signin-validation';
export async function fetchPostSignin({
email,
password,
}: SignInValidationType) {
const { data, error, response } = await fetcher.POST('/v1/auth/login', {
body: {
email,
password,
},
});
if (error) {
throw new Error(error.message);
}
if (!response.ok) {
throw new Error('Someting went wrong, please try again later');
}
return data;
}
@@ -0,0 +1,18 @@
import { z } from 'zod';
export const signInValidationSchema = z.object({
email: z
.string({
required_error: 'Email tidak boleh kosong',
invalid_type_error: 'Email harus berupa string',
})
.min(1, 'Email tidak boleh kosong')
.email('Email harus valid'),
password: z
.string({
required_error: 'Password tidak boleh kosong',
invalid_type_error: 'Password harus berupa string',
})
.min(1, 'Password tidak boleh kosong'),
});
export type SignInValidationType = z.infer<typeof signInValidationSchema>;
@@ -0,0 +1,36 @@
import { LogoSimple } from '@/app/_components/logo';
import { Metadata } from 'next';
import Link from 'next/link';
import { SigninForm } from './_components/signin-form';
export const metadata: Metadata = {
title: 'IMPHNEN - Signin',
};
export default function Page() {
return (
<>
<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>
</>
);
}
@@ -0,0 +1,25 @@
'use server';
import { getRemoteIp } from '@/lib/headers';
import { z } from 'zod';
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
import { fetchPostSignin } from '../_http/fetch-post-signup';
import { signupValidationSchema } from '../_validation/signup-validation';
export async function SignupAction(
request: z.infer<typeof signupValidationSchema>
) {
const validRequest = signupValidationSchema.parse(request);
const remoteIp = await getRemoteIp();
const isCapchaValid = await fetchPostverifyTurnstile(
validRequest.token,
remoteIp
);
if (!isCapchaValid) throw new Error('Failed to verify captcha');
const data = await fetchPostSignin(validRequest);
return data;
}
@@ -0,0 +1,223 @@
'use client';
import {
Button,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@components';
import { zodResolver } from '@hookform/resolvers/zod';
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { LuLoader } from 'react-icons/lu';
import { z } from 'zod';
import { SignupAction } from '../_actions/signup-action';
import {
signupValidationSchema,
stepOneSignupValidationSchema,
stepTwoSignupValidationSchema,
} from '../_validation/signup-validation';
export function SignupForm() {
const router = useRouter();
const ref = useRef<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>
)}
</>
);
}
@@ -0,0 +1,28 @@
import { fetcher } from '@/lib/fetcher';
import { SignupValidationSchema } from '../_validation/signup-validation';
export async function fetchPostSignin({
email,
password,
fullname,
phone_number,
}: SignupValidationSchema) {
const { data, error, response } = await fetcher.POST('/v1/auth/register', {
body: {
email,
password,
fullname,
phone_number,
},
});
if (error) {
throw new Error(error.message);
}
if (!response.ok) {
throw new Error('Someting went wrong, please try again later');
}
return data;
}
@@ -0,0 +1,57 @@
import { z } from 'zod';
export const stepOneSignupValidationSchema = z
.object({
email: z
.string({
required_error: 'Email tidak boleh kosong',
invalid_type_error: 'Email harus berupa string',
})
.min(1, 'Email tidak boleh kosong')
.email('Email harus valid'),
fullname: z
.string({
required_error: 'Nama tidak boleh kosong',
invalid_type_error: 'Nama harus berupa string',
})
.min(1, 'Nama tidak boleh kosong')
.max(50, 'Nama tidak boleh lebih dari 50 karakter'),
password: z
.string({
required_error: 'Password tidak boleh kosong',
invalid_type_error: 'Password harus berupa string',
})
.min(8, 'Password harus minimal 8 karakter')
.max(50, 'Password tidak boleh lebih dari 50 karakter')
.regex(
/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*\W).+$/,
'Password harus mengandung setidaknya satu huruf kapital, satu huruf kecil, satu angka, dan satu karakter spesial'
),
confirm_password: z
.string({
required_error: 'Konfirmasi password tidak boleh kosong',
})
.min(8, 'Konfirmasi password harus minimal 8 karakter')
.max(50, 'Konfirmasi password tidak boleh lebih dari 50 karakter'),
phone_number: z
.string({
required_error: 'Nomor telepon tidak boleh kosong',
invalid_type_error: 'Nomor telepon harus berupa string',
})
.min(10, 'Nomor telepon tidak boleh kurang dari 10 karakter')
.max(15, 'Nomor telepon tidak boleh lebih dari 15 karakter')
.regex(/^\d+$/, 'Nomor telepon hanya boleh berisi angka'),
})
.refine((data) => data.password === data.confirm_password, {
message: 'Password dan Konfirmasi Password harus sama',
path: ['confirm_password'],
});
export const stepTwoSignupValidationSchema = z.object({
token: z.string(),
});
export const signupValidationSchema = stepOneSignupValidationSchema.and(
stepTwoSignupValidationSchema
);
export type SignupValidationSchema = z.infer<typeof signupValidationSchema>;
@@ -0,0 +1,36 @@
import { LogoSimple } from '@/app/_components/logo';
import { Metadata } from 'next';
import Link from 'next/link';
import { SignupForm } from './_components/signup-form';
export const metadata: Metadata = {
title: 'IMPHNEN - Signup',
};
export default function Page() {
return (
<>
<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>
</>
);
}
@@ -0,0 +1,31 @@
'use server';
import { fetcher } from '@/lib/fetcher';
import { getRemoteIp } from '@/lib/headers';
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
import {
resendOTPValidationSchema,
type ResendOTPValidationType,
} from '../_validation/resend-otp-validation';
export async function resendOTPAction(request: ResendOTPValidationType) {
const validRequest = resendOTPValidationSchema.parse(request);
const remoteIp = await getRemoteIp();
const isCapchaValid = await fetchPostverifyTurnstile(
validRequest.token,
remoteIp
);
if (!isCapchaValid) throw new Error('Failed to verify captcha');
const { data, error } = await fetcher.POST('/v1/auth/send-otp', {
body: {
email: validRequest.email,
},
});
if (error) throw new Error(error.message);
return data;
}
@@ -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;
}
@@ -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<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>
);
}
@@ -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 (
<>
<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')}
>
Resend OTP
</button>
</div>
{activeTab === 'form' ? <VerifyEmailForm /> : <ResendOTPForm />}
</>
);
}

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