fix: conflict
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { api } from '@imphnen-frontend-service/utils';
|
||||
import { api } from '../';
|
||||
import {
|
||||
TLoginRequest,
|
||||
TLoginResponse,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from './api';
|
||||
export * from './hooks';
|
||||
export * from './types';
|
||||
export * from './schemas';
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const authLoginSchema = 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 const stepOneRegisterSchema = 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(1, 'Password tidak boleh kosong')
|
||||
.min(8, 'Password harus lebih dari 8 karakter')
|
||||
.max(50, 'Password tidak boleh lebih dari 50 karakter'),
|
||||
confirm_password: z
|
||||
.string({
|
||||
required_error: 'Konfirmasi password tidak boleh kosong',
|
||||
})
|
||||
.min(1, 'Konfirmasi password tidak boleh kosong')
|
||||
.max(50, 'Konfirmasi password tidak boleh lebih dari 50 karakter'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirm_password, {
|
||||
message: 'Password dan Konfirmasi Password harus sama',
|
||||
path: ['confirm_password'],
|
||||
});
|
||||
|
||||
const stepTwoRegisterSchema = z.object({
|
||||
phone_number: z
|
||||
.string({
|
||||
required_error: 'Nomor telepon tidak boleh kosong',
|
||||
invalid_type_error: 'Nomor telepon harus berupa string',
|
||||
})
|
||||
.min(1, 'Nomor telepon tidak boleh kosong')
|
||||
.max(15, 'Nomor telepon tidak boleh lebih dari 15 karakter'),
|
||||
referral_code: z
|
||||
.string()
|
||||
.min(1, 'Kode referral tidak boleh kosong')
|
||||
.max(4, 'Kode referral tidak boleh lebih dari 4 karakter')
|
||||
.optional(),
|
||||
referred_by: z
|
||||
.string()
|
||||
.min(1, 'Kode referral tidak boleh kosong')
|
||||
.max(50, 'Kode referral tidak boleh lebih dari 50 karakter')
|
||||
.optional(),
|
||||
student_type: z.string(),
|
||||
});
|
||||
|
||||
export const authRegisterSchema = stepOneRegisterSchema.and(
|
||||
stepTwoRegisterSchema
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const gachaItemSchema = z.object({
|
||||
itemName: z
|
||||
.string({
|
||||
required_error: 'Nama item tidak boleh kosong',
|
||||
invalid_type_error: 'Nama item harus berupa string',
|
||||
})
|
||||
.min(1, 'Nama item tidak boleh kosong'),
|
||||
quantity: z
|
||||
.number({
|
||||
required_error: 'Quantity tidak boleh kosong',
|
||||
invalid_type_error: 'Quantity harus berupa angka',
|
||||
})
|
||||
.min(1, 'Quantity paling sedikit adalah 1'),
|
||||
foto: z
|
||||
.instanceof(File)
|
||||
.optional()
|
||||
.refine(
|
||||
(file) => !file || file.size <= 5000000, // 5MB in bytes
|
||||
'Ukuran file maksimal 5MB'
|
||||
)
|
||||
.refine(
|
||||
(file) => !file || ['image/jpeg', 'image/png', 'image/webp'].includes(file.type),
|
||||
'Format file harus JPG, PNG, atau WEBP'
|
||||
)
|
||||
});
|
||||
|
||||
export const gachaRollItemSchema = z.object({
|
||||
itemName: z
|
||||
.string({
|
||||
required_error: 'Nama item tidak boleh kosong',
|
||||
invalid_type_error: 'Nama item harus berupa string',
|
||||
})
|
||||
.min(1, 'Nama item tidak boleh kosong'),
|
||||
quantity: z
|
||||
.number({
|
||||
required_error: 'Quantity tidak boleh kosong',
|
||||
invalid_type_error: 'Quantity harus berupa angka',
|
||||
})
|
||||
.min(1, 'Quantity paling sedikit adalah 1'),
|
||||
chanceRate: z
|
||||
.number({
|
||||
required_error: 'Chance rate tidak boleh kosong',
|
||||
invalid_type_error: 'Chance rate harus berupa angka',
|
||||
})
|
||||
.min(0.1, 'Chance rate paling sedikit adalah 0,1')
|
||||
.max(1, 'Chance rate paling banyak adalah 1'),
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './auth';
|
||||
export * from './gacha';
|
||||
@@ -1,3 +1,5 @@
|
||||
import { TUserItem } from '../users';
|
||||
|
||||
export type TLoginRequest = {
|
||||
email: string;
|
||||
password: string;
|
||||
@@ -9,18 +11,19 @@ export type TLoginResponse = {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
};
|
||||
user: {
|
||||
fullname: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
};
|
||||
user: TUserItem;
|
||||
};
|
||||
};
|
||||
|
||||
export type TRegisterRequest = {
|
||||
fullname: string;
|
||||
email: string;
|
||||
fullname: string;
|
||||
password: string;
|
||||
phone_number: string;
|
||||
referral_code?: string;
|
||||
referred_by?: string;
|
||||
student_type: string;
|
||||
confirm_password: string;
|
||||
};
|
||||
|
||||
export type TVerifyEmailRequest = {
|
||||
|
||||
@@ -1 +1,11 @@
|
||||
export {};
|
||||
export type TGachaItem = {
|
||||
itemName: string;
|
||||
quantity: number;
|
||||
foto?: File;
|
||||
};
|
||||
|
||||
export type TGachaRollItem = {
|
||||
itemName: string;
|
||||
quantity: number;
|
||||
chanceRate: number;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { TPermissionItem } from '../permissions';
|
||||
|
||||
export type TRoleItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
permissions: TPermissionItem[];
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -24,9 +24,9 @@ describe('Test Button Component', () => {
|
||||
|
||||
const button = screen.getByText('Delete');
|
||||
|
||||
expect(button).toHaveClass('bg-danger-500');
|
||||
expect(button).toHaveClass('hover:bg-danger-600');
|
||||
expect(button).toHaveClass('text-white');
|
||||
expect(button).toHaveClass('bg-danger-100');
|
||||
expect(button).toHaveClass('hover:bg-danger-200');
|
||||
expect(button).toHaveClass('text-danger-500');
|
||||
});
|
||||
|
||||
it("disables the button when 'disabled' prop is set", async () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ const variantClasses: Record<TButtonVariant, string> = {
|
||||
bordered:
|
||||
'border border-primary-500 hover:border-primary-600 bg-transparent hover:text-primary-600 hover:bg-gray-50 text-primary-500',
|
||||
success: 'bg-success-500 hover:bg-success-600 text-white shadow-md',
|
||||
danger: 'bg-danger-500 hover:bg-danger-600 text-white shadow-md',
|
||||
danger: 'bg-danger-100 hover:bg-danger-200 text-danger-500 shadow-md',
|
||||
};
|
||||
|
||||
const sizeClasses: Record<TButtonSize, string> = {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons'; // Import
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { Button } from '../button';
|
||||
|
||||
type TInputType = 'text' | 'email' | 'password' | 'file';
|
||||
type TInputType = 'text' | 'email' | 'number' | 'password' | 'file';
|
||||
type TInputSize = 'sm' | 'md' | 'lg';
|
||||
type Width = 'standard' | 'custom'
|
||||
|
||||
@@ -43,7 +43,8 @@ export const Input: FC<TInputProps> = ({
|
||||
}): ReactElement => {
|
||||
const [showPassword, setShowPassword] = useState(false); // State for password visibility
|
||||
|
||||
const togglePasswordVisibility = () => {
|
||||
const togglePasswordVisibility = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!disabled) setShowPassword((prev) => !prev);
|
||||
};
|
||||
|
||||
@@ -66,6 +67,7 @@ export const Input: FC<TInputProps> = ({
|
||||
{type === 'password' && (
|
||||
<div className="absolute end-0 px-[12px] h-full flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size={size}
|
||||
onClick={togglePasswordVisibility}
|
||||
|
||||
@@ -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';
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import InputForm from './input-form';
|
||||
import { InputField } from './input-field';
|
||||
|
||||
describe('InputForm Component', () => {
|
||||
describe('InputField Component', () => {
|
||||
it('renders correctly with disabled prop', () => {
|
||||
render(<InputForm label="Test Label" disabled={true} />);
|
||||
render(<InputField htmlFor="test" 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 htmlFor="test" label="Test Label" disabled={false} />);
|
||||
|
||||
const input = screen.getByLabelText('Test Label');
|
||||
expect(input).not.toBeDisabled();
|
||||
+3
-3
@@ -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>;
|
||||
+13
-12
@@ -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' | 'number' | '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',
|
||||
@@ -56,7 +54,7 @@ export const InputForm: FC<TInputFormProps> = ({
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className={cn(
|
||||
'items-start justify-item-start text-start',
|
||||
'items-start justify-item-start text-start !text-neutral-800',
|
||||
sizeClasses[size].label
|
||||
)}
|
||||
>
|
||||
@@ -72,15 +70,20 @@ export const InputForm: FC<TInputFormProps> = ({
|
||||
error &&
|
||||
'border-danger-500 hover:border-danger-500 focus:outline-danger-500',
|
||||
className,
|
||||
disabled && 'opacity-50 cursor-not-allowed' // Add styles for disabled state
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="text-danger-500 text-xs mt-1">{error}</p>
|
||||
<p className="text-danger-500 text-label1 text-left">{error}</p>
|
||||
) : (
|
||||
helperText && (
|
||||
<p className={cn('text-cs mt-1', sizeClasses[size].helperText)}>
|
||||
<p
|
||||
className={cn(
|
||||
'text-label2 text-left',
|
||||
sizeClasses[size].helperText
|
||||
)}
|
||||
>
|
||||
{helperText}
|
||||
</p>
|
||||
)
|
||||
@@ -88,5 +91,3 @@ export const InputForm: FC<TInputFormProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputForm;
|
||||
@@ -1 +0,0 @@
|
||||
export * from './input-form';
|
||||
@@ -3,24 +3,26 @@ import {
|
||||
AuditOutlined,
|
||||
InboxOutlined,
|
||||
LogoutOutlined,
|
||||
ReloadOutlined,
|
||||
UsergroupAddOutlined,
|
||||
UserOutlined,
|
||||
UserSwitchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button } from '../../atoms';
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useSession } from '@imphnen-frontend-service/utils';
|
||||
|
||||
export const BackofficeSidebar: FC = (): ReactElement => {
|
||||
const { signOut } = useSession();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isActive = (path: string) => location.pathname.includes(path);
|
||||
|
||||
return (
|
||||
<aside className="sticky top-0 left-0 w-[280px] bg-white min-h-screen py-[60px] px-[28px] shadow-xl flex flex-col justify-between">
|
||||
<div className="flex flex-col gap-20 justify-between items-center">
|
||||
{/* Logo */}
|
||||
<img src="/logos/simple.svg" alt="IMPHNEN Logo" className="w-[150px]" />
|
||||
|
||||
{/* Navigation Menu */}
|
||||
<nav className="flex flex-col gap-4 w-full">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
@@ -34,6 +36,42 @@ export const BackofficeSidebar: FC = (): ReactElement => {
|
||||
<span className="text-p3 font-medium">Dashboard & Set Gacha</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/gacha-roll"
|
||||
className={`flex items-center justify-items-start gap-3 px-[8px] py-[10px] ${
|
||||
isActive('/gacha-roll')
|
||||
? 'bg-primary-500 text-white rounded-md'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<ReloadOutlined className="text-[20px]" />
|
||||
<span className="text-p3 font-medium">Gacha Roll</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/permissions"
|
||||
className={`flex items-center justify-items-start gap-3 px-[8px] py-[10px] ${
|
||||
isActive('/permissions')
|
||||
? 'bg-primary-500 text-white rounded-md'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<UserSwitchOutlined className="text-[20px]" />
|
||||
<span className="text-p3 font-medium">Permissions</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/roles"
|
||||
className={`flex items-center justify-items-start gap-3 px-[8px] py-[10px] ${
|
||||
isActive('/roles')
|
||||
? 'bg-primary-500 text-white rounded-md'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<UsergroupAddOutlined className="text-[20px]" />
|
||||
<span className="text-p3 font-medium">Roles</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/accounts"
|
||||
className={`flex items-center justify-items-start gap-3 px-[8px] py-[10px] ${
|
||||
@@ -72,14 +110,10 @@ export const BackofficeSidebar: FC = (): ReactElement => {
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Log Out Button */}
|
||||
<div className="w-full">
|
||||
<hr className="mb-5 border-primary-200" />
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigate('/');
|
||||
}}
|
||||
onClick={signOut}
|
||||
variant="text"
|
||||
className="items-start justify-start gap-3 px-[8px] py-[10px] text-gray-700 hover:text-red-500 transition-colors w-full"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let value;
|
||||
|
||||
if (props.type === 'number') {
|
||||
value = Number(e.target.value);
|
||||
} else if (props.type === 'file') {
|
||||
value = e.target.files?.[0];
|
||||
} else {
|
||||
value = e.target.value;
|
||||
}
|
||||
field.onChange(value);
|
||||
};
|
||||
|
||||
const inputProps =
|
||||
props.type === 'file'
|
||||
? { ...props, ...field, value: undefined }
|
||||
: { ...props, ...field };
|
||||
|
||||
return (
|
||||
<InputField
|
||||
error={fieldState.error?.message}
|
||||
{...inputProps}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './controlled-input-field';
|
||||
@@ -43,7 +43,7 @@ export const DataTable = <T,>({
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<table className="w-full min-w-full text-base">
|
||||
<thead className="bg-primary-50 mb-3 text-left">
|
||||
<thead className="bg-primary-50 mb-3 text-left text-nowrap">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
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 { useModalLogin, 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);
|
||||
const { setShowModalLogin } = useModalLogin();
|
||||
|
||||
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 +43,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 onClick={() => setShowModalLogin(true)}>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 +87,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>
|
||||
<Button
|
||||
onClick={() => setShowModalLogin(true)}
|
||||
className="block w-full text-gray-100 transition-colors px-4 py-2 text-center font-semibold"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</li>
|
||||
) : (
|
||||
<li>{session.user?.fullname}</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
export * from './api';
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './use-query-state';
|
||||
export * from './use-session';
|
||||
export * from './use-modal-login';
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createContext, ReactNode, useContext, useState } from 'react';
|
||||
|
||||
interface ModalLoginContextType {
|
||||
showModalLogin: boolean;
|
||||
setShowModalLogin: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const ModalLoginContext = createContext<ModalLoginContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
export const ModalLoginProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [showModalLogin, setShowModalLogin] = useState(false);
|
||||
|
||||
return (
|
||||
<ModalLoginContext.Provider value={{ showModalLogin, setShowModalLogin }}>
|
||||
{children}
|
||||
</ModalLoginContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useModalLogin = () => {
|
||||
const context = useContext(ModalLoginContext);
|
||||
if (!context) {
|
||||
throw new Error('useModalLogin must be used within an ModalLoginProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user