diff --git a/apps/gacha/src/app/_components/form/modal-form-forgot-password.tsx b/apps/gacha/src/app/_components/form/modal-form-forgot-password.tsx
new file mode 100644
index 0000000..691588a
--- /dev/null
+++ b/apps/gacha/src/app/_components/form/modal-form-forgot-password.tsx
@@ -0,0 +1,144 @@
+import { Button } from '@imphnen-frontend-service/ui/atoms';
+import {
+ InputForm,
+ Modal,
+ Stepper,
+} from '@imphnen-frontend-service/ui/molecules';
+
+interface IModalFormForgotPasswordProps {
+ isOpen: boolean;
+ onClose: () => void;
+ currentStep: number;
+ nextStep: () => void;
+ prevStep: () => void;
+ resetStep: () => void;
+}
+
+const ModalFormForgotPassword = ({
+ isOpen,
+ onClose,
+ currentStep,
+ nextStep,
+ prevStep,
+ resetStep,
+}: IModalFormForgotPasswordProps) => {
+ return (
+ {
+ onClose();
+ resetStep();
+ }}
+ >
+
+
+
+ Forgot Password
+
+
+
+
+ {currentStep === 1 && }
+ {currentStep === 2 && (
+
+ )}
+ {currentStep === 3 && (
+
+ )}
+
+
+ );
+};
+
+interface IStepOneProps {
+ nextStep: () => void;
+ onClose: () => void;
+}
+
+const StepOne = ({ nextStep, onClose }: IStepOneProps) => (
+ <>
+
+
+
+
+
+ >
+);
+
+interface IStepTwoProps {
+ nextStep: () => void;
+ prevStep: () => void;
+}
+
+const StepTwo = ({ nextStep, prevStep }: IStepTwoProps) => (
+ <>
+
+
+
+
+
+ >
+);
+
+interface IStepThreeProps {
+ onClose: () => void;
+ resetStep: () => void;
+}
+
+const StepThree = ({ onClose, resetStep }: IStepThreeProps) => (
+ <>
+
+
+
+ >
+);
+
+export default ModalFormForgotPassword;
diff --git a/apps/gacha/src/app/_components/form/modal-form-login.tsx b/apps/gacha/src/app/_components/form/modal-form-login.tsx
new file mode 100644
index 0000000..8b83cd2
--- /dev/null
+++ b/apps/gacha/src/app/_components/form/modal-form-login.tsx
@@ -0,0 +1,65 @@
+import { Button } from '@imphnen-frontend-service/ui/atoms';
+import { InputForm, Modal } from '@imphnen-frontend-service/ui/molecules';
+import { Link } from 'react-router-dom';
+
+interface IModalFormLogin {
+ isOpen: boolean;
+ onClose: () => void;
+ onForgotPassword: () => void;
+}
+
+const ModalFormLogin = ({
+ isOpen,
+ onClose,
+ onForgotPassword,
+}: IModalFormLogin) => {
+ return (
+
+
+
+
+ Login
+
+
+
+
+
+
+
+
+
+
+
Belum punya akun?
+
+ Daftar
+
+
+
+
+ );
+};
+
+export default ModalFormLogin;
diff --git a/apps/gacha/src/app/page.tsx b/apps/gacha/src/app/page.tsx
index 6cac5b2..610a786 100644
--- a/apps/gacha/src/app/page.tsx
+++ b/apps/gacha/src/app/page.tsx
@@ -1,9 +1,10 @@
import { ArrowDownOutlined } from '@ant-design/icons';
import { Button } from '@imphnen-frontend-service/ui/atoms';
-import { InputForm, Modal } from '@imphnen-frontend-service/ui/molecules';
import { cn } from '@imphnen-frontend-service/utils';
import { FC, Fragment, ReactElement, useEffect, useState } from 'react';
-import { Link } from 'react-router';
+import { useQueryState } from '../hooks/use-query-state';
+import ModalFormForgotPassword from './_components/form/modal-form-forgot-password';
+import ModalFormLogin from './_components/form/modal-form-login';
interface GachaItemProps {
src: string;
@@ -12,8 +13,19 @@ interface GachaItemProps {
}
export const Components: FC = (): ReactElement => {
- // Pinjem
- const [showModal, setShowModal] = useState(false);
+ const [showModalForgotPassword, setShowModalForgotPassword] = useState(false);
+ const [showModalLogin, setShowModalLogin] = useState(false);
+
+ const {
+ step: currentStep,
+ nextStep,
+ prevStep,
+ resetStep,
+ } = useQueryState('step', {
+ defaultValue: 1,
+ maxValue: 3,
+ minValue: 1,
+ });
const scrollToRoulette = () => {
const rouletteSection = document.getElementById('roulette');
@@ -65,6 +77,12 @@ export const Components: FC = (): ReactElement => {
);
};
+ const handleForgotPasswordClick = () => {
+ setShowModalLogin(false);
+ setShowModalForgotPassword(true);
+ resetStep();
+ };
+
return (
{/* Landing Page */}
@@ -216,64 +234,40 @@ export const Components: FC = (): ReactElement => {
Spin Now
+ {/* TODO: */}
+
+
+
+
{/* Diffuser & Cloud */}
- {/**Izin pake untuk debug modals gacha */}
-
- setShowModal(false)}
- >
-
-
-
- Login
-
-
-
-
-
-
-
- Lupa Password?
-
-
-
-
-
-
-
Belum punya akun?
-
- Daftar disini
-
-
-
-
- {/**Izin pake untuk debug modals gacha */}
+ {/* Login Modal */}
+ setShowModalLogin(false)}
+ onForgotPassword={handleForgotPasswordClick}
+ key={currentStep}
+ />
+
+ {/* Forgot Password Modal */}
+ {
+ setShowModalForgotPassword(false);
+ setShowModalLogin(true);
+ }}
+ nextStep={nextStep}
+ prevStep={prevStep}
+ resetStep={resetStep}
+ key={currentStep}
+ />
);
};
diff --git a/apps/gacha/src/hooks/use-query-state.ts b/apps/gacha/src/hooks/use-query-state.ts
new file mode 100644
index 0000000..3943f95
--- /dev/null
+++ b/apps/gacha/src/hooks/use-query-state.ts
@@ -0,0 +1,71 @@
+import { useCallback, useEffect, useState } from 'react';
+
+interface UseQueryStateOptions {
+ defaultValue: number;
+ maxValue?: number;
+ minValue?: number;
+}
+
+export const useQueryState = (key: string, options: UseQueryStateOptions) => {
+ const { defaultValue, maxValue = Infinity, minValue = 1 } = options;
+
+ const getQueryParam = (param: string): string | null => {
+ if (typeof window === 'undefined') return null;
+ const searchParams = new URLSearchParams(window.location.search);
+ return searchParams.get(param);
+ };
+
+ const setQueryParam = (param: string, value: string) => {
+ const searchParams = new URLSearchParams(window.location.search);
+ searchParams.set(param, value);
+ const newUrl = `${window.location.pathname}?${searchParams.toString()}`;
+ window.history.replaceState(null, '', newUrl);
+ };
+
+ const initialValue = () => {
+ const queryValue = getQueryParam(key);
+ const parsedValue = queryValue ? parseInt(queryValue, 10) : defaultValue;
+ return Math.max(minValue, Math.min(maxValue, parsedValue));
+ };
+
+ const [value, setValue] = useState(initialValue);
+
+ useEffect(() => {
+ const handlePopState = () => {
+ const queryValue = getQueryParam(key);
+ const newValue = queryValue ? parseInt(queryValue, 10) : defaultValue;
+ setValue(Math.max(minValue, Math.min(maxValue, newValue)));
+ };
+
+ window.addEventListener('popstate', handlePopState);
+ return () => window.removeEventListener('popstate', handlePopState);
+ }, [key, defaultValue, minValue, maxValue]);
+
+ const updateValue = useCallback(
+ (newValue: number) => {
+ const constrainedValue = Math.max(minValue, Math.min(maxValue, newValue));
+ setValue(constrainedValue);
+ setQueryParam(key, constrainedValue.toString());
+ },
+ [key, minValue, maxValue]
+ );
+
+ const nextStep = useCallback(() => {
+ updateValue(value + 1);
+ }, [value, updateValue]);
+
+ const prevStep = useCallback(() => {
+ updateValue(value - 1);
+ }, [value, updateValue]);
+
+ const resetStep = useCallback(() => {
+ updateValue(defaultValue);
+ }, [defaultValue, updateValue]);
+
+ return {
+ step: value,
+ nextStep,
+ prevStep,
+ resetStep,
+ };
+};
diff --git a/libs/ui/src/molecules/index.ts b/libs/ui/src/molecules/index.ts
index 7349f9a..b822815 100644
--- a/libs/ui/src/molecules/index.ts
+++ b/libs/ui/src/molecules/index.ts
@@ -1,5 +1,6 @@
-export * from "./forgot-step";
-export * from "./otp-form";
+export * from './forgot-step';
+export * from './otp-form';
export * from './input-form';
export * from './pagination';
export * from './modal/modal';
+export * from './stepper';
diff --git a/libs/ui/src/molecules/stepper/index.tsx b/libs/ui/src/molecules/stepper/index.tsx
new file mode 100644
index 0000000..ef1c3dd
--- /dev/null
+++ b/libs/ui/src/molecules/stepper/index.tsx
@@ -0,0 +1 @@
+export * from './stepper';
diff --git a/libs/ui/src/molecules/stepper/stepper.spec.tsx b/libs/ui/src/molecules/stepper/stepper.spec.tsx
new file mode 100644
index 0000000..444a7fc
--- /dev/null
+++ b/libs/ui/src/molecules/stepper/stepper.spec.tsx
@@ -0,0 +1,48 @@
+import { render, screen } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import { Stepper, StepperProps } from './Stepper';
+
+vi.mock('@imphnen-frontend-service/utils', () => ({
+ cn: (...args: string[]) => args.filter(Boolean).join(' '),
+}));
+
+describe('Stepper', () => {
+ const renderStepper = (props: StepperProps) => render();
+
+ it('renders with correct step information', () => {
+ renderStepper({ currentStep: 2, totalSteps: 5 });
+
+ expect(screen.getByText('Langkah 2 dari 5')).toBeInTheDocument();
+ });
+
+ it('applies custom className correctly', () => {
+ const customClass = 'custom-stepper';
+ renderStepper({
+ currentStep: 1,
+ totalSteps: 3,
+ className: customClass,
+ });
+
+ const stepperElement = screen.getByText('Langkah 1 dari 3').parentElement;
+ expect(stepperElement).toHaveClass('text-center');
+ expect(stepperElement).toHaveClass('mb-4');
+ expect(stepperElement).toHaveClass(customClass);
+ });
+
+ it('handles step 0 correctly', () => {
+ renderStepper({ currentStep: 0, totalSteps: 3 });
+ expect(screen.getByText('Langkah 0 dari 3')).toBeInTheDocument();
+ });
+
+ it('handles equal currentStep and totalSteps', () => {
+ renderStepper({ currentStep: 3, totalSteps: 3 });
+ expect(screen.getByText('Langkah 3 dari 3')).toBeInTheDocument();
+ });
+
+ it('applies correct text styling classes', () => {
+ renderStepper({ currentStep: 1, totalSteps: 2 });
+ const textElement = screen.getByText('Langkah 1 dari 2');
+ expect(textElement).toHaveClass('text-neutral-400');
+ expect(textElement).toHaveClass('text-lg');
+ });
+});
diff --git a/libs/ui/src/molecules/stepper/stepper.stories.tsx b/libs/ui/src/molecules/stepper/stepper.stories.tsx
new file mode 100644
index 0000000..b0729fd
--- /dev/null
+++ b/libs/ui/src/molecules/stepper/stepper.stories.tsx
@@ -0,0 +1,61 @@
+import { Meta, StoryObj } from '@storybook/react';
+import { Stepper } from './Stepper';
+
+const meta: Meta = {
+ title: 'Components/Stepper',
+ component: Stepper,
+ argTypes: {
+ currentStep: {
+ control: { type: 'number', min: 0 },
+ description: 'Current step number',
+ },
+ totalSteps: {
+ control: { type: 'number', min: 1 },
+ description: 'Total number of steps',
+ },
+ className: {
+ control: 'text',
+ description: 'Custom CSS class',
+ },
+ },
+ parameters: {
+ docs: {
+ description: {
+ component: 'A simple stepper component showing progress through steps',
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ currentStep: 1,
+ totalSteps: 3,
+ },
+};
+
+export const MiddleStep: Story = {
+ args: {
+ currentStep: 2,
+ totalSteps: 4,
+ },
+};
+
+export const LastStep: Story = {
+ args: {
+ currentStep: 3,
+ totalSteps: 3,
+ },
+};
+
+export const WithCustomClass: Story = {
+ args: {
+ currentStep: 1,
+ totalSteps: 5,
+ className: 'custom-class',
+ },
+};
diff --git a/libs/ui/src/molecules/stepper/stepper.tsx b/libs/ui/src/molecules/stepper/stepper.tsx
new file mode 100644
index 0000000..a496f14
--- /dev/null
+++ b/libs/ui/src/molecules/stepper/stepper.tsx
@@ -0,0 +1,25 @@
+import { cn } from '@imphnen-frontend-service/utils';
+import React from 'react';
+
+interface StepperProps {
+ currentStep: number;
+ totalSteps: number;
+ className?: string;
+}
+
+const Stepper: React.FC = ({
+ currentStep,
+ totalSteps,
+ className,
+}) => {
+ return (
+
+
+ Langkah {currentStep} dari {totalSteps}
+
+
+ );
+};
+
+export { Stepper };
+export type { StepperProps };