From d58ad58ef844f47ad2118fd4fb7bde892b69ef65 Mon Sep 17 00:00:00 2001 From: egagofur Date: Sun, 30 Mar 2025 22:41:06 +0700 Subject: [PATCH 1/3] feat: dyamic stepper form forgot password --- .../form/modal-form-forgot-password.tsx | 144 ++++++++++++++++++ .../app/_components/form/modal-form-login.tsx | 65 ++++++++ apps/gacha/src/app/page.tsx | 110 +++++++------ apps/gacha/src/hooks/use-query-state.ts | 71 +++++++++ libs/ui/src/molecules/index.ts | 5 +- libs/ui/src/molecules/stepper/index.tsx | 1 + .../ui/src/molecules/stepper/stepper.spec.tsx | 48 ++++++ .../src/molecules/stepper/stepper.stories.tsx | 61 ++++++++ libs/ui/src/molecules/stepper/stepper.tsx | 25 +++ 9 files changed, 470 insertions(+), 60 deletions(-) create mode 100644 apps/gacha/src/app/_components/form/modal-form-forgot-password.tsx create mode 100644 apps/gacha/src/app/_components/form/modal-form-login.tsx create mode 100644 apps/gacha/src/hooks/use-query-state.ts create mode 100644 libs/ui/src/molecules/stepper/index.tsx create mode 100644 libs/ui/src/molecules/stepper/stepper.spec.tsx create mode 100644 libs/ui/src/molecules/stepper/stepper.stories.tsx create mode 100644 libs/ui/src/molecules/stepper/stepper.tsx 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 }; From 5d418830b946024b25a198f09fd6e8d6fadaaf9a Mon Sep 17 00:00:00 2001 From: egagofur Date: Sun, 30 Mar 2025 22:44:59 +0700 Subject: [PATCH 2/3] feat: add todo component otp input --- .../src/app/_components/form/modal-form-forgot-password.tsx | 1 + 1 file changed, 1 insertion(+) 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 index 691588a..cf41f53 100644 --- a/apps/gacha/src/app/_components/form/modal-form-forgot-password.tsx +++ b/apps/gacha/src/app/_components/form/modal-form-forgot-password.tsx @@ -83,6 +83,7 @@ interface IStepTwoProps { const StepTwo = ({ nextStep, prevStep }: IStepTwoProps) => ( <> + {/* TODO: Change component using OTP Input */} Date: Sun, 30 Mar 2025 22:47:29 +0700 Subject: [PATCH 3/3] feat: add todo change using real button trigger --- apps/gacha/src/app/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/gacha/src/app/page.tsx b/apps/gacha/src/app/page.tsx index 610a786..6a58c4a 100644 --- a/apps/gacha/src/app/page.tsx +++ b/apps/gacha/src/app/page.tsx @@ -234,7 +234,7 @@ export const Components: FC = (): ReactElement => { Spin Now - {/* TODO: */} + {/* TODO: Change using real button trigger */}