Merge branch 'develop' into feat/backoffice-login

This commit is contained in:
Hafid Nur
2025-04-03 12:29:28 +07:00
37 changed files with 954 additions and 810 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
import { api } from '@imphnen-frontend-service/utils';
import { api } from '../';
import {
TLoginRequest,
TLoginResponse,
+8
View File
@@ -1,3 +1,11 @@
import axios, { AxiosRequestConfig } from 'axios';
export * from './auth';
export * from './gacha';
export * from './users';
const config: AxiosRequestConfig = {
baseURL: import.meta.env.VITE_API_URL,
};
export const api = axios.create(config);
+7
View File
@@ -6,6 +6,8 @@ import {
TRegisterRequest,
TVerifyEmailRequest,
} from '../../types/auth';
import { SessionToken, SessionUser } from '@imphnen-frontend-service/utils';
import { TResponseError, TResponseMessage } from '../../types/common';
export const usePostLogin = (): UseMutationResult<
@@ -17,6 +19,11 @@ export const usePostLogin = (): UseMutationResult<
return useMutation({
mutationKey: ['post-login'],
mutationFn: async (payload) => await postLogin(payload),
onSuccess: (res) => {
SessionUser.set(res.data.user);
SessionToken.set(res.data.token);
window.location.reload();
},
});
};
+1
View File
@@ -1,3 +1,4 @@
export * from './api';
export * from './hooks';
export * from './types';
export * from './schemas';
+9
View File
@@ -0,0 +1,9 @@
import { z } from 'zod';
export const authLoginSchema = z.object({
email: z
.string()
.min(1, 'Email cannot be empty')
.email('Email must be valid'),
password: z.string().min(1, 'Password cannot be empty'),
});
+1
View File
@@ -0,0 +1 @@
export * from './auth';
+3 -23
View File
@@ -1,3 +1,5 @@
import { TUserItem } from '../users';
export type TLoginRequest = {
email: string;
password: string;
@@ -9,29 +11,7 @@ export type TLoginResponse = {
access_token: string;
refresh_token: string;
};
user: {
role: {
id: string;
name: string;
permission: [
{
id: string;
name: string;
created_at: string;
updated_at: string;
}
]
created_at: string;
updated_at: string;
};
fullname: string;
email: string;
avatar: string;
phone_number: string;
is_active: boolean;
gender: string;
birthdate: string;
};
user: TUserItem;
};
};
+2
View File
@@ -1,3 +1,5 @@
export * from './auth';
export * from './gacha';
export * from './users';
export * from './roles';
export * from './permissions';
@@ -0,0 +1,6 @@
export type TPermissionItem = {
id: string;
name: string;
created_at: string;
updated_at: string;
};
+9
View File
@@ -0,0 +1,9 @@
import { TPermissionItem } from '../permissions';
export type TRoleItem = {
id: string;
name: string;
created_at: string;
updated_at: string;
permissions: TPermissionItem[];
};
+19 -1
View File
@@ -1 +1,19 @@
export {};
import { TRoleItem } from '../roles';
export type TUserItem = {
id: string;
avatar: string;
birthdate: string;
email: string;
fullname: string;
gender: string;
identity_number: string;
is_active: boolean;
is_profile_completed: boolean;
phone_number: string;
referral_code: string;
referred_by: string;
religion: string;
student_type: string;
role: TRoleItem;
};
+1 -1
View File
@@ -1,6 +1,6 @@
export * from './forgot-step';
export * from './otp-form';
export * from './input-form';
export * from './input-field';
export * from './pagination';
export * from './modal/modal';
export * from './stepper';
@@ -0,0 +1 @@
export * from './input-field';
@@ -1,9 +1,9 @@
import { render, screen } from '@testing-library/react';
import InputForm from './input-form';
import { InputField } from './input-field';
describe('InputForm Component', () => {
it('renders correctly with disabled prop', () => {
render(<InputForm label="Test Label" disabled={true} />);
render(<InputField label="Test Label" disabled={true} />);
const input = screen.getByLabelText('Test Label');
expect(input).toBeDisabled();
@@ -11,7 +11,7 @@ describe('InputForm Component', () => {
});
it('renders correctly without disabled prop', () => {
render(<InputForm label="Test Label" disabled={false} />);
render(<InputField label="Test Label" disabled={false} />);
const input = screen.getByLabelText('Test Label');
expect(input).not.toBeDisabled();
@@ -1,9 +1,9 @@
import type { Meta, StoryObj } from '@storybook/react';
import { InputForm } from './input-form';
import { InputField } from './input-field';
const meta = {
title: 'Molecules/Input Form',
component: InputForm,
component: InputField,
parameters: {
layout: 'centered',
docs: {
@@ -27,7 +27,7 @@ Cek dan inspect element pada story With HtmlFor untuk melihat hasilnya.
},
},
tags: ['autodocs'],
} satisfies Meta<typeof InputForm>;
} satisfies Meta<typeof InputField>;
export default meta;
type Story = StoryObj<typeof meta>;
@@ -7,10 +7,9 @@ import {
import { Input } from '../../atoms';
import { cn } from '@imphnen-frontend-service/utils';
type TInputType = 'text' | 'email' | 'password' | 'file';
type TInputSize = 'sm' | 'md' | 'lg';
type TInputFormProps = Omit<
export type TInputType = 'text' | 'email' | 'password' | 'file';
export type TInputSize = 'sm' | 'md' | 'lg';
export type TInputFieldProps = Omit<
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
'size' | 'type'
> & {
@@ -19,7 +18,6 @@ type TInputFormProps = Omit<
size?: TInputSize;
error?: string;
disabled?: boolean;
helperText?: string;
htmlFor?: string;
};
@@ -39,7 +37,7 @@ const sizeClasses: Record<TInputSize, { label: string; helperText: string }> = {
},
};
export const InputForm: FC<TInputFormProps> = ({
export const InputField: FC<TInputFieldProps> = ({
label,
placeholder,
type = 'text',
@@ -88,5 +86,3 @@ export const InputForm: FC<TInputFormProps> = ({
</div>
);
};
export default InputForm;
@@ -1 +0,0 @@
export * from './input-form';
@@ -0,0 +1,21 @@
import {
InputField,
TInputFieldProps,
} from '@imphnen-frontend-service/ui/molecules';
import {
FieldValues,
useController,
UseControllerProps,
} from 'react-hook-form';
export type TControlledInputFieldProps<T extends FieldValues> =
UseControllerProps<T> & TInputFieldProps;
export const ControlledInputField = <T extends FieldValues>(
props: TControlledInputFieldProps<T>
) => {
const { field, fieldState } = useController<T>(props);
return (
<InputField error={fieldState.error?.message} {...{ ...props, ...field }} />
);
};
@@ -0,0 +1 @@
export * from './controlled-input-field';
+6 -5
View File
@@ -1,6 +1,7 @@
export * from './navbar';
export * from "./modals-gacha";
export * from "./auth-banner";
export * from './backoffice-sidebar'
export * from './datatable'
export * from './filter'
export * from './modals-gacha';
export * from './auth-banner';
export * from './backoffice-sidebar';
export * from './datatable';
export * from './filter';
export * from './controlled-field';
+2 -2
View File
@@ -163,7 +163,7 @@ describe('Navbar', () => {
}
});
it('has correct ARIA role for navigation', () => {
it('has correct ARIA role for nav', () => {
const { container }: RenderResult = render(
<BrowserRouter>
<Navbar />
@@ -171,6 +171,6 @@ describe('Navbar', () => {
);
const header: HTMLElement | null = container.querySelector('header');
expect(header).toHaveAttribute('role', 'navigation');
expect(header).toHaveAttribute('role', 'nav');
});
});
+33 -20
View File
@@ -1,16 +1,18 @@
import { MenuOutlined } from '@ant-design/icons';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { FC, ReactElement, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button } from '../../atoms/button';
import { useSession } from '@imphnen-frontend-service/utils';
export const Navbar: FC = (): ReactElement => {
const [isDropdownOpen, setDropdownOpen] = useState(false);
const { session, signOut, isAuthenticated } = useSession();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
return (
<div className="bg-primary-50 w-full px-[32px] pt-[32px] md:px-[60px] md:pt-[60px] lg:px-[80px] sticky top-0 z-50">
<header
className="bg-white shadow-lg rounded-lg min-h-[47px] max-h-[47px] md:min-h-[60px] md:max-h-[60px] lg:min-h-[71px] lg:max-h-[71px] flex justify-between w-full max-w-[1280px] xl:mx-auto"
role="navigation"
role="nav"
>
<div className="flex w-full items-center justify-between p-4 md:px-[32px] md:py-[10px]">
<div className="flex items-center">
@@ -40,20 +42,27 @@ export const Navbar: FC = (): ReactElement => {
<Link to="#">Merch Gacha</Link>
</Button>
</li>
<li>
<Button
size="md"
className="lg:text-[19px] lg:max-h-[44px] text-neutral-50 hover:text-neutral-200 transition-colors"
>
<Link to="/login">Login</Link>
</Button>
</li>
{!isAuthenticated ? (
<li>
<Button>Login</Button>
</li>
) : (
<li className="flex gap-x-4">
<span className="text-lg">{session.user?.fullname}</span>
<div
onClick={signOut}
className="text-lg text-red-500 font-bold"
>
Logout
</div>
</li>
)}
</ul>
<button
className={`md:hidden duration-200 ${
isDropdownOpen ? 'transform rotate-90' : ''
}`}
onClick={() => setDropdownOpen(!isDropdownOpen)}
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
<MenuOutlined style={{ color: '#1a8ce6' }} />
</button>
@@ -77,14 +86,18 @@ export const Navbar: FC = (): ReactElement => {
Merch Gacha
</Link>
</li>
<li>
<Link
to="#"
className="block text-gray-600 transition-colors px-4 py-2 text-center font-semibold"
>
Login
</Link>
</li>
{!isAuthenticated ? (
<li>
<Link
to="#"
className="block text-gray-600 transition-colors px-4 py-2 text-center font-semibold"
>
Login
</Link>
</li>
) : (
<li>{session.user?.fullname}</li>
)}
</ul>
</div>
)}
-7
View File
@@ -1,7 +0,0 @@
import axios, { AxiosRequestConfig } from 'axios';
const config: AxiosRequestConfig = {
baseURL: import.meta.env.VITE_API_URL,
};
export const api = axios.create(config);
-1
View File
@@ -1 +0,0 @@
export * from './api';
+2
View File
@@ -0,0 +1,2 @@
export * from './use-query-state';
export * from './use-session';
+71
View File
@@ -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<number>(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,
};
};
+22
View File
@@ -0,0 +1,22 @@
import { SessionToken, SessionUser } from '../local-storage';
export const useSession = () => {
const session = {
user: SessionUser.get(),
token: SessionToken.get(),
};
const isAuthenticated = !!session.token?.access_token;
const signOut = () => {
SessionUser.remove();
SessionToken.remove();
window.location.reload();
};
return {
isAuthenticated,
session,
signOut,
};
};
+2 -1
View File
@@ -1,4 +1,5 @@
export * from './react-query';
export * from './react-router';
export * from './tailwind-merge';
export * from './axios';
export * from './hooks';
export * from './local-storage';
+60
View File
@@ -0,0 +1,60 @@
export type TPermissionItem = {
id: string;
name: string;
created_at: string;
updated_at: string;
};
type TRoleItem = {
id: string;
name: string;
created_at: string;
updated_at: string;
permissions: TPermissionItem[];
};
type TUserItem = {
id: string;
avatar: string;
birthdate: string;
email: string;
fullname: string;
gender: string;
identity_number: string;
is_active: boolean;
is_profile_completed: boolean;
phone_number: string;
referral_code: string;
referred_by: string;
religion: string;
student_type: string;
role: TRoleItem;
};
export const SessionUser = {
set: (val: TUserItem) => localStorage.setItem('users', JSON.stringify(val)),
get: (): TUserItem | undefined => {
const users = localStorage.getItem('users');
return users ? JSON.parse(users) : undefined;
},
remove: () => localStorage.removeItem('users'),
};
export const SessionToken = {
set: (val: { access_token: string; refresh_token: string }) => {
localStorage.setItem('access_token', val.access_token);
localStorage.setItem('refresh_token', val.refresh_token);
},
get: ():
| { access_token?: string | null; refresh_token?: string | null }
| undefined => {
return {
access_token: localStorage.getItem('access_token'),
refresh_token: localStorage.getItem('refresh_token'),
};
},
remove: () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
},
};