Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
280afd68ab | ||
|
|
9777b7bea9 | ||
|
|
6bad504d44 | ||
|
|
6b959a2f22 | ||
|
|
f8f796e071 | ||
|
|
9c7b8c1c63 | ||
|
|
2bd767d20b | ||
|
|
a6fea630a4 | ||
|
|
c05061becf | ||
|
|
c70e5706bc | ||
|
|
49ca2a9fb0 | ||
|
|
47ebd3ad73 | ||
|
|
d544e43abc | ||
|
|
d7a90f47d0 | ||
|
|
984a31c587 | ||
|
|
a575e534b5 | ||
|
|
989503f930 | ||
|
|
3b979119ed | ||
|
|
ecec423d30 | ||
|
|
3bbdba7015 | ||
|
|
f7a7acaf65 | ||
|
|
809eeee931 | ||
|
|
f39466936f | ||
|
|
5d418830b9 | ||
|
|
d58ad58ef8 | ||
|
|
b62c762cf9 | ||
|
|
4e665cca76 | ||
|
|
74c0ebc5dd | ||
|
|
eadd554a32 | ||
|
|
ecdaa4a82e | ||
|
|
747e75e684 | ||
|
|
76637dd051 | ||
|
|
3dda8149ba | ||
|
|
cc21735468 | ||
|
|
a6bc6de72e | ||
|
|
340bd8c4bf | ||
|
|
1594be9d46 | ||
|
|
7eecf2621d | ||
|
|
088a0fc41f | ||
|
|
9178c65c4a | ||
|
|
f93d741cef | ||
|
|
81fa73935f | ||
|
|
88c71ccdfa | ||
|
|
5b3b9e44fa | ||
|
|
ac2ff2ec92 | ||
|
|
79e0d39280 | ||
|
|
fd9113109c | ||
|
|
da7e1067d2 | ||
|
|
75779b0373 | ||
|
|
a3887519f9 | ||
|
|
2ec6ad5b5c | ||
|
|
2d541c71ba | ||
|
|
46f3bd6014 | ||
|
|
c374c97e66 | ||
|
|
a4ca53004f | ||
|
|
2d9473ea7c | ||
|
|
6bfa48bed8 | ||
|
|
43167fe7a1 | ||
|
|
39391e5522 | ||
|
|
ec509a7b43 | ||
|
|
a76bd89ec1 | ||
|
|
cafdb29024 | ||
|
|
d79d0d3d09 | ||
|
|
7606c5b4e2 | ||
|
|
bc9a0fd740 | ||
|
|
7f9a106fca | ||
|
|
0bcc8f7f6a | ||
|
|
31b7587571 | ||
|
|
82fb1ea2bc | ||
|
|
bd5e46e712 | ||
|
|
542469bc67 | ||
|
|
631684cb05 | ||
|
|
54f4572533 | ||
|
|
ca933f624a | ||
|
|
90ab974e03 | ||
|
|
daeeb09798 | ||
|
|
c8935b885e |
@@ -0,0 +1,30 @@
|
||||
## Issue
|
||||
|
||||
<!-- #GitHubIssueNumber / Issue Link / Describe the problem here (if the issue hasn't been defined) -->
|
||||
|
||||
## What did you do?
|
||||
|
||||
<!--
|
||||
- Describe what did you do in this code
|
||||
- Example:
|
||||
- [CORRECT] Add auth feature / Modify auth feature.
|
||||
- [WRONG] Create file /controllers/auth.ts, /models/user.ts, modify /routes/web.ts
|
||||
-->
|
||||
|
||||
## Screenshot / Video
|
||||
|
||||
<!--
|
||||
Attach pictures of this work / video explanation about this work
|
||||
-->
|
||||
|
||||
## Note for Deployment
|
||||
|
||||
<!--
|
||||
- Tell to DevOps what should he do to deploy this change
|
||||
- example:
|
||||
- Please run migration when deploy this
|
||||
- Please upload these files into folder xxx
|
||||
- Please create folder `xxx` and give permission `777`
|
||||
- Please run this SQL after deploy ```sql INSERT INTO xxx (...) ```
|
||||
- etc...
|
||||
-->
|
||||
@@ -44,4 +44,10 @@ Thumbs.db
|
||||
vite.config.*.timestamp*
|
||||
vitest.config.*.timestamp*
|
||||
|
||||
storybook-static
|
||||
storybook-static
|
||||
|
||||
|
||||
.env
|
||||
.env.prod
|
||||
.env.develop
|
||||
.env.staging
|
||||
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 511 KiB |
@@ -0,0 +1,162 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface IModalEditAccount {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleEditAccount?: () => void;
|
||||
currentStep?: number;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const ModalEditAccount = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
resetStep,
|
||||
handleEditAccount,
|
||||
}: IModalEditAccount) => {
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
disableEscapeKeyDown={true}
|
||||
>
|
||||
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||
{currentStep === 2 && (
|
||||
<StepTwo
|
||||
onClose={onClose}
|
||||
handleEditAccount={handleEditAccount}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepOneProps {
|
||||
nextStep: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StepOne = ({ nextStep }: IStepOneProps) => {
|
||||
const [fullName, setFullName] = useState('Ahmad Wiyana');
|
||||
const [email, setEmail] = useState('fullname23@gmail.com');
|
||||
const [phoneNumber, setPhoneNumber] = useState('081904423804');
|
||||
const [address, setAddress] = useState('Jl. Pantai Cibaduyut Indah');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Header>
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Edit Data Akun
|
||||
</h2>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<InputField
|
||||
label="Nama Lengkap"
|
||||
type="text"
|
||||
placeholder="Masukkan Nama Lengkap"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<InputField
|
||||
label="Email"
|
||||
type="text"
|
||||
placeholder="Masukkan Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<InputField
|
||||
label="Nomor Telepon"
|
||||
type="text"
|
||||
placeholder="Masukkan Nomor Telepon"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<InputField
|
||||
label="Alamat"
|
||||
type="text"
|
||||
placeholder="Masukkan Alamat"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={nextStep}
|
||||
>
|
||||
Perbarui Data
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepTwoProps {
|
||||
onClose: () => void;
|
||||
handleEditAccount?: () => void;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const StepTwo = ({ onClose, handleEditAccount, resetStep }: IStepTwoProps) => (
|
||||
<>
|
||||
<Modal.Header className="mb-0 text-center items-center">
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Update Data
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Apakah kamu yakin dengan
|
||||
<br /> perubahan yang dilakukan?
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex gap-4">
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
handleEditAccount && handleEditAccount();
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
|
||||
export default ModalEditAccount;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
export const AppLayout: FC = (): ReactElement => {
|
||||
return (
|
||||
<div className="bg-primary-50 min-h-screen flex justify-center">
|
||||
<div className="bg-primary-50 min-h-screen w-full flex">
|
||||
<BackofficeSidebar />
|
||||
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
@@ -0,0 +1,195 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||
import {
|
||||
FilterOutlined,
|
||||
SearchOutlined,
|
||||
EditOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
PaginationState,
|
||||
useReactTable,
|
||||
RowSelectionState,
|
||||
} from '@tanstack/react-table';
|
||||
import ModalEditAccount from './_components/modal-edit-account';
|
||||
import { useQueryState } from '../../hook/use-query-state';
|
||||
|
||||
interface Account {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
// Mock data for demonstration
|
||||
const mockData: Account[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
|
||||
email: 'fullname23@gmail.com',
|
||||
phone: '081904423804',
|
||||
address: 'Jl. Pantai Cibaduyut Indah',
|
||||
}));
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
const [showModalEditAccount, setShowModalEditAccount] = useState(false);
|
||||
|
||||
const {
|
||||
step: currentStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
resetStep,
|
||||
} = useQueryState('step', {
|
||||
defaultValue: 1,
|
||||
maxValue: 2,
|
||||
minValue: 1,
|
||||
});
|
||||
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
});
|
||||
|
||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
|
||||
const columns: ColumnDef<Account>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'No',
|
||||
accessorKey: 'id',
|
||||
},
|
||||
{
|
||||
header: 'Nama Lengkap',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'Email',
|
||||
accessorKey: 'email',
|
||||
},
|
||||
{
|
||||
header: 'Nomor Telp',
|
||||
accessorKey: 'phone',
|
||||
},
|
||||
{
|
||||
header: 'Alamat Pengiriman',
|
||||
accessorKey: 'address',
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowModalEditAccount(true);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<EditOutlined /> Edit
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: mockData,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||
manualPagination: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||
{/* Header */}
|
||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||
<h1 className="text-p2 font-semibold">Data Akun</h1>
|
||||
</header>
|
||||
{/* Account Table Section */}
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
{/* Search and Filter */}
|
||||
<div className="flex justify-between items-center gap-8 mb-2">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
placeholder="Cari berdasarkan nama lengkap, email"
|
||||
className="pl-12 w-full max-h-full"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||
<SearchOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex items-center gap-3"
|
||||
disabled
|
||||
onClick={() => setShowFilter(!showFilter)}
|
||||
>
|
||||
<FilterOutlined />
|
||||
Filters
|
||||
</Button>
|
||||
{showFilter && (
|
||||
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
|
||||
<Filter onClose={() => setShowFilter(false)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Table */}
|
||||
<DataTable data={mockData} columns={columns} table={table} />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Modal Edit Account */}
|
||||
<ModalEditAccount
|
||||
currentStep={currentStep}
|
||||
isOpen={showModalEditAccount}
|
||||
onClose={() => setShowModalEditAccount(false)}
|
||||
handleEditAccount={() => {
|
||||
console.log('Account updated');
|
||||
}}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Components;
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
|
||||
interface IModalAddItem {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleAddItem: () => void;
|
||||
currentStep?: number;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const ModalAddItem = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentStep,
|
||||
nextStep,
|
||||
resetStep,
|
||||
handleAddItem,
|
||||
}: IModalAddItem) => {
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
disableEscapeKeyDown={true}
|
||||
>
|
||||
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||
{currentStep === 2 && (
|
||||
<StepTwo
|
||||
onClose={onClose}
|
||||
handleAddItem={handleAddItem}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepOneProps {
|
||||
nextStep: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StepOne = ({ nextStep }: IStepOneProps) => (
|
||||
<>
|
||||
<Modal.Header>
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Tambah Item Gacha
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Lengkapi detail di bawah ini untuk menambahkan item gacha
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<InputField
|
||||
label="Nama Hadiah"
|
||||
type="text"
|
||||
placeholder="Masukkan Nama Hadiah"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<InputField
|
||||
label="Chance Rate"
|
||||
type="text"
|
||||
placeholder="Masukkan Chance Rate"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<InputField
|
||||
label="Foto Barang"
|
||||
type="file"
|
||||
placeholder=".jpg, .jpeg, atau .png"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="primary" size="lg" className="w-full" onClick={nextStep}>
|
||||
Tambahkan Item
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
|
||||
interface IStepTwoProps {
|
||||
onClose: () => void;
|
||||
handleAddItem?: () => void;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const StepTwo = ({ onClose, handleAddItem, resetStep }: IStepTwoProps) => (
|
||||
<>
|
||||
<Modal.Header className="mb-0 text-center items-center">
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Tambah Item
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Apakah kamu yakin ingin
|
||||
<br /> menambahkan item ini?
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex gap-4">
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
handleAddItem && handleAddItem();
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
>
|
||||
Tambahkan
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
|
||||
export default ModalAddItem;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
|
||||
interface IModalDeleteItem {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleDeleteItem?: () => void;
|
||||
}
|
||||
|
||||
const ModalDeleteItem = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
handleDeleteItem,
|
||||
}: IModalDeleteItem) => {
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
closeButtonClassName="hidden"
|
||||
>
|
||||
<Modal.Header className="gap-8">
|
||||
<img
|
||||
src="/chibi-delete.webp"
|
||||
alt="Delete item?"
|
||||
width={148}
|
||||
className="self-center"
|
||||
/>
|
||||
<div className="text-center">
|
||||
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
|
||||
Delete Item
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Apakah kamu yakin untuk menghapus item ini?
|
||||
</p>
|
||||
</div>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={onClose}
|
||||
>
|
||||
Batal Hapus
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
handleDeleteItem && handleDeleteItem();
|
||||
}}
|
||||
>
|
||||
Hapus Item
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalDeleteItem;
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
|
||||
interface IModalEditItem {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleEditItem?: () => void;
|
||||
currentStep?: number;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const ModalEditItem = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentStep,
|
||||
nextStep,
|
||||
resetStep,
|
||||
handleEditItem,
|
||||
}: IModalEditItem) => {
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
disableEscapeKeyDown={true}
|
||||
>
|
||||
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||
{currentStep === 2 && (
|
||||
<StepTwo
|
||||
onClose={onClose}
|
||||
handleEditItem={handleEditItem}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepOneProps {
|
||||
nextStep: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StepOne = ({ nextStep }: IStepOneProps) => (
|
||||
<>
|
||||
<Modal.Header>
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Edit Item Gacha
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Silakan mengubah detail dari item yang diperlukan
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<InputField
|
||||
label="Nama Hadiah"
|
||||
type="text"
|
||||
placeholder="Masukkan Nama Hadiah"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<InputField
|
||||
label="Chance Rate"
|
||||
type="text"
|
||||
placeholder="Masukkan Chance Rate"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<InputField
|
||||
label="Foto Barang"
|
||||
type="file"
|
||||
placeholder=".jpg, .jpeg, atau .png"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="primary" size="lg" className="w-full" onClick={nextStep}>
|
||||
Perbarui Item
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
|
||||
interface IStepTwoProps {
|
||||
onClose: () => void;
|
||||
handleEditItem?: () => void;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const StepTwo = ({ onClose, handleEditItem, resetStep }: IStepTwoProps) => (
|
||||
<>
|
||||
<Modal.Header className="mb-0 text-center items-center">
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Update Item
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Apakah kamu yakin dengan
|
||||
<br /> perubahan yang dilakukan?
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex gap-4">
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
handleEditItem && handleEditItem();
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
);
|
||||
|
||||
export default ModalEditItem;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
export const AppLayout: FC = (): ReactElement => {
|
||||
return (
|
||||
<div className="bg-primary-50 min-h-screen flex justify-center">
|
||||
<div className="bg-primary-50 min-h-screen w-full flex">
|
||||
<BackofficeSidebar />
|
||||
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
UsergroupAddOutlined,
|
||||
UsergroupDeleteOutlined,
|
||||
UserSwitchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||
import ModalAddItem from './_components/modal-add-item';
|
||||
import ModalEditItem from './_components/modal-edit-item';
|
||||
import ModalDeleteItem from './_components/modal-delete-item';
|
||||
import { useQueryState } from '../../hook/use-query-state';
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
const [showModalAddItem, setShowModalAddItem] = useState(false);
|
||||
const [showModalEditItem, setShowModalEditItem] = useState(false);
|
||||
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
|
||||
|
||||
const {
|
||||
step: currentStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
resetStep,
|
||||
} = useQueryState('step', {
|
||||
defaultValue: 1,
|
||||
maxValue: 2,
|
||||
minValue: 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||
{/* Dashboard Header */}
|
||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||
<h1 className="text-p2 font-semibold">Dashboard</h1>
|
||||
</header>
|
||||
|
||||
<div className="flex justify-between gap-[40px] p-8 bg-white rounded-md">
|
||||
<div className="w-full flex flex-col gap-[40px]">
|
||||
{/* Summary Section */}
|
||||
<section>
|
||||
<h2 className="text-p2 font-medium text-primary-500 mb-8">
|
||||
Summary
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Participants */}
|
||||
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
||||
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
||||
<UsergroupAddOutlined className="text-[20px]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-p1 font-semibold">1000</h3>
|
||||
<p className="text-label1 text-neutral-500">Participants</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Roll and Reroll */}
|
||||
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
||||
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
||||
<ReloadOutlined className="text-[20px]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-p1 font-semibold">1000</h3>
|
||||
<p className="text-label1 text-neutral-500">
|
||||
Roll and Reroll
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Redeem */}
|
||||
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
||||
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
||||
<UserSwitchOutlined className="text-[20px]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-p1 font-semibold">1000</h3>
|
||||
<p className="text-label1 text-neutral-500">Redeem</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inactive Users */}
|
||||
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
|
||||
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
|
||||
<UsergroupDeleteOutlined className="text-[20px]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-p1 font-semibold">1000</h3>
|
||||
<p className="text-label1 text-neutral-500">
|
||||
Inactive Users
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Gacha Items Section */}
|
||||
<section className="flex flex-col gap-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-p2 font-medium text-primary-500">
|
||||
Gacha Items
|
||||
</h2>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="items-end gap-3"
|
||||
onClick={() => setShowModalAddItem(true)}
|
||||
>
|
||||
<span>Tambah Item</span>
|
||||
<PlusOutlined className="text-[16px]" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Gacha Items List */}
|
||||
<div className="flex flex-col gap-4 max-h-140 overflow-auto">
|
||||
{[1, 2, 3, 4, 5, 6].map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="bg-white max-h-[80px] overflow-clip rounded-lg shadow-sm flex justify-between border border-neutral-100"
|
||||
>
|
||||
<div className="flex flex-col py-4 px-6 gap-4">
|
||||
<div>
|
||||
<h3 className="text-p3 text-primary-500 font-medium">
|
||||
Lanyard IMPHNEN
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-label2 text-gray-500 mt-1">
|
||||
<span>Prize {item}</span>
|
||||
<span>Chance Rate: (0.1%)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-start gap-2">
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="text-[10px] text-neutral-500 p-0 font-normal hover:bg-transparent hover:text-primary-500"
|
||||
onClick={() => setShowModalEditItem(true)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="text-[10px] text-red-500 p-0 font-normal hover:bg-transparent hover:text-red-700"
|
||||
onClick={() => setShowModalDeleteItem(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lebih baik gunakan gambar yang sudah di-clip dengan size height: 78px daripada hard-code object-position dan margin */}
|
||||
<img
|
||||
src="gacha-clip.webp"
|
||||
alt="Lanyard IMPHNEN"
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Right-side illustration */}
|
||||
<img
|
||||
src="gacha.webp"
|
||||
alt=""
|
||||
className="rounded-lg hidden xl:block xl:min-w-[436px] h-auto object-cover"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Modal Add Item */}
|
||||
<ModalAddItem
|
||||
currentStep={currentStep}
|
||||
isOpen={showModalAddItem}
|
||||
onClose={() => setShowModalAddItem(false)}
|
||||
handleAddItem={() => {
|
||||
console.log('Item added');
|
||||
}}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
|
||||
{/* Modal Edit Item */}
|
||||
<ModalEditItem
|
||||
currentStep={currentStep}
|
||||
isOpen={showModalEditItem}
|
||||
onClose={() => setShowModalEditItem(false)}
|
||||
handleEditItem={() => {
|
||||
console.log('Item edited');
|
||||
}}
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
resetStep={resetStep}
|
||||
/>
|
||||
|
||||
{/* Modal Delete Item */}
|
||||
<ModalDeleteItem
|
||||
isOpen={showModalDeleteItem}
|
||||
onClose={() => setShowModalDeleteItem(false)}
|
||||
handleDeleteItem={() => {
|
||||
console.log('Item deleted');
|
||||
}}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Components;
|
||||
@@ -1,11 +1,9 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Navbar } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
export const AppLayout: FC = (): ReactElement => {
|
||||
return (
|
||||
<main className="bg-primary-50 min-h-screen">
|
||||
<Navbar />
|
||||
<Outlet />
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,36 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { InputField } from '@imphnen-frontend-service/ui/molecules';
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
return <>Hallo</>;
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<div className="bg-white border border-primary-200 shadow-lg p-[60px] text-center flex flex-col justify-items-stretch gap-8 rounded-2xl">
|
||||
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
|
||||
<h1 className="text-primary-500 text-p1 font-semibold">
|
||||
Welcome to IMPHNEN Backoffice
|
||||
</h1>
|
||||
<InputField
|
||||
label="Email"
|
||||
placeholder="Masukkan Email"
|
||||
type="email"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<InputField
|
||||
label="Password"
|
||||
placeholder="Masukkan password"
|
||||
type="password"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<Button onClick={() => navigate('/dashboard')}>Login</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Components;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
|
||||
interface IModalProcessDelivery {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleProcessDelivery?: () => void;
|
||||
}
|
||||
|
||||
const ModalProcessDelivery = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
handleProcessDelivery,
|
||||
}: IModalProcessDelivery) => {
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
disableEscapeKeyDown={true}
|
||||
>
|
||||
<Modal.Header>
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Delivery Process
|
||||
</h2>
|
||||
<p className="text-p3 text-neutral-400">
|
||||
Lakukan pengiriman hadiah gacha untuk pengguna di bawah ini, jika
|
||||
sudah ubah status menjadi “Delivered”.
|
||||
</p>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<InputField
|
||||
label="Nama Lengkap"
|
||||
type="text"
|
||||
placeholder="Masukkan Nama Lengkap"
|
||||
value="Ahmad Wiyana"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
readOnly
|
||||
/>
|
||||
<InputField
|
||||
label="Item yang didapatkan"
|
||||
type="text"
|
||||
placeholder="Masukkan Nama Item"
|
||||
value="Lanyard + ID Card"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
readOnly
|
||||
/>
|
||||
<InputField
|
||||
label="Alamat Pengiriman"
|
||||
type="text"
|
||||
placeholder="Masukkan Alamat Pengiriman"
|
||||
value="Jl. Pantai Cibaduyut Indah"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
readOnly
|
||||
/>
|
||||
<InputField
|
||||
label="Status"
|
||||
type="text"
|
||||
placeholder="Isi Status Pengiriman"
|
||||
value="Lanyard + ID Card"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => handleProcessDelivery && handleProcessDelivery()}
|
||||
>
|
||||
Proses Pengiriman
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalProcessDelivery;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
export const AppLayout: FC = (): ReactElement => {
|
||||
return (
|
||||
<div className="bg-primary-50 min-h-screen flex justify-center">
|
||||
<div className="bg-primary-50 min-h-screen w-full flex">
|
||||
<BackofficeSidebar />
|
||||
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
@@ -0,0 +1,251 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||
import {
|
||||
FilterOutlined,
|
||||
SearchOutlined,
|
||||
AuditOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
PaginationState,
|
||||
useReactTable,
|
||||
RowSelectionState,
|
||||
} from '@tanstack/react-table';
|
||||
import ModalProcessDelivery from './_components/modal-process-item';
|
||||
|
||||
type OrderValid = 'valid' | 'invalid' | 'unchecked';
|
||||
type Status = 'undelivered' | 'delivered';
|
||||
|
||||
interface Prize {
|
||||
id: number;
|
||||
name: string;
|
||||
orderValid: OrderValid;
|
||||
items: string;
|
||||
address: string;
|
||||
status: Status;
|
||||
}
|
||||
|
||||
const items = [
|
||||
'Sertifikat + Laminating',
|
||||
'Lanyard + ID Card',
|
||||
'Pin',
|
||||
'Sticker Isi 3',
|
||||
'Sticker Isi 5',
|
||||
'Gelang Karet',
|
||||
];
|
||||
|
||||
// Mock data for transactions
|
||||
const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: 'Nama Lengkap',
|
||||
orderValid: (i % 3 === 0
|
||||
? 'invalid'
|
||||
: i % 5 === 0
|
||||
? 'unchecked'
|
||||
: 'valid') as OrderValid,
|
||||
items: items[i % items.length],
|
||||
address: 'Jl. Pantai Cibaduyut Indah',
|
||||
status: (i % 3 === 0 ? 'undelivered' : 'delivered') as Status,
|
||||
}));
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
const [showModalProcessDelivery, setShowModalProcessDelivery] =
|
||||
useState(false);
|
||||
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
});
|
||||
|
||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
|
||||
const deliveryOptions = [
|
||||
{ id: 'option1', value: 'undelivered', label: 'Undelivered' },
|
||||
{ id: 'option1', value: 'delivered', label: 'Delivered' },
|
||||
];
|
||||
|
||||
const columns: ColumnDef<Prize>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'No',
|
||||
accessorKey: 'id',
|
||||
},
|
||||
{
|
||||
header: 'Nama Lengkap',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'Order Valid?',
|
||||
accessorKey: 'orderValid',
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.orderValid;
|
||||
const statusColors: Record<OrderValid, string> = {
|
||||
valid: 'bg-success-200 text-success-500',
|
||||
invalid: 'bg-danger-200 text-danger-500',
|
||||
unchecked: 'bg-warning-200 text-warning-900',
|
||||
};
|
||||
const statusText: Record<OrderValid, string> = {
|
||||
valid: 'Valid',
|
||||
invalid: 'Invalid',
|
||||
unchecked: 'Unchecked',
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
||||
>
|
||||
{statusText[status]}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Items',
|
||||
accessorKey: 'items',
|
||||
},
|
||||
{
|
||||
header: 'Alamat Pengiriman',
|
||||
accessorKey: 'address',
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status;
|
||||
const statusColors: Record<Status, string> = {
|
||||
delivered: 'bg-success-200 text-success-500',
|
||||
undelivered: 'bg-danger-200 text-danger-500',
|
||||
};
|
||||
const statusText: Record<Status, string> = {
|
||||
delivered: 'Delivered',
|
||||
undelivered: 'Undelivered',
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
||||
>
|
||||
{statusText[status]}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowModalProcessDelivery(true);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full"
|
||||
>
|
||||
<AuditOutlined className="text-[16px]" /> Process
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: mockData,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||
manualPagination: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||
{/* Header */}
|
||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||
<h1 className="text-p2 font-semibold">Data Pengiriman Hadiah</h1>
|
||||
</header>
|
||||
{/* Account Table Section */}
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
{/* Search and Filter */}
|
||||
<div className="flex justify-between items-center gap-8 mb-2">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
placeholder="Cari berdasarkan nama lengkap, nomor order Shopee"
|
||||
className="pl-12 w-full max-h-full"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||
<SearchOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex items-center gap-3"
|
||||
onClick={() => setShowFilter(!showFilter)}
|
||||
>
|
||||
<FilterOutlined />
|
||||
Filters
|
||||
</Button>
|
||||
{showFilter && (
|
||||
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
|
||||
<Filter
|
||||
options={deliveryOptions}
|
||||
onClose={() => setShowFilter(false)}
|
||||
onFilterChange={(value) => {
|
||||
console.log('Selected filter:', value);
|
||||
// Filter logic di sini
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Table */}
|
||||
<DataTable data={mockData} columns={columns} table={table} />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Modal Process Delivery */}
|
||||
<ModalProcessDelivery
|
||||
isOpen={showModalProcessDelivery}
|
||||
onClose={() => setShowModalProcessDelivery(false)}
|
||||
handleProcessDelivery={() => {
|
||||
console.log('Action ketika user menekan tombol Proses Pengiriman');
|
||||
}}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Components;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
|
||||
interface IModalValidate {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
handleValid?: () => void;
|
||||
handleInvalid?: () => void;
|
||||
}
|
||||
|
||||
const ModalValidate = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
handleValid,
|
||||
handleInvalid,
|
||||
}: IModalValidate) => {
|
||||
return (
|
||||
<Modal
|
||||
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
disableEscapeKeyDown={true}
|
||||
>
|
||||
<Modal.Header className="mb-0 text-center items-center">
|
||||
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
|
||||
Validasi Transaksi
|
||||
</h2>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="flex flex-col gap-8">
|
||||
<InputField
|
||||
label="Nomor Transaksi"
|
||||
type="text"
|
||||
placeholder="Masukkan Nomor Transaksi"
|
||||
value="2502133Y9AFVBO"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="lg"
|
||||
className="w-full border-danger-500 text-danger-500 hover:border-danger-700 hover:text-danger-700"
|
||||
onClick={() => handleInvalid && handleInvalid()}
|
||||
>
|
||||
Tidak Valid
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => handleValid && handleValid()}
|
||||
>
|
||||
Valid
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalValidate;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
export const AppLayout: FC = (): ReactElement => {
|
||||
return (
|
||||
<div className="bg-primary-50 min-h-screen flex justify-center">
|
||||
<div className="bg-primary-50 min-h-screen w-full flex">
|
||||
<BackofficeSidebar />
|
||||
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
@@ -0,0 +1,214 @@
|
||||
import * as React from 'react';
|
||||
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||
import {
|
||||
FilterOutlined,
|
||||
SearchOutlined,
|
||||
AuditOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
PaginationState,
|
||||
useReactTable,
|
||||
RowSelectionState,
|
||||
} from '@tanstack/react-table';
|
||||
import ModalValidate from './_components/modal-validate';
|
||||
|
||||
type TransactionStatus = 'valid' | 'invalid' | 'unchecked';
|
||||
|
||||
interface Transaction {
|
||||
id: number;
|
||||
name: string;
|
||||
transactionNumber: string;
|
||||
status: TransactionStatus;
|
||||
}
|
||||
|
||||
// Mock data for transactions
|
||||
const mockTransactions: Transaction[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
|
||||
transactionNumber: '25D2133Y9AFYBD',
|
||||
status: (i % 3 === 0
|
||||
? 'invalid'
|
||||
: i % 5 === 0
|
||||
? 'unchecked'
|
||||
: 'valid') as TransactionStatus,
|
||||
}));
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
const [showModalValidate, setShowModalValidate] = useState(false);
|
||||
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
});
|
||||
|
||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
|
||||
const validationOptions = [
|
||||
{ id: 'option1', value: 'unchecked', label: 'Unchecked' },
|
||||
{ id: 'option2', value: 'valid', label: 'Valid' },
|
||||
{ id: 'option3', value: 'invalid', label: 'Invalid' },
|
||||
];
|
||||
|
||||
const columns: ColumnDef<Transaction>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'No',
|
||||
accessorKey: 'id',
|
||||
},
|
||||
{
|
||||
header: 'Nama Lengkap',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'Nomor Transaksi',
|
||||
accessorKey: 'transactionNumber',
|
||||
},
|
||||
{
|
||||
header: 'Order Valid?',
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status;
|
||||
const statusColors: Record<TransactionStatus, string> = {
|
||||
valid: 'bg-success-200 text-success-500',
|
||||
invalid: 'bg-danger-200 text-danger-500',
|
||||
unchecked: 'bg-warning-200 text-warning-900',
|
||||
};
|
||||
const statusText: Record<TransactionStatus, string> = {
|
||||
valid: 'Valid',
|
||||
invalid: 'Invalid',
|
||||
unchecked: 'Unchecked',
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
||||
>
|
||||
{statusText[status]}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowModalValidate(true);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full"
|
||||
>
|
||||
<AuditOutlined className="text-[16px]" /> Update
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: mockTransactions,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(mockTransactions.length / pagination.pageSize),
|
||||
manualPagination: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||
{/* Header */}
|
||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||
<h1 className="text-p2 font-semibold">Validasi Transaksi</h1>
|
||||
</header>
|
||||
|
||||
{/* Account Table Section */}
|
||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||
{/* Search and Filter */}
|
||||
<div className="flex justify-between items-center gap-8 mb-2">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
placeholder="Cari berdasarkan nama lengkap, nomor order Shopee"
|
||||
className="pl-12 w-full max-h-full"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||
<SearchOutlined />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex items-center gap-3"
|
||||
onClick={() => setShowFilter(!showFilter)}
|
||||
>
|
||||
<FilterOutlined />
|
||||
Filters
|
||||
</Button>
|
||||
{showFilter && (
|
||||
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
|
||||
<Filter
|
||||
options={validationOptions}
|
||||
onClose={() => setShowFilter(false)}
|
||||
onFilterChange={(value) => {
|
||||
console.log('Selected filter:', value);
|
||||
// Filter logic di sini
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<DataTable data={mockTransactions} columns={columns} table={table} />
|
||||
</section>
|
||||
</main>
|
||||
<ModalValidate
|
||||
isOpen={showModalValidate}
|
||||
onClose={() => setShowModalValidate(false)}
|
||||
handleValid={() => {
|
||||
console.log('Action ketika user klik Valid');
|
||||
}}
|
||||
handleInvalid={() => {
|
||||
console.log('Action ketika user klik Tidak Valid');
|
||||
}}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Components;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -17,59 +17,55 @@
|
||||
|
||||
--color-neutral-50: #f6f6f6;
|
||||
--color-neutral-100: #e7e7e7;
|
||||
--color-neutral-200: #d0d0d0;
|
||||
--color-neutral-300: #b7b7b7;
|
||||
--color-neutral-400: #9f9f9f;
|
||||
--color-neutral-500: #7e7e7e;
|
||||
--color-neutral-200: #d1d1d1;
|
||||
--color-neutral-300: #b0b0b0;
|
||||
--color-neutral-400: #888888;
|
||||
--color-neutral-500: #6d6d6d;
|
||||
--color-neutral-600: #5d5d5d;
|
||||
--color-neutral-700: #4a4a4a;
|
||||
--color-neutral-800: #3a3a3a;
|
||||
--color-neutral-900: #2f2f2f;
|
||||
--color-neutral-700: #4f4f4f;
|
||||
--color-neutral-800: #454545;
|
||||
--color-neutral-900: #3d3d3d;
|
||||
--color-neutral-950: #2b2b2b;
|
||||
|
||||
--color-success-50: #e7f2ee;
|
||||
--color-success-100: #cde6dd;
|
||||
--color-success-200: #9bccbc;
|
||||
--color-success-300: #78bdab;
|
||||
--color-success-400: #4ca88f;
|
||||
--color-success-500: #349078;
|
||||
--color-success-600: #2f7c66;
|
||||
--color-success-700: #26624f;
|
||||
--color-success-800: #1e4a3b;
|
||||
--color-success-900: #1b513b;
|
||||
--color-success-100: #e0fbd8;
|
||||
--color-success-200: #bcf8b0;
|
||||
--color-success-300: #8eea85;
|
||||
--color-success-400: #63d564;
|
||||
--color-success-500: #35ba43;
|
||||
--color-success-600: #269f3e;
|
||||
--color-success-700: #1a8439;
|
||||
--color-success-800: #106b32;
|
||||
--color-success-900: #0b592f;
|
||||
|
||||
--color-info-50: #e7f4f5;
|
||||
--color-info-100: #cce9ea;
|
||||
--color-info-200: #99d3d5;
|
||||
--color-info-300: #78c4c7;
|
||||
--color-info-400: #3aa9ad;
|
||||
--color-info-500: #279599;
|
||||
--color-info-600: #207d82;
|
||||
--color-info-700: #1a666b;
|
||||
--color-info-800: #124c52;
|
||||
--color-info-900: #093d43;
|
||||
--color-info-100: #ccfcfe;
|
||||
--color-info-200: #9bf3fd;
|
||||
--color-info-300: #67e3fb;
|
||||
--color-info-400: #42cdf8;
|
||||
--color-info-500: #04acf3;
|
||||
--color-info-600: #0185d0;
|
||||
--color-info-700: #0264af;
|
||||
--color-info-800: #01478d;
|
||||
--color-info-900: #003375;
|
||||
|
||||
--color-warning-50: #fffce6;
|
||||
--color-warning-100: #fff8cc;
|
||||
--color-warning-200: #fef39b;
|
||||
--color-warning-300: #fde967;
|
||||
--color-warning-400: #fbd93d;
|
||||
--color-warning-500: #f3c215;
|
||||
--color-warning-600: #d7a20f;
|
||||
--color-warning-700: #aa7e0c;
|
||||
--color-warning-800: #805c07;
|
||||
--color-warning-900: #665c00;
|
||||
--color-warning-100: #fffcd3;
|
||||
--color-warning-200: #fffaa9;
|
||||
--color-warning-300: #fff67d;
|
||||
--color-warning-400: #fff25d;
|
||||
--color-warning-500: #ffed27;
|
||||
--color-warning-600: #dbc91d;
|
||||
--color-warning-700: #b7a714;
|
||||
--color-warning-800: #93850b;
|
||||
--color-warning-900: #7a6d07;
|
||||
|
||||
--color-danger-50: #ffe7e7;
|
||||
--color-danger-100: #ffcece;
|
||||
--color-danger-200: #ff9f9f;
|
||||
--color-danger-300: #ff6e6e;
|
||||
--color-danger-400: #fa4646;
|
||||
--color-danger-500: #ef1f1f;
|
||||
--color-danger-600: #d11515;
|
||||
--color-danger-700: #a51111;
|
||||
--color-danger-800: #7f0c0c;
|
||||
--color-danger-900: #5d2d2d;
|
||||
--color-danger-100: #ffe8da;
|
||||
--color-danger-200: #ffcbb3;
|
||||
--color-danger-300: #ffaa8d;
|
||||
--color-danger-400: #ff8870;
|
||||
--color-danger-500: #ff5242;
|
||||
--color-danger-600: #da3030;
|
||||
--color-danger-700: #b7212d;
|
||||
--color-danger-800: #93152a;
|
||||
--color-danger-900: #7a0c27;
|
||||
|
||||
--radius-none: 0px;
|
||||
--radius-sm: 2px;
|
||||
@@ -137,4 +133,4 @@
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 8.6 MiB |
@@ -0,0 +1,12 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
export const AppLayout: FC = (): ReactElement => {
|
||||
return (
|
||||
<main className="bg-primary-50 min-h-screen">
|
||||
<Outlet />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
export const AppLayout: FC = (): ReactElement => {
|
||||
return (
|
||||
<main className="bg-primary-50 min-h-screen">
|
||||
<Outlet />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { RegisterResetBanner } from "@imphnen-frontend-service/ui/organisms";
|
||||
import { ForgotStep, OtpForm } from "@imphnen-frontend-service/ui/molecules";
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
return (
|
||||
<div className='flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]'>
|
||||
<div className='bg-white min-w-[1120px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6'>
|
||||
<RegisterResetBanner />
|
||||
<div className='border-2 border-primary-500/50 w-[596px] rounded-lg py-[70px] px-[48px] flex flex-col justify-center'>
|
||||
<ForgotStep step={2} />
|
||||
<h3 className='mt-5 text-3xl font-semibold text-primary-500'>Forgot Password</h3>
|
||||
<h5 className='mt-2 text-xl font-medium text-primary-500'>Yeay~! Pesan dari dunia lain (a.k.a email-mu) sudah tiba!</h5>
|
||||
<OtpForm />
|
||||
<Button>Summon Password Baru!</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Components;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { RegisterResetBanner } from "@imphnen-frontend-service/ui/organisms";
|
||||
import { ForgotStep } from "@imphnen-frontend-service/ui/molecules";
|
||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
return (
|
||||
<div className='flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]'>
|
||||
<div className='bg-white min-w-[1120px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6'>
|
||||
<RegisterResetBanner />
|
||||
<div className='border-2 border-primary-500/50 w-[596px] rounded-lg py-[70px] px-[48px] flex flex-col justify-center'>
|
||||
<ForgotStep step={1} />
|
||||
<h3 className='mt-5 text-3xl font-semibold text-primary-500'>Forgot Password</h3>
|
||||
<h5 className='mt-2 text-xl font-medium text-primary-500'>Masukkan email-mu, dan biarkan kami memanggil password-mu kembali dari dunia lain!</h5>
|
||||
<h6 className='text-gray-600 mt-8'>Email</h6>
|
||||
<Input type='email' size='lg' placeholder='Contoh: yourname@mail.com'></Input>
|
||||
<Button variant='primary' className='mt-7'>Kirim Kode OTP ^^</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Components;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
export const AppLayout: FC = (): ReactElement => {
|
||||
return (
|
||||
<main className="bg-primary-50 min-h-screen">
|
||||
<Outlet />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { RegisterResetBanner } from "@imphnen-frontend-service/ui/organisms";
|
||||
import { ForgotStep } from "@imphnen-frontend-service/ui/molecules";
|
||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
return (
|
||||
<div className='flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]'>
|
||||
<div className='bg-white min-w-[1120px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6'>
|
||||
<RegisterResetBanner />
|
||||
<div className='border-2 border-primary-500/50 w-[596px] rounded-lg py-[70px] px-[48px] flex flex-col justify-center'>
|
||||
<ForgotStep step={3} />
|
||||
<h3 className='mt-5 text-3xl font-semibold text-primary-500'>Forgot Password</h3>
|
||||
<h5 className='mt-2 text-md font-medium text-primary-500'>Sekarang, buatlah password baru yang lebih kuat, seperti jurus andalanmu!<span role="img" aria-label='emoji'>🔥⚡</span></h5>
|
||||
<div className='my-4'>
|
||||
<h6 className='text-gray-700'>Password Baru</h6>
|
||||
<Input className='w-full' type="password" placeholder='Buat password sekokoh armor legendary!'/>
|
||||
<h6 className='text-gray-700 mt-5'>Ulang Password Baru</h6>
|
||||
<Input className='w-full' type="password" placeholder='Pastikan Cocok! Jangan sampai ada typo, Senpai~!'/>
|
||||
</div>
|
||||
<div className='mb-4 text-gray-700 flex flex-col gap-3 text-sm'>
|
||||
<h6><span role='img' aria-label='emoji'>💡</span> Tips dari kami:</h6>
|
||||
<h6><span role='img' aria-label='emoji'>✅ </span> Buat kombinasi huruf besar, kecil, angka, dan simbol untuk kekuatan maksimal!</h6>
|
||||
<h6><span role='img' aria-label='emoji'>✅</span> Pastikan kamu ingat password-mu atau simpan di tempat aman~</h6>
|
||||
<h6>Masukkan password baru dan bersiaplah untuk kembali bertualang!</h6>
|
||||
</div>
|
||||
<Button className='mt-5'>Summon Password Baru!</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Components;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
export const AppLayout: FC = (): ReactElement => {
|
||||
return (
|
||||
<main className="bg-primary-50 min-h-screen">
|
||||
<Outlet />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { LoginBanner } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { InputField } from '@imphnen-frontend-service/ui/molecules';
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const error = searchParams.get('error'); // Ambil nilai ?error=
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]">
|
||||
<div className="bg-white min-w-[1120px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6">
|
||||
<LoginBanner />
|
||||
<div className="border-2 border-primary-500/50 w-[596px] rounded-lg py-[70px] px-[96px] flex justify-center">
|
||||
<div className="w-[404px]">
|
||||
<h2 className="text-4xl font-semibold text-primary-500 text-center mb-2">
|
||||
Hallo Minna-san
|
||||
</h2>
|
||||
<h5 className="text-xl font-medium text-primary-500 text-center">
|
||||
Welcome to Dimentorin by IMPHNEN
|
||||
</h5>
|
||||
<InputField
|
||||
label="Email"
|
||||
error={error ? error : undefined}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
placeholder="Masukkan email-mu, Senpai~! ✨ (Pastikan tidak typo, ya~ 😆)"
|
||||
/>
|
||||
<InputField
|
||||
label="password"
|
||||
error={error ? error : undefined}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
type="password"
|
||||
placeholder="Masukkan password rahasiamu!"
|
||||
/>
|
||||
<div className="flex justify-end my-5">
|
||||
<a href="/auth/forgot" className="text-primary-500 font-medium">
|
||||
Lupa Password ?
|
||||
</a>
|
||||
</div>
|
||||
<Button className="w-full">Enter Isekai</Button>
|
||||
<div className="flex my-3 gap-3 justify-center">
|
||||
<h5>Belum Punya akun ?</h5>
|
||||
<a href="/auth/register" className="text-primary-500 font-medium">
|
||||
Daftar Disini
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center w-full">
|
||||
<div className="flex-grow border-t border-blue-400 opacity-50"></div>
|
||||
<span className="px-3 text-blue-400">Or</span>
|
||||
<div className="flex-grow border-t border-blue-400 opacity-50"></div>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full my-3 text-gray-500 gap-2"
|
||||
variant="secondary"
|
||||
>
|
||||
<p>Log In With Google</p>
|
||||
<img
|
||||
src="/image/33978ce5bed2da9bf9d73acad802182a.webp"
|
||||
alt="Google Icon"
|
||||
width={24}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Components;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
export const AppLayout: FC = (): ReactElement => {
|
||||
return (
|
||||
<main className="bg-primary-50 min-h-screen">
|
||||
<Outlet />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { RegisterResetBanner } from "@imphnen-frontend-service/ui/organisms";
|
||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
return (
|
||||
<div className='flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]'>
|
||||
<div className='bg-white min-w-[1120px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6'>
|
||||
<RegisterResetBanner />
|
||||
<div className='border-2 border-primary-500/50 w-[596px] rounded-lg py-[70px] px-[96px] flex justify-center'>
|
||||
<div className='w-[404px]'>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Components;
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import {
|
||||
InputField,
|
||||
Modal,
|
||||
Stepper,
|
||||
} from '@imphnen-frontend-service/ui/molecules';
|
||||
import { useQueryState } from '@imphnen-frontend-service/utils';
|
||||
import { Fragment } from 'react/jsx-runtime';
|
||||
interface IModalFormForgotPasswordProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ModalFormForgotPassword = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: IModalFormForgotPasswordProps) => {
|
||||
const {
|
||||
step: currentStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
resetStep,
|
||||
} = useQueryState('step', {
|
||||
defaultValue: 1,
|
||||
maxValue: 3,
|
||||
minValue: 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
>
|
||||
<Modal.Header className="space-y-4">
|
||||
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
|
||||
<h1 className="text-primary-500 text-p1 text-center font-semibold">
|
||||
Forgot Password
|
||||
</h1>
|
||||
<Stepper currentStep={currentStep} totalSteps={3} />
|
||||
</Modal.Header>
|
||||
<Modal.Content className="space-y-6">
|
||||
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||
{currentStep === 2 && (
|
||||
<StepTwo nextStep={nextStep} prevStep={prevStep} />
|
||||
)}
|
||||
{currentStep === 3 && (
|
||||
<StepThree onClose={onClose} resetStep={resetStep} />
|
||||
)}
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepOneProps {
|
||||
nextStep: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StepOne = ({ nextStep, onClose }: IStepOneProps) => (
|
||||
<>
|
||||
<InputField
|
||||
label="Email"
|
||||
placeholder="Masukkan Email yang Terdaftar"
|
||||
type="email"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex gap-6">
|
||||
<Button variant="bordered" size="md" className="w-full" onClick={onClose}>
|
||||
Back To Login
|
||||
</Button>
|
||||
<Button size="md" className="w-full" onClick={nextStep}>
|
||||
Kirim OTP
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
interface IStepTwoProps {
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
}
|
||||
|
||||
const StepTwo = ({ nextStep, prevStep }: IStepTwoProps) => (
|
||||
<Fragment>
|
||||
<InputField
|
||||
label="Kode OTP"
|
||||
placeholder="Masukkan Kode OTP"
|
||||
type="text"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex gap-6">
|
||||
<Button
|
||||
size="md"
|
||||
variant="bordered"
|
||||
className="w-full"
|
||||
onClick={prevStep}
|
||||
>
|
||||
Change Email
|
||||
</Button>
|
||||
<Button size="md" className="w-full" onClick={nextStep}>
|
||||
Reset Password
|
||||
</Button>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
interface IStepThreeProps {
|
||||
onClose: () => void;
|
||||
resetStep: () => void;
|
||||
}
|
||||
|
||||
const StepThree = ({ onClose, resetStep }: IStepThreeProps) => (
|
||||
<>
|
||||
<InputField
|
||||
label="Password Baru"
|
||||
placeholder="Masukkan Password Baru"
|
||||
type="password"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<InputField
|
||||
label="Ulang Password"
|
||||
placeholder="Masukkan Ulang Password"
|
||||
type="password"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<Button
|
||||
size="md"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
console.log('Password reset submitted');
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
>
|
||||
Buat Password Baru
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
export default ModalFormForgotPassword;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { useLogin } from '../../_hooks/use-login';
|
||||
|
||||
interface IModalFormLogin {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onForgotPassword: () => void;
|
||||
setIsOpenRegisterModal: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const ModalFormLogin = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onForgotPassword,
|
||||
setIsOpenRegisterModal,
|
||||
}: IModalFormLogin) => {
|
||||
const { form, onSubmit } = useLogin();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Modal.Header className="space-y-4">
|
||||
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
|
||||
<h1 className="text-primary-500 text-p1 text-center font-semibold">
|
||||
Login
|
||||
</h1>
|
||||
</Modal.Header>
|
||||
<Modal.Content>
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Email"
|
||||
placeholder="Masukkan Email"
|
||||
type="email"
|
||||
name="email"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Password"
|
||||
placeholder="Masukkan Password"
|
||||
type="password"
|
||||
name="password"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<h1 className="text-end text-primary-500 text-xl font-medium">
|
||||
<Button variant="text" onClick={onForgotPassword}>
|
||||
Lupa Password?
|
||||
</Button>
|
||||
</h1>
|
||||
<Button type="submit" size="md" className="w-full">
|
||||
Login
|
||||
</Button>
|
||||
<div className="flex justify-center gap-2 pt-2.5 font-medium">
|
||||
<p className="text-neutral-500">Belum punya akun?</p>
|
||||
<Button
|
||||
variant="text"
|
||||
className="text-primary-500 hover:text-primary-600 m-0 p-0"
|
||||
onClick={() => setIsOpenRegisterModal(true)}
|
||||
>
|
||||
Daftar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalFormLogin;
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { InputField, Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
import { useQueryState } from '@imphnen-frontend-service/utils';
|
||||
|
||||
interface IModalFormRegisterProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ModalFormRegister = ({ isOpen, onClose }: IModalFormRegisterProps) => {
|
||||
const {
|
||||
step: currentStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
resetStep,
|
||||
} = useQueryState('registerStep', {
|
||||
defaultValue: 1,
|
||||
maxValue: 2,
|
||||
minValue: 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
resetStep();
|
||||
}}
|
||||
>
|
||||
<Modal.Header className="space-y-4">
|
||||
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
|
||||
<h1 className="text-primary-500 text-p1 text-center font-semibold">
|
||||
Register
|
||||
</h1>
|
||||
<h2 className="text-neutral-500 text-lg text-center">
|
||||
{currentStep === 1 ? 'Info Akun' : 'Informasi Pengiriman Hadiah'}
|
||||
</h2>
|
||||
</Modal.Header>
|
||||
<Modal.Content className="space-y-6">
|
||||
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />}
|
||||
{currentStep === 2 && (
|
||||
<StepTwo nextStep={nextStep} prevStep={prevStep} />
|
||||
)}
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStepOneProps {
|
||||
nextStep: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StepOne = ({ nextStep, onClose }: IStepOneProps) => (
|
||||
<>
|
||||
<InputField
|
||||
label="Nama Lengkap"
|
||||
placeholder="Masukkan Nama Lengkap"
|
||||
type="text"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<InputField
|
||||
label="Email"
|
||||
placeholder="Masukkan Email Anda"
|
||||
type="email"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<InputField
|
||||
label="Password"
|
||||
placeholder="Masukkan Password"
|
||||
type="password"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<InputField
|
||||
label="Ulangi Password"
|
||||
placeholder="Masukkan Ulang Password"
|
||||
type="password"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<Button size="md" className="w-full" onClick={nextStep}>
|
||||
Selanjutnya
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
interface IStepTwoProps {
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
}
|
||||
|
||||
const StepTwo = ({ nextStep, prevStep }: IStepTwoProps) => (
|
||||
<>
|
||||
<InputField
|
||||
label="Nomor Telepon"
|
||||
placeholder="Masukkan Nomor Telepon Aktif"
|
||||
type="text"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<InputField
|
||||
label="Alamat Pengiriman"
|
||||
placeholder="Masukkan Alamat Pengiriman"
|
||||
type="text"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex gap-6">
|
||||
<Button size="md" variant="text" className="w-[40%]" onClick={prevStep}>
|
||||
Kembali
|
||||
</Button>
|
||||
<Button size="md" className="w-full" onClick={nextStep}>
|
||||
Gacha Sekarang
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default ModalFormRegister;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { FC, ReactElement } from 'react';
|
||||
|
||||
type TGachaItem = {
|
||||
src: string;
|
||||
label: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const GachaItem: FC<TGachaItem> = ({
|
||||
src,
|
||||
label,
|
||||
className,
|
||||
}): ReactElement => {
|
||||
return (
|
||||
<div className="snap-center flex-auto justify-center items-center flex flex-col min-w-full overflow-hidden">
|
||||
<div className="max-h-[180px] md:max-h-[270px] md:max-w-[500px] mb-2 md:mb-5 flex flex-1 justify-center items-center">
|
||||
<img
|
||||
src={src}
|
||||
alt="Banner"
|
||||
width={255}
|
||||
height={255}
|
||||
className={cn('h-[150px] md:h-[255px] object-contain', className)}
|
||||
/>
|
||||
</div>
|
||||
<p className="font-semibold text-center md:text-p2">{label}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
authLoginSchema,
|
||||
TLoginRequest,
|
||||
usePostLogin,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
export const useLogin = () => {
|
||||
const postLogin = usePostLogin();
|
||||
const form = useForm<TLoginRequest>({
|
||||
resolver: zodResolver(authLoginSchema),
|
||||
mode: 'all',
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => {
|
||||
postLogin.mutate(data, {
|
||||
onSuccess: () => console.log('Success Login'),
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
form,
|
||||
onSubmit,
|
||||
};
|
||||
};
|
||||
@@ -1,14 +1,16 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Navbar } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { ModalLoginProvider } from '@imphnen-frontend-service/utils';
|
||||
|
||||
export const AppLayout: FC = (): ReactElement => {
|
||||
return (
|
||||
<main className="bg-primary-50 min-h-screen">
|
||||
<Navbar />
|
||||
<Outlet />
|
||||
</main>
|
||||
<ModalLoginProvider>
|
||||
<main className="bg-primary-50 min-h-screen">
|
||||
<Navbar />
|
||||
<Outlet />
|
||||
</main>
|
||||
</ModalLoginProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { ArrowDownOutlined } from '@ant-design/icons';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { ModalsGacha } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { FC, Fragment, ReactElement, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface GachaItemProps {
|
||||
src: string;
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
import { FC, Fragment, ReactElement, useState } from 'react';
|
||||
import ModalFormForgotPassword from './_components/form/modal-form-forgot-password';
|
||||
import ModalFormLogin from './_components/form/modal-form-login';
|
||||
import ModalFormRegister from './_components/form/modal-form-register';
|
||||
import { GachaItem } from './_components/item/gacha-item';
|
||||
import { useModalLogin } from '@imphnen-frontend-service/utils';
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
// Pinjem
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
//
|
||||
const { showModalLogin, setShowModalLogin } = useModalLogin();
|
||||
const [showModalForgotPassword, setShowModalForgotPassword] = useState(false);
|
||||
const [showModalRegister, setShowModalRegister] = useState(false);
|
||||
|
||||
const scrollToRoulette = () => {
|
||||
const rouletteSection = document.getElementById('roulette');
|
||||
@@ -23,52 +19,13 @@ export const Components: FC = (): ReactElement => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const gachaPlaySection = document.getElementById('gacha-play');
|
||||
if (gachaPlaySection) {
|
||||
let currentIndex = 0;
|
||||
const items = gachaPlaySection.children;
|
||||
const totalItems = items.length;
|
||||
|
||||
const scrollItems = () => {
|
||||
if (currentIndex >= totalItems) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
items[currentIndex].scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
inline: 'center',
|
||||
});
|
||||
currentIndex++;
|
||||
};
|
||||
|
||||
const intervalId = setInterval(scrollItems, 2800);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const GachaItem: FC<GachaItemProps> = ({
|
||||
src,
|
||||
label,
|
||||
className,
|
||||
}): ReactElement => {
|
||||
return (
|
||||
<div className="snap-center flex-auto justify-center items-center flex flex-col min-w-full">
|
||||
<div className="max-h-[180px] md:max-h-[270px] md:max-w-[500px] mb-2 md:mb-5 flex flex-1 justify-center items-center">
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className={cn('h-[150px] md:h-[255px] object-contain', className)}
|
||||
/>
|
||||
</div>
|
||||
<p className="font-semibold text-center md:text-p2">{label}</p>
|
||||
</div>
|
||||
);
|
||||
const handleForgotPasswordClick = () => {
|
||||
setShowModalLogin(false);
|
||||
setShowModalForgotPassword(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{/* Landing Page */}
|
||||
<section
|
||||
id="landing-page"
|
||||
className="my-12 md:my-24 grid grid-cols-4 md:grid-cols-8 lg:grid-cols-12 justify-items-center max-w-[1280px] items-center justify-center mx-[32px] md:mx-[60px] lg:mx-[80px] xl:mx-auto"
|
||||
@@ -131,22 +88,28 @@ export const Components: FC = (): ReactElement => {
|
||||
~ 175k ~
|
||||
</div>
|
||||
</div>
|
||||
<div className="order-2 flex flex-col col-span-4 justify-center items-center mb-8 relative">
|
||||
<div className="order-2 flex flex-col col-span-4 justify-center items-center mb-8 relative overflow-hidden">
|
||||
<div className="relative h-[130px] md:h-[230px]">
|
||||
<img
|
||||
width={400}
|
||||
height={400}
|
||||
src="/merch/2.png"
|
||||
alt="Merch 2"
|
||||
className="relative top-[5px] md:top-[8px] left-0 md:left-[5px] z-10 w-[165px] md:w-[277px]"
|
||||
/>
|
||||
<img
|
||||
width={400}
|
||||
height={400}
|
||||
className="absolute top-0 -left-[5px] md:-left-[8px] z-0 min-w-[174px] md:min-w-[302px]"
|
||||
src="/merch/Vector-2.svg"
|
||||
alt=""
|
||||
alt="Merch 3"
|
||||
/>
|
||||
<img
|
||||
width={400}
|
||||
height={400}
|
||||
className="absolute hidden lg:block -bottom-[100px] -left-[55px]"
|
||||
src="/landing-arrow-2.svg"
|
||||
alt=""
|
||||
alt="Merch 1"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-3 py-1 text-primary-500 font-semibold text-base md:text-p2 bg-white shadow-md rounded">
|
||||
@@ -157,7 +120,7 @@ export const Components: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/* Roulette Page */}
|
||||
|
||||
<section
|
||||
id="roulette"
|
||||
className="mt-20 pb-50 lg:py-90 mx-[32px] md:mx-[60px] lg:mx-[80px] xl:mx-auto lg:max-w-[1280px] grid grid-cols-4 md:grid-cols-8 lg:grid-cols-12 justify-items-center gap-y-16 md:gap-y-28"
|
||||
@@ -185,10 +148,7 @@ export const Components: FC = (): ReactElement => {
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="roulette-spin"
|
||||
className="col-span-4 md:col-span-8 lg:col-span-6 flex flex-col items-center gap-4 md:gap-8 overflow-x-hidden"
|
||||
>
|
||||
<div className="col-span-4 md:col-span-8 lg:col-span-6 flex flex-col items-center gap-4 md:gap-8 overflow-x-hidden">
|
||||
<div className="bg-white text-primary-500 font-medium text-p3 md:text-h3 shadow py-2 px-4 md:py-4 md:px-8 max-w-fit rounded-md md:rounded-lg">
|
||||
Here Take Your Prize
|
||||
</div>
|
||||
@@ -218,21 +178,32 @@ export const Components: FC = (): ReactElement => {
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
{/* Diffuser & Cloud */}
|
||||
<div className="sticky bottom-0 h-[86px] md:h-[200px] bg-gradient-to-b from-primary-500/0 to-primary-500/50 to-80%"></div>
|
||||
|
||||
{/**Izin pake untuk debug modals gacha */}
|
||||
<div>
|
||||
<p>Debug : Test Modals Gacha</p>
|
||||
<Button onClick={
|
||||
() => setShowModal(true)
|
||||
}>Tekan</Button>
|
||||
</div>
|
||||
{showModal && createPortal(
|
||||
<ModalsGacha></ModalsGacha>,
|
||||
document.body
|
||||
)}
|
||||
{/**Izin pake untuk debug modals gacha */}
|
||||
<ModalFormLogin
|
||||
isOpen={showModalLogin}
|
||||
onClose={() => setShowModalLogin(false)}
|
||||
onForgotPassword={handleForgotPasswordClick}
|
||||
setIsOpenRegisterModal={setShowModalRegister}
|
||||
key="login"
|
||||
/>
|
||||
|
||||
<ModalFormRegister
|
||||
isOpen={showModalRegister}
|
||||
onClose={() => {
|
||||
setShowModalRegister(false);
|
||||
}}
|
||||
key="register"
|
||||
/>
|
||||
|
||||
<ModalFormForgotPassword
|
||||
isOpen={showModalForgotPassword}
|
||||
onClose={() => {
|
||||
setShowModalForgotPassword(false);
|
||||
setShowModalLogin(true);
|
||||
}}
|
||||
key="forgot-password"
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,59 +17,55 @@
|
||||
|
||||
--color-neutral-50: #f6f6f6;
|
||||
--color-neutral-100: #e7e7e7;
|
||||
--color-neutral-200: #d0d0d0;
|
||||
--color-neutral-300: #b7b7b7;
|
||||
--color-neutral-400: #9f9f9f;
|
||||
--color-neutral-500: #7e7e7e;
|
||||
--color-neutral-200: #d1d1d1;
|
||||
--color-neutral-300: #b0b0b0;
|
||||
--color-neutral-400: #888888;
|
||||
--color-neutral-500: #6d6d6d;
|
||||
--color-neutral-600: #5d5d5d;
|
||||
--color-neutral-700: #4a4a4a;
|
||||
--color-neutral-800: #3a3a3a;
|
||||
--color-neutral-900: #2f2f2f;
|
||||
--color-neutral-700: #4f4f4f;
|
||||
--color-neutral-800: #454545;
|
||||
--color-neutral-900: #3d3d3d;
|
||||
--color-neutral-950: #2b2b2b;
|
||||
|
||||
--color-success-50: #e7f2ee;
|
||||
--color-success-100: #cde6dd;
|
||||
--color-success-200: #9bccbc;
|
||||
--color-success-300: #78bdab;
|
||||
--color-success-400: #4ca88f;
|
||||
--color-success-500: #349078;
|
||||
--color-success-600: #2f7c66;
|
||||
--color-success-700: #26624f;
|
||||
--color-success-800: #1e4a3b;
|
||||
--color-success-900: #1b513b;
|
||||
--color-success-100: #e0fbd8;
|
||||
--color-success-200: #bcf8b0;
|
||||
--color-success-300: #8eea85;
|
||||
--color-success-400: #63d564;
|
||||
--color-success-500: #35ba43;
|
||||
--color-success-600: #269f3e;
|
||||
--color-success-700: #1a8439;
|
||||
--color-success-800: #106b32;
|
||||
--color-success-900: #0b592f;
|
||||
|
||||
--color-info-50: #e7f4f5;
|
||||
--color-info-100: #cce9ea;
|
||||
--color-info-200: #99d3d5;
|
||||
--color-info-300: #78c4c7;
|
||||
--color-info-400: #3aa9ad;
|
||||
--color-info-500: #279599;
|
||||
--color-info-600: #207d82;
|
||||
--color-info-700: #1a666b;
|
||||
--color-info-800: #124c52;
|
||||
--color-info-900: #093d43;
|
||||
--color-info-100: #ccfcfe;
|
||||
--color-info-200: #9bf3fd;
|
||||
--color-info-300: #67e3fb;
|
||||
--color-info-400: #42cdf8;
|
||||
--color-info-500: #04acf3;
|
||||
--color-info-600: #0185d0;
|
||||
--color-info-700: #0264af;
|
||||
--color-info-800: #01478d;
|
||||
--color-info-900: #003375;
|
||||
|
||||
--color-warning-50: #fffce6;
|
||||
--color-warning-100: #fff8cc;
|
||||
--color-warning-200: #fef39b;
|
||||
--color-warning-300: #fde967;
|
||||
--color-warning-400: #fbd93d;
|
||||
--color-warning-500: #f3c215;
|
||||
--color-warning-600: #d7a20f;
|
||||
--color-warning-700: #aa7e0c;
|
||||
--color-warning-800: #805c07;
|
||||
--color-warning-900: #665c00;
|
||||
--color-warning-100: #fffcd3;
|
||||
--color-warning-200: #fffaa9;
|
||||
--color-warning-300: #fff67d;
|
||||
--color-warning-400: #fff25d;
|
||||
--color-warning-500: #ffed27;
|
||||
--color-warning-600: #dbc91d;
|
||||
--color-warning-700: #b7a714;
|
||||
--color-warning-800: #93850b;
|
||||
--color-warning-900: #7a6d07;
|
||||
|
||||
--color-danger-50: #ffe7e7;
|
||||
--color-danger-100: #ffcece;
|
||||
--color-danger-200: #ff9f9f;
|
||||
--color-danger-300: #ff6e6e;
|
||||
--color-danger-400: #fa4646;
|
||||
--color-danger-500: #ef1f1f;
|
||||
--color-danger-600: #d11515;
|
||||
--color-danger-700: #a51111;
|
||||
--color-danger-800: #7f0c0c;
|
||||
--color-danger-900: #5d2d2d;
|
||||
--color-danger-100: #ffe8da;
|
||||
--color-danger-200: #ffcbb3;
|
||||
--color-danger-300: #ffaa8d;
|
||||
--color-danger-400: #ff8870;
|
||||
--color-danger-500: #ff5242;
|
||||
--color-danger-600: #da3030;
|
||||
--color-danger-700: #b7212d;
|
||||
--color-danger-800: #93152a;
|
||||
--color-danger-900: #7a0c27;
|
||||
|
||||
--radius-none: 0px;
|
||||
--radius-sm: 2px;
|
||||
@@ -137,4 +133,4 @@
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nx/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# service
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test service` to execute the unit tests via [Vitest](https://vitest.dev/).
|
||||
@@ -0,0 +1,12 @@
|
||||
import nx from '@nx/eslint-plugin';
|
||||
import baseConfig from '../../eslint.config.mjs';
|
||||
|
||||
export default [
|
||||
...baseConfig,
|
||||
...nx.configs['flat/react'],
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
|
||||
// Override or add rules here
|
||||
rules: {},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@imphnen-frontend-service/service",
|
||||
"version": "0.0.1",
|
||||
"main": "./index.js",
|
||||
"types": "./index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.mjs",
|
||||
"require": "./index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "service",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/service/src",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"nx-release-publish": {
|
||||
"options": {
|
||||
"packageRoot": "dist/{projectRoot}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"release": {
|
||||
"version": {
|
||||
"generatorOptions": {
|
||||
"packageRoot": "dist/{projectRoot}",
|
||||
"currentVersionResolver": "git-tag",
|
||||
"fallbackCurrentVersionResolver": "disk"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { api } from '../';
|
||||
import {
|
||||
TLoginRequest,
|
||||
TLoginResponse,
|
||||
TRegisterRequest,
|
||||
TVerifyEmailRequest,
|
||||
} from '../../types/auth';
|
||||
import { TResponseMessage } from '../../types/common';
|
||||
|
||||
export const postLogin = async (
|
||||
payload: TLoginRequest
|
||||
): Promise<TLoginResponse> => {
|
||||
const { data } = await api({
|
||||
method: 'POST',
|
||||
url: '/auth/login',
|
||||
data: payload,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const postRegister = async (
|
||||
payload: TRegisterRequest
|
||||
): Promise<TResponseMessage> => {
|
||||
const { data } = await api({
|
||||
method: 'POST',
|
||||
url: '/auth/register',
|
||||
data: payload,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const postVerifyEmail = async (
|
||||
payload: TVerifyEmailRequest
|
||||
): Promise<TResponseMessage> => {
|
||||
const { data } = await api({
|
||||
method: 'POST',
|
||||
url: '/auth/verify',
|
||||
data: payload,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -1,5 +1,9 @@
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
|
||||
export * from './auth';
|
||||
export * from './gacha';
|
||||
export * from './users';
|
||||
|
||||
const config: AxiosRequestConfig = {
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useMutation, UseMutationResult } from '@tanstack/react-query';
|
||||
import { postLogin, postRegister, postVerifyEmail } from '../../api/auth';
|
||||
import {
|
||||
TLoginRequest,
|
||||
TLoginResponse,
|
||||
TRegisterRequest,
|
||||
TVerifyEmailRequest,
|
||||
} from '../../types/auth';
|
||||
import { SessionToken, SessionUser } from '@imphnen-frontend-service/utils';
|
||||
|
||||
import { TResponseError, TResponseMessage } from '../../types/common';
|
||||
|
||||
export const usePostLogin = (): UseMutationResult<
|
||||
TLoginResponse,
|
||||
TResponseError,
|
||||
TLoginRequest,
|
||||
unknown
|
||||
> => {
|
||||
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();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const usePostRegister = (): UseMutationResult<
|
||||
TResponseMessage,
|
||||
TResponseError,
|
||||
TRegisterRequest,
|
||||
unknown
|
||||
> => {
|
||||
return useMutation({
|
||||
mutationKey: ['post-register'],
|
||||
mutationFn: async (payload) => await postRegister(payload),
|
||||
});
|
||||
};
|
||||
|
||||
export const usePostVerifyEmail = (): UseMutationResult<
|
||||
TResponseMessage,
|
||||
TResponseError,
|
||||
TVerifyEmailRequest,
|
||||
unknown
|
||||
> => {
|
||||
return useMutation({
|
||||
mutationKey: ['post-verify-email'],
|
||||
mutationFn: async (payload) => await postVerifyEmail(payload),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './auth';
|
||||
export * from './gacha';
|
||||
export * from './users';
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './api';
|
||||
export * from './hooks';
|
||||
export * from './types';
|
||||
export * from './schemas';
|
||||
@@ -0,0 +1,17 @@
|
||||
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'),
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './auth';
|
||||
@@ -0,0 +1,27 @@
|
||||
import { TUserItem } from '../users';
|
||||
|
||||
export type TLoginRequest = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type TLoginResponse = {
|
||||
data: {
|
||||
token: {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
};
|
||||
user: TUserItem;
|
||||
};
|
||||
};
|
||||
|
||||
export type TRegisterRequest = {
|
||||
fullname: string;
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type TVerifyEmailRequest = {
|
||||
email: string;
|
||||
otp: number;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
export type TResponse<T = unknown> = {
|
||||
data: T;
|
||||
};
|
||||
|
||||
export type TResponseMessage = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type TResponseError = AxiosError<TResponseMessage>;
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +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[];
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": false,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"types": ["vite/client", "vitest"]
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
],
|
||||
"extends": "../../tsconfig.base.json"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": [
|
||||
"node",
|
||||
"@nx/react/typings/cssmodule.d.ts",
|
||||
"@nx/react/typings/image.d.ts",
|
||||
"vite/client"
|
||||
]
|
||||
},
|
||||
"exclude": [
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.js",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.jsx",
|
||||
"**/*.test.jsx",
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx"
|
||||
],
|
||||
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import dts from 'vite-plugin-dts';
|
||||
import * as path from 'path';
|
||||
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../node_modules/.vite/libs/service',
|
||||
plugins: [
|
||||
react(),
|
||||
nxViteTsPaths(),
|
||||
nxCopyAssetsPlugin(['*.md']),
|
||||
dts({
|
||||
entryRoot: 'src',
|
||||
tsconfigPath: path.join(__dirname, 'tsconfig.lib.json'),
|
||||
}),
|
||||
],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
// Configuration for building your library.
|
||||
// See: https://vitejs.dev/guide/build.html#library-mode
|
||||
build: {
|
||||
outDir: '../../dist/libs/service',
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
lib: {
|
||||
// Could also be a dictionary or array of multiple entry points.
|
||||
entry: 'src/index.ts',
|
||||
name: 'service',
|
||||
fileName: 'index',
|
||||
// Change this to the formats you want to support.
|
||||
// Don't forget to update your package.json as well.
|
||||
formats: ['es' as const],
|
||||
},
|
||||
rollupOptions: {
|
||||
// External packages that should not be bundled into your library.
|
||||
external: ['react', 'react-dom', 'react/jsx-runtime'],
|
||||
},
|
||||
},
|
||||
test: {
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
reporters: ['default'],
|
||||
coverage: {
|
||||
reportsDirectory: '../../coverage/libs/service',
|
||||
provider: 'v8' as const,
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -11,6 +11,10 @@ const config: StorybookConfig = {
|
||||
},
|
||||
},
|
||||
},
|
||||
staticDirs: [
|
||||
'../../../apps/backoffice/public',
|
||||
'../../../apps/gacha/public',
|
||||
'../../../apps/dimentorin/public', ],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -51,7 +51,7 @@ export const Button: FC<TButtonProps> = ({
|
||||
...rest
|
||||
}): ReactElement => {
|
||||
const mergedClassName = cn(
|
||||
'inline-flex items-center justify-center font-[600] rounded-lg px-[16px] py-[10px]',
|
||||
'inline-flex items-center justify-center font-[600] rounded-md px-[16px] py-[10px]',
|
||||
'transition-colors duration-200 cursor-pointer',
|
||||
sizeClasses[size],
|
||||
variantClasses[variant],
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './button';
|
||||
export * from './input';
|
||||
export * from './password-input';
|
||||
export * from './textarea';
|
||||
|
||||
@@ -1,36 +1,15 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Input } from "./input";
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Input } from './input';
|
||||
|
||||
describe("Test Input Component", () => {
|
||||
it("renders the input with placeholder text", () => {
|
||||
describe('Test Input Component', () => {
|
||||
it('renders the input with placeholder text', () => {
|
||||
render(<Input placeholder="Placeholder" />);
|
||||
expect(screen.getByPlaceholderText("Placeholder")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Placeholder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// it("renders the input with different sizes", () => {
|
||||
// const sizeClasses = {
|
||||
// sm: "text-[10px] max-h-[28px]",
|
||||
// md: "text-[12px] max-h-[30px]",
|
||||
// lg: "text-[15px] max-h-[34px]",
|
||||
// };
|
||||
// Object.entries(sizeClasses).forEach(([size, className]) => {
|
||||
// render(<Input size={size} />);
|
||||
// expect(screen.getByRole("textbox")).toHaveClass(className);
|
||||
// });
|
||||
// });
|
||||
|
||||
// it("renders the input with different types", () => {
|
||||
// const types = ["text", "password", "email", "number"];
|
||||
// types.forEach((type) => {
|
||||
// render(<Input type={type} />);
|
||||
// const input = screen.getByRole("textbox");
|
||||
// expect(input).toHaveAttribute("type", type);
|
||||
// });
|
||||
// });
|
||||
|
||||
it("disables the input field when 'disabled' prop is set", () => {
|
||||
render(<Input disabled />);
|
||||
expect(screen.getByRole("textbox")).toBeDisabled();
|
||||
expect(screen.getByRole('textbox')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,17 +4,8 @@ import { Input } from './input';
|
||||
const meta: Meta<typeof Input> = {
|
||||
title: 'Components/Input',
|
||||
component: Input,
|
||||
argTypes: {
|
||||
type: {
|
||||
control: 'select',
|
||||
options: ['text', 'email'],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['sm', 'md', 'lg'],
|
||||
},
|
||||
},
|
||||
};
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Input>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Input>;
|
||||
@@ -22,6 +13,7 @@ type Story = StoryObj<typeof Input>;
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
size: 'lg',
|
||||
type: 'text',
|
||||
placeholder: 'Placeholder',
|
||||
},
|
||||
};
|
||||
@@ -29,6 +21,7 @@ export const Large: Story = {
|
||||
export const Medium: Story = {
|
||||
args: {
|
||||
size: 'md',
|
||||
type: 'text',
|
||||
placeholder: 'Placeholder',
|
||||
},
|
||||
};
|
||||
@@ -36,6 +29,7 @@ export const Medium: Story = {
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
size: 'sm',
|
||||
type: 'text',
|
||||
placeholder: 'Placeholder',
|
||||
},
|
||||
};
|
||||
@@ -54,6 +48,13 @@ export const EmailInput: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const PasswordInput: Story = {
|
||||
args: {
|
||||
size: 'md',
|
||||
type: 'password',
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
size: 'md',
|
||||
@@ -61,11 +62,3 @@ export const Disabled: Story = {
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
size: 'md',
|
||||
type: 'text',
|
||||
error: 'This field is required',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,10 +3,13 @@ import {
|
||||
FC,
|
||||
InputHTMLAttributes,
|
||||
ReactElement,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons'; // Import Ant Design icons
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { Button } from '../button';
|
||||
|
||||
type TInputType = 'text' | 'email';
|
||||
type TInputType = 'text' | 'email' | 'password' | 'file';
|
||||
type TInputSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
type TInputProps = Omit<
|
||||
@@ -15,46 +18,70 @@ type TInputProps = Omit<
|
||||
> & {
|
||||
type?: TInputType;
|
||||
size?: TInputSize;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const sizeClasses: Record<TInputSize, string> = {
|
||||
sm: 'text-[10px] max-h-[28px]',
|
||||
md: 'text-[12px] max-h-[30px]',
|
||||
lg: 'text-[15px] max-h-[34px]',
|
||||
};
|
||||
const sizeClasses: Record<TInputSize, { textSize: string; iconSize: string }> =
|
||||
{
|
||||
sm: { textSize: 'text-[10px] max-h-[28px]', iconSize: 'text-[10px]' },
|
||||
md: { textSize: 'text-[12px] max-h-[30px]', iconSize: 'text-[12px]' },
|
||||
lg: { textSize: 'text-[15px] max-h-[34px]', iconSize: 'text-[15px]' },
|
||||
};
|
||||
|
||||
const disabledClass = 'opacity-50 hover:border-neutral-200 cursor-not-allowed';
|
||||
const errorClass =
|
||||
'border-danger-500 hover:border-danger-500 focus:outline-danger-500';
|
||||
|
||||
export const Input: FC<TInputProps> = ({
|
||||
type = 'text',
|
||||
size = 'md',
|
||||
type,
|
||||
placeholder = 'Placeholder',
|
||||
disabled,
|
||||
error,
|
||||
className,
|
||||
...rest
|
||||
}): ReactElement => {
|
||||
const [showPassword, setShowPassword] = useState(false); // State for password visibility
|
||||
|
||||
const togglePasswordVisibility = () => {
|
||||
if (!disabled) setShowPassword((prev) => !prev);
|
||||
};
|
||||
|
||||
const mergedClassName = cn(
|
||||
'px-[12px] py-[8px] text-neutral-800 placeholder:text-neutral-300 border border-neutral-200 hover:border-blue-300 focus:outline-1 focus:outline-blue-500 rounded-md font-bai-jamjuree',
|
||||
sizeClasses[size],
|
||||
'px-[12px] py-[8px] text-neutral-800 bg-white placeholder:text-neutral-300 border border-neutral-200 hover:border-blue-300 focus:outline-1 focus:outline-blue-500 rounded-md font-bai-jamjuree min-w-70',
|
||||
sizeClasses[size].textSize,
|
||||
disabled && disabledClass,
|
||||
error && errorClass,
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
className={mergedClassName}
|
||||
type={type}
|
||||
type={type === 'password' && showPassword ? 'text' : type}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
{...rest}
|
||||
/>
|
||||
{error && <p className="text-danger-500 text-xs mt-1">{error}</p>}
|
||||
</>
|
||||
{type === 'password' && (
|
||||
<div className="absolute end-0 px-[12px] h-full flex items-center">
|
||||
<Button
|
||||
variant="text"
|
||||
size={size}
|
||||
onClick={togglePasswordVisibility}
|
||||
className={cn(
|
||||
'relative aspect-square -me-[8px] p-[6px]',
|
||||
sizeClasses[size].iconSize,
|
||||
disabled && 'cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeInvisibleOutlined
|
||||
style={{ color: 'var(--color-neutral-500)' }}
|
||||
/>
|
||||
) : (
|
||||
<EyeOutlined style={{ color: 'var(--color-neutral-500)' }} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./password-input";
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { PasswordInput } from './password-input';
|
||||
|
||||
const meta: Meta<typeof PasswordInput> = {
|
||||
title: 'Components/Password Input',
|
||||
component: PasswordInput,
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['sm', 'md', 'lg'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PasswordInput>;
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
size: 'lg',
|
||||
placeholder: 'Placeholder',
|
||||
},
|
||||
};
|
||||
|
||||
export const Medium: Story = {
|
||||
args: {
|
||||
size: 'md',
|
||||
placeholder: 'Placeholder',
|
||||
},
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
size: 'sm',
|
||||
placeholder: 'Placeholder',
|
||||
},
|
||||
};
|
||||
|
||||
export const Input: Story = {
|
||||
args: {
|
||||
size: 'md',
|
||||
placeholder: 'Placeholder',
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
size: 'md',
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
size: 'md',
|
||||
error: 'This field is required',
|
||||
},
|
||||
};
|
||||
@@ -1,111 +0,0 @@
|
||||
import {
|
||||
DetailedHTMLProps,
|
||||
FC,
|
||||
InputHTMLAttributes,
|
||||
ReactElement,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
|
||||
type TPasswordInputType = 'password';
|
||||
type TPasswordInputSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
type TPasswordInputProps = Omit<
|
||||
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
||||
'size' | 'type'
|
||||
> & {
|
||||
type?: TPasswordInputType;
|
||||
size?: TPasswordInputSize;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const sizeClasses: Record<TPasswordInputSize, string> = {
|
||||
sm: 'text-[10px] max-h-[28px]',
|
||||
md: 'text-[12px] max-h-[30px]',
|
||||
lg: 'text-[15px] max-h-[34px]',
|
||||
};
|
||||
|
||||
const iconSizeClasses: Record<TPasswordInputSize, string> = {
|
||||
sm: 'text-[10px]',
|
||||
md: 'text-[12px]',
|
||||
lg: 'text-[16px]',
|
||||
};
|
||||
|
||||
const disabledClass = 'opacity-50 hover:border-neutral-200 cursor-not-allowed';
|
||||
const errorClass =
|
||||
'border-danger-500 hover:border-danger-500 focus:outline-danger-500';
|
||||
|
||||
export const PasswordInput: FC<TPasswordInputProps> = ({
|
||||
size = 'md',
|
||||
placeholder = 'Placeholder',
|
||||
disabled,
|
||||
error,
|
||||
className,
|
||||
...rest
|
||||
}): ReactElement => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const togglePasswordVisibility = () => {
|
||||
if (!disabled) setShowPassword((prev) => !prev);
|
||||
};
|
||||
|
||||
const mergedClassName = cn(
|
||||
'px-[12px] py-[8px] text-neutral-800 placeholder:text-neutral-300 border border-neutral-200 hover:border-blue-300 focus:outline-1 focus:outline-blue-500 rounded-md font-bai-jamjuree',
|
||||
sizeClasses[size],
|
||||
disabled && disabledClass,
|
||||
error && errorClass,
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'relative inline-flex items-center border border-neutral-200 rounded-md',
|
||||
mergedClassName
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className="focus:outline-0"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
{...rest}
|
||||
/>
|
||||
<div className="absolute end-0 px-[12px] h-full flex items-center">
|
||||
<Button
|
||||
variant="text"
|
||||
size={size}
|
||||
onClick={togglePasswordVisibility}
|
||||
className={cn(
|
||||
'relative aspect-square -me-[10px] p-[6px]',
|
||||
iconSizeClasses[size],
|
||||
disabled && 'cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeInvisibleOutlined
|
||||
style={{
|
||||
color: error
|
||||
? 'var(--color-danger-500)'
|
||||
: 'var(--color-neutral-500)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<EyeOutlined
|
||||
style={{
|
||||
color: error
|
||||
? 'var(--color-danger-500)'
|
||||
: 'var(--color-neutral-500)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-danger-500 text-xs mt-1">{error}</p>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,72 +1,71 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Bai+Jamjuree:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap');
|
||||
@import 'tailwindcss';
|
||||
@source "../../../libs/ui/**/*.{ts,tsx}";
|
||||
|
||||
@theme {
|
||||
--color-primary-50: #f0f8ff;
|
||||
--color-primary-100: #e1f0fd;
|
||||
--color-primary-200: #b8e1f8;
|
||||
--color-primary-300: #a1d4f0;
|
||||
--color-primary-400: #4aa4da;
|
||||
--color-primary-500: #1a8ce6;
|
||||
--color-primary-600: #1673c7;
|
||||
--color-primary-700: #0f5ca5;
|
||||
--color-primary-800: #0a4780;
|
||||
--color-primary-900: #04305d;
|
||||
--color-primary-200: #bce1fb;
|
||||
--color-primary-300: #81cbf8;
|
||||
--color-primary-400: #3eb0f2;
|
||||
--color-primary-500: #23a1eb;
|
||||
--color-primary-600: #0877c1;
|
||||
--color-primary-700: #085f9c;
|
||||
--color-primary-800: #0b5181;
|
||||
--color-primary-900: #0f446b;
|
||||
--color-primary-950: #0a2b47;
|
||||
|
||||
--color-neutral-50: #f7f7f7;
|
||||
--color-neutral-50: #f6f6f6;
|
||||
--color-neutral-100: #e7e7e7;
|
||||
--color-neutral-200: #d0d0d0;
|
||||
--color-neutral-300: #b7b7b7;
|
||||
--color-neutral-400: #9f9f9f;
|
||||
--color-neutral-500: #7e7e7e;
|
||||
--color-neutral-200: #d1d1d1;
|
||||
--color-neutral-300: #b0b0b0;
|
||||
--color-neutral-400: #888888;
|
||||
--color-neutral-500: #6d6d6d;
|
||||
--color-neutral-600: #5d5d5d;
|
||||
--color-neutral-700: #4a4a4a;
|
||||
--color-neutral-800: #3a3a3a;
|
||||
--color-neutral-900: #2f2f2f;
|
||||
--color-neutral-700: #4f4f4f;
|
||||
--color-neutral-800: #454545;
|
||||
--color-neutral-900: #3d3d3d;
|
||||
--color-neutral-950: #2b2b2b;
|
||||
|
||||
--color-success-50: #e7f2ee;
|
||||
--color-success-100: #cde6dd;
|
||||
--color-success-200: #9bccbc;
|
||||
--color-success-300: #78bdab;
|
||||
--color-success-400: #4ca88f;
|
||||
--color-success-500: #349078;
|
||||
--color-success-600: #2f7c66;
|
||||
--color-success-700: #26624f;
|
||||
--color-success-800: #1e4a3b;
|
||||
--color-success-900: #1b513b;
|
||||
--color-success-100: #e0fbd8;
|
||||
--color-success-200: #bcf8b0;
|
||||
--color-success-300: #8eea85;
|
||||
--color-success-400: #63d564;
|
||||
--color-success-500: #35ba43;
|
||||
--color-success-600: #269f3e;
|
||||
--color-success-700: #1a8439;
|
||||
--color-success-800: #106b32;
|
||||
--color-success-900: #0b592f;
|
||||
|
||||
--color-info-50: #e7f4f5;
|
||||
--color-info-100: #cce9ea;
|
||||
--color-info-200: #99d3d5;
|
||||
--color-info-300: #78c4c7;
|
||||
--color-info-400: #3aa9ad;
|
||||
--color-info-500: #279599;
|
||||
--color-info-600: #207d82;
|
||||
--color-info-700: #1a666b;
|
||||
--color-info-800: #124c52;
|
||||
--color-info-900: #093d43;
|
||||
--color-info-100: #ccfcfe;
|
||||
--color-info-200: #9bf3fd;
|
||||
--color-info-300: #67e3fb;
|
||||
--color-info-400: #42cdf8;
|
||||
--color-info-500: #04acf3;
|
||||
--color-info-600: #0185d0;
|
||||
--color-info-700: #0264af;
|
||||
--color-info-800: #01478d;
|
||||
--color-info-900: #003375;
|
||||
|
||||
--color-warning-50: #fffce6;
|
||||
--color-warning-100: #fff8cc;
|
||||
--color-warning-200: #fef39b;
|
||||
--color-warning-300: #fde967;
|
||||
--color-warning-400: #fbd93d;
|
||||
--color-warning-500: #f3c215;
|
||||
--color-warning-600: #d7a20f;
|
||||
--color-warning-700: #aa7e0c;
|
||||
--color-warning-800: #805c07;
|
||||
--color-warning-900: #665c00;
|
||||
--color-warning-100: #fffcd3;
|
||||
--color-warning-200: #fffaa9;
|
||||
--color-warning-300: #fff67d;
|
||||
--color-warning-400: #fff25d;
|
||||
--color-warning-500: #ffed27;
|
||||
--color-warning-600: #dbc91d;
|
||||
--color-warning-700: #b7a714;
|
||||
--color-warning-800: #93850b;
|
||||
--color-warning-900: #7a6d07;
|
||||
|
||||
--color-danger-50: #ffe7e7;
|
||||
--color-danger-100: #ffcece;
|
||||
--color-danger-200: #ff9f9f;
|
||||
--color-danger-300: #ff6e6e;
|
||||
--color-danger-400: #fa4646;
|
||||
--color-danger-500: #ef1f1f;
|
||||
--color-danger-600: #d11515;
|
||||
--color-danger-700: #a51111;
|
||||
--color-danger-800: #7f0c0c;
|
||||
--color-danger-900: #5d2d2d;
|
||||
--color-danger-100: #ffe8da;
|
||||
--color-danger-200: #ffcbb3;
|
||||
--color-danger-300: #ffaa8d;
|
||||
--color-danger-400: #ff8870;
|
||||
--color-danger-500: #ff5242;
|
||||
--color-danger-600: #da3030;
|
||||
--color-danger-700: #b7212d;
|
||||
--color-danger-800: #93152a;
|
||||
--color-danger-900: #7a0c27;
|
||||
|
||||
--radius-none: 0px;
|
||||
--radius-sm: 2px;
|
||||
@@ -79,16 +78,28 @@
|
||||
|
||||
--font-bai-jamjuree: 'Bai Jamjuree', sans-serif;
|
||||
|
||||
--text-h1: 3rem; /* ~48px */
|
||||
--text-h2: 2.4rem; /* ~38.4px */
|
||||
--text-h3: 1.92rem; /* ~30.72px */
|
||||
--text-p1: 1.54rem; /* ~24.58px */
|
||||
--text-p2: 1.23rem; /* ~19.68px */
|
||||
--text-label1: 0.98rem; /* ~15.74px */
|
||||
--text-label2: 0.78rem; /* ~12.59px */
|
||||
--text-h1: 3.833rem;
|
||||
/* ~46px */
|
||||
--text-h2: 3.083rem;
|
||||
/* ~37px */
|
||||
--text-h3: 2.417rem;
|
||||
/* ~29px */
|
||||
--text-p1: 1.917rem;
|
||||
/* ~23px */
|
||||
--text-p2: 1.583rem;
|
||||
/* ~19px */
|
||||
--text-p3: 1.25rem;
|
||||
/* 15px */
|
||||
--text-label1: 1rem;
|
||||
/* 12px */
|
||||
--text-label2: 0.833rem;
|
||||
/* ~10px */
|
||||
--text-label3: 0.677rem;
|
||||
/* ~8px */
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
@@ -102,13 +113,16 @@
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-neutral-50);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cccccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-neutral-300);
|
||||
}
|
||||
@@ -116,5 +130,7 @@
|
||||
html {
|
||||
font-family: 'Bai Jamjuree', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { DetailedHTMLProps, FC, InputHTMLAttributes, ReactElement } from "react"
|
||||
|
||||
type TForgotStepProps = Omit<
|
||||
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
||||
'size' | 'type'
|
||||
> & {
|
||||
step: number
|
||||
};
|
||||
|
||||
export const ForgotStep: FC<TForgotStepProps> = ({
|
||||
step = 1,
|
||||
...rest
|
||||
}): ReactElement => {
|
||||
return (
|
||||
<div className="w-full grid grid-cols-3 gap-3">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<div className={`${step === 1 ? "bg-primary-500" : "bg-primary-200"} px-5 py-2 rounded-md w-full`}></div>
|
||||
<h4 className={`${step === 1 ? "text-primary-500" : "text-gray-500"} text-xs`}>Masukkan Email</h4>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<div className={`${step === 2 ? "bg-primary-500" : "bg-primary-200"} px-5 py-2 rounded-md w-full`}></div>
|
||||
<h4 className={`${step === 2 ? "text-primary-500" : "text-gray-500"} text-xs`}>Verifikasi OTP</h4>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<div className={`${step === 3 ? "bg-primary-500" : "bg-primary-200"} px-5 py-2 rounded-md w-full`}></div>
|
||||
<h4 className={`${step === 3 ? "text-primary-500" : "text-gray-500"} text-xs`}>Summon Password ^^</h4>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './forgot-step';
|
||||
@@ -1 +1,6 @@
|
||||
export {};
|
||||
export * from './forgot-step';
|
||||
export * from './otp-form';
|
||||
export * from './input-field';
|
||||
export * from './pagination';
|
||||
export * from './modal/modal';
|
||||
export * from './stepper';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './input-field';
|
||||
@@ -0,0 +1,20 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { InputField } from './input-field';
|
||||
|
||||
describe('InputField Component', () => {
|
||||
it('renders correctly with disabled prop', () => {
|
||||
render(<InputField label="Test Label" disabled={true} />);
|
||||
|
||||
const input = screen.getByLabelText('Test Label');
|
||||
expect(input).toBeDisabled();
|
||||
expect(input).toHaveClass('opacity-50 cursor-not-allowed');
|
||||
});
|
||||
|
||||
it('renders correctly without disabled prop', () => {
|
||||
render(<InputField label="Test Label" disabled={false} />);
|
||||
|
||||
const input = screen.getByLabelText('Test Label');
|
||||
expect(input).not.toBeDisabled();
|
||||
expect(input).not.toHaveClass('opacity-50 cursor-not-allowed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { InputField } from './input-field';
|
||||
|
||||
const meta = {
|
||||
title: 'Molecules/Input Form',
|
||||
component: InputField,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
Komponen input form yang menggabungkan label dengan kolom input.
|
||||
|
||||
## Aksesibilitas
|
||||
Ketika prop \`htmlFor\` disediakan:
|
||||
- Atribut \`htmlFor\` pada label akan diatur ke nilai tersebut
|
||||
- Atribut \`id\` pada input akan otomatis diatur ke nilai yang sama
|
||||
- Ini menciptakan asosiasi label-input yang tepat untuk aksesibilitas
|
||||
|
||||
Cek dan inspect element pada story With HtmlFor untuk melihat hasilnya.
|
||||
|
||||
## Helper Text dan Error
|
||||
- Jika prop \`error\` disediakan, akan ditampilkan dalam warna merah di bawah input
|
||||
- Jika prop \`helperText\` disediakan dan tidak ada error, akan ditampilkan dalam warna abu-abu di bawah input
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof InputField>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
placeholder: 'Placeholder',
|
||||
type: 'text',
|
||||
size: 'lg',
|
||||
helperText: 'Helper Text',
|
||||
},
|
||||
};
|
||||
|
||||
export const Medium: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
placeholder: 'Placeholder',
|
||||
type: 'text',
|
||||
size: 'md',
|
||||
helperText: 'Helper Text',
|
||||
},
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
label: 'Label',
|
||||
placeholder: 'Placeholder',
|
||||
type: 'text',
|
||||
size: 'sm',
|
||||
helperText: 'Helper Text',
|
||||
},
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
label: 'Default',
|
||||
placeholder: 'Enter your name',
|
||||
type: 'text',
|
||||
size: 'md',
|
||||
},
|
||||
};
|
||||
|
||||
export const PasswordInput: Story = {
|
||||
args: {
|
||||
label: 'Password',
|
||||
placeholder: 'Enter your password',
|
||||
type: 'password',
|
||||
size: 'md',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithHelperText: Story = {
|
||||
args: {
|
||||
label: 'Email',
|
||||
placeholder: 'Enter your email',
|
||||
type: 'email',
|
||||
size: 'md',
|
||||
helperText: 'We will never share your email',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutHelperText: Story = {
|
||||
args: {
|
||||
label: 'Username',
|
||||
placeholder: 'Enter your username',
|
||||
type: 'text',
|
||||
size: 'md',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
label: 'Username',
|
||||
placeholder: 'Enter your username',
|
||||
type: 'text',
|
||||
size: 'md',
|
||||
error: 'This field is required',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithHtmlFor: Story = {
|
||||
args: {
|
||||
label: 'Full Name',
|
||||
placeholder: 'Enter your full name',
|
||||
type: 'text',
|
||||
size: 'md',
|
||||
htmlFor: 'fullname-input',
|
||||
helperText: 'Click on the label',
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
label: 'Disabled',
|
||||
placeholder: 'This field is disabled',
|
||||
type: 'text',
|
||||
size: 'md',
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
DetailedHTMLProps,
|
||||
FC,
|
||||
InputHTMLAttributes,
|
||||
ReactElement,
|
||||
} from 'react';
|
||||
import { Input } from '../../atoms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
export type TInputType = 'text' | 'email' | 'password' | 'file';
|
||||
export type TInputSize = 'sm' | 'md' | 'lg';
|
||||
export type TInputFieldProps = Omit<
|
||||
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
||||
'size' | 'type'
|
||||
> & {
|
||||
label: string;
|
||||
type?: TInputType;
|
||||
size?: TInputSize;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
helperText?: string;
|
||||
htmlFor?: string;
|
||||
};
|
||||
|
||||
const sizeClasses: Record<TInputSize, { label: string; helperText: string }> = {
|
||||
lg: {
|
||||
label: 'text-p3 font-medium',
|
||||
helperText: 'text-label3 font-normal',
|
||||
},
|
||||
md: {
|
||||
label: 'text-label1 font-medium',
|
||||
helperText: 'text-label2 font-normal',
|
||||
},
|
||||
sm: {
|
||||
label: 'text-label2 font-medium',
|
||||
helperText: 'text-label2 font-normal',
|
||||
},
|
||||
};
|
||||
|
||||
export const InputField: FC<TInputFieldProps> = ({
|
||||
label,
|
||||
placeholder,
|
||||
type = 'text',
|
||||
size = 'md',
|
||||
error,
|
||||
helperText,
|
||||
htmlFor,
|
||||
className,
|
||||
disabled,
|
||||
...rest
|
||||
}): ReactElement => {
|
||||
return (
|
||||
<div className="flex gap-[8px] flex-col">
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className={cn(
|
||||
'items-start justify-item-start text-start',
|
||||
sizeClasses[size].label
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<Input
|
||||
{...(htmlFor && { id: htmlFor })}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
error &&
|
||||
'border-danger-500 hover:border-danger-500 focus:outline-danger-500',
|
||||
className,
|
||||
disabled && 'opacity-50 cursor-not-allowed' // Add styles for disabled state
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="text-danger-500 text-xl mt-1">{error}</p>
|
||||
) : (
|
||||
helperText && (
|
||||
<p className={cn('text-cs mt-1', sizeClasses[size].helperText)}>
|
||||
{helperText}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import React, { useEffect, useMemo, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
overlayClassName?: string;
|
||||
closeButtonClassName?: string;
|
||||
disableEscapeKeyDown?: boolean;
|
||||
'aria-label'?: string;
|
||||
'aria-labelledby'?: string;
|
||||
'aria-describedby'?: string;
|
||||
}
|
||||
|
||||
const Modal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
overlayClassName,
|
||||
closeButtonClassName,
|
||||
disableEscapeKeyDown = false,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
}: ModalProps) => {
|
||||
const handleEscapeKey = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen && !disableEscapeKeyDown) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[isOpen, onClose, disableEscapeKeyDown]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.addEventListener('keydown', handleEscapeKey);
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
window.removeEventListener('keydown', handleEscapeKey);
|
||||
};
|
||||
}, [isOpen, handleEscapeKey]);
|
||||
|
||||
const modalNode = useMemo(() => document.createElement('div'), []);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.appendChild(modalNode);
|
||||
return () => {
|
||||
document.body.removeChild(modalNode);
|
||||
};
|
||||
}, [modalNode]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-gray-900/80 transition-opacity duration-200',
|
||||
isOpen ? 'opacity-100' : 'opacity-0',
|
||||
overlayClassName
|
||||
)}
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 bg-[#F0F8FF] rounded-lg p-6 shadow-xl transition-all duration-200',
|
||||
'sm:rounded-lg sm:max-w-md',
|
||||
isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95',
|
||||
className
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
>
|
||||
{children}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'absolute right-4 top-4 rounded-sm p-1 text-gray-500 transition-colors hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-gray-950 focus:ring-offset-2',
|
||||
closeButtonClassName
|
||||
)}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<CloseOutlined className="h-4 w-4 cursor-pointer" />
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
modalNode
|
||||
);
|
||||
};
|
||||
|
||||
interface ModalHeaderProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ModalHeader = ({ className, children }: ModalHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'mb-4 flex flex-col space-y-1.5 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ModalContentProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ModalContent = ({ className, children }: ModalContentProps) => (
|
||||
<div className={cn('mb-4', className)}>{children}</div>
|
||||
);
|
||||
|
||||
interface ModalFooterProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ModalFooter = ({ className, children }: ModalFooterProps) => (
|
||||
<div className={cn('flex gap-2 sm:flex-row sm:justify-end', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ModalTitleProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const ModalTitle = ({ className, children, id }: ModalTitleProps) => (
|
||||
<h2
|
||||
id={id}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
|
||||
interface ModalDescriptionProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const ModalDescription = ({
|
||||
className,
|
||||
children,
|
||||
id,
|
||||
}: ModalDescriptionProps) => (
|
||||
<p id={id} className={cn('text-sm text-gray-500', className)}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
Modal.Header = ModalHeader;
|
||||
Modal.Content = ModalContent;
|
||||
Modal.Footer = ModalFooter;
|
||||
Modal.Title = ModalTitle;
|
||||
Modal.Description = ModalDescription;
|
||||
|
||||
export { Modal };
|
||||
export type {
|
||||
ModalProps,
|
||||
ModalHeaderProps,
|
||||
ModalContentProps,
|
||||
ModalFooterProps,
|
||||
ModalTitleProps,
|
||||
ModalDescriptionProps,
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { Modal } from './modal';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
describe('Modal Component', () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
children: <div>Modal Content</div>,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders when isOpen is true', () => {
|
||||
render(<Modal {...defaultProps} />);
|
||||
expect(screen.getByText('Modal Content')).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Close modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when isOpen is false', () => {
|
||||
render(<Modal {...defaultProps} isOpen={false} />);
|
||||
expect(screen.queryByText('Modal Content')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when close button is clicked', async () => {
|
||||
render(<Modal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByLabelText('Close modal'));
|
||||
await waitFor(() => expect(defaultProps.onClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('calls onClose when overlay is clicked', async () => {
|
||||
render(<Modal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole('presentation'));
|
||||
await waitFor(() => expect(defaultProps.onClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('closes on Escape key press when disableEscapeKeyDown is false', async () => {
|
||||
render(<Modal {...defaultProps} />);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
await waitFor(() => expect(defaultProps.onClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('does not close on Escape key press when disableEscapeKeyDown is true', async () => {
|
||||
render(<Modal {...defaultProps} disableEscapeKeyDown={true} />);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
await waitFor(() => expect(defaultProps.onClose).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('renders sub-components correctly', () => {
|
||||
render(
|
||||
<Modal {...defaultProps}>
|
||||
<Modal.Header>
|
||||
<Modal.Title>Modal Title</Modal.Title>
|
||||
<Modal.Description>Modal Description</Modal.Description>
|
||||
</Modal.Header>
|
||||
<Modal.Content>Modal Content</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<button>Footer Button</button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Modal Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Modal Description')).toBeInTheDocument();
|
||||
expect(screen.getByText('Modal Content')).toBeInTheDocument();
|
||||
expect(screen.getByText('Footer Button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom classNames', () => {
|
||||
render(
|
||||
<Modal
|
||||
{...defaultProps}
|
||||
className="custom-modal"
|
||||
overlayClassName="custom-overlay"
|
||||
closeButtonClassName="custom-close"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole('dialog')).toHaveClass('custom-modal');
|
||||
expect(screen.getByRole('presentation')).toHaveClass('custom-overlay');
|
||||
expect(screen.getByLabelText('Close modal')).toHaveClass('custom-close');
|
||||
});
|
||||
|
||||
it('sets ARIA attributes correctly', () => {
|
||||
render(
|
||||
<Modal
|
||||
{...defaultProps}
|
||||
aria-label="Test Modal"
|
||||
aria-labelledby="modal-title"
|
||||
aria-describedby="modal-desc"
|
||||
>
|
||||
<Modal.Header>
|
||||
<Modal.Title id="modal-title">Title</Modal.Title>
|
||||
<Modal.Description id="modal-desc">Description</Modal.Description>
|
||||
</Modal.Header>
|
||||
</Modal>
|
||||
);
|
||||
const modal = screen.getByRole('dialog');
|
||||
expect(modal).toHaveAttribute('aria-label', 'Test Modal');
|
||||
expect(modal).toHaveAttribute('aria-labelledby', 'modal-title');
|
||||
expect(modal).toHaveAttribute('aria-describedby', 'modal-desc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Modal } from './modal';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
const meta: Meta<typeof Modal> = {
|
||||
title: 'Components/Modal',
|
||||
component: Modal,
|
||||
argTypes: {
|
||||
isOpen: { control: 'boolean' },
|
||||
onClose: { action: 'closed' },
|
||||
className: { control: 'text' },
|
||||
overlayClassName: { control: 'text' },
|
||||
closeButtonClassName: { control: 'text' },
|
||||
disableEscapeKeyDown: { control: 'boolean' },
|
||||
'aria-label': { control: 'text' },
|
||||
'aria-labelledby': { control: 'text' },
|
||||
'aria-describedby': { control: 'text' },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Modal>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
isOpen: true,
|
||||
onClose: () => {
|
||||
console.log('Modal closed');
|
||||
},
|
||||
children: (
|
||||
<>
|
||||
<Modal.Header>
|
||||
<Modal.Title>Modal Title</Modal.Title>
|
||||
<Modal.Description>
|
||||
This is a simple modal description.
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
<Modal.Content>This is the main content of the modal.</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<button className="px-4 py-2 bg-blue-500 text-white rounded">
|
||||
Confirm
|
||||
</button>
|
||||
<button className="px-4 py-2 bg-gray-300 rounded">Cancel</button>
|
||||
</Modal.Footer>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomClasses: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
className: 'bg-red-100',
|
||||
overlayClassName: 'bg-opacity-50',
|
||||
closeButtonClassName: 'text-red-500',
|
||||
},
|
||||
};
|
||||
|
||||
export const NoEscapeClose: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
disableEscapeKeyDown: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAria: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
'aria-label': 'Custom Modal',
|
||||
'aria-labelledby': 'modal-title',
|
||||
'aria-describedby': 'modal-desc',
|
||||
children: (
|
||||
<>
|
||||
<Modal.Header>
|
||||
<Modal.Title id="modal-title">Modal Title</Modal.Title>
|
||||
<Modal.Description id="modal-desc">
|
||||
This is a modal with ARIA attributes.
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
<Modal.Content>Modal content here.</Modal.Content>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import React, { useEffect, useMemo, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
overlayClassName?: string;
|
||||
closeButtonClassName?: string;
|
||||
disableEscapeKeyDown?: boolean;
|
||||
'aria-label'?: string;
|
||||
'aria-labelledby'?: string;
|
||||
'aria-describedby'?: string;
|
||||
}
|
||||
|
||||
const Modal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
overlayClassName,
|
||||
closeButtonClassName,
|
||||
disableEscapeKeyDown = false,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
}: ModalProps) => {
|
||||
const handleEscapeKey = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen && !disableEscapeKeyDown) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[isOpen, onClose, disableEscapeKeyDown]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.addEventListener('keydown', handleEscapeKey);
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
window.removeEventListener('keydown', handleEscapeKey);
|
||||
};
|
||||
}, [isOpen, handleEscapeKey]);
|
||||
|
||||
const modalNode = useMemo(() => document.createElement('div'), []);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.appendChild(modalNode);
|
||||
return () => {
|
||||
document.body.removeChild(modalNode);
|
||||
};
|
||||
}, [modalNode]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-gray-900/80 transition-opacity duration-200',
|
||||
isOpen ? 'opacity-100' : 'opacity-0',
|
||||
overlayClassName
|
||||
)}
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 bg-[#F0F8FF] rounded-lg p-6 shadow-xl transition-all duration-200',
|
||||
'sm:rounded-lg sm:max-w-md',
|
||||
isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95',
|
||||
className
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
>
|
||||
{children}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'absolute right-4 top-4 rounded-sm p-1 text-gray-500 transition-colors hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-gray-950 focus:ring-offset-2',
|
||||
closeButtonClassName
|
||||
)}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<CloseOutlined className="h-4 w-4 cursor-pointer" />
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
modalNode
|
||||
);
|
||||
};
|
||||
|
||||
interface ModalHeaderProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ModalHeader = ({ className, children }: ModalHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'mb-4 flex flex-col space-y-1.5 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ModalContentProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ModalContent = ({ className, children }: ModalContentProps) => (
|
||||
<div className={cn('mb-4', className)}>{children}</div>
|
||||
);
|
||||
|
||||
interface ModalFooterProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ModalFooter = ({ className, children }: ModalFooterProps) => (
|
||||
<div className={cn('flex gap-2 sm:flex-row sm:justify-end', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ModalTitleProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const ModalTitle = ({ className, children, id }: ModalTitleProps) => (
|
||||
<h2
|
||||
id={id}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
|
||||
interface ModalDescriptionProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const ModalDescription = ({
|
||||
className,
|
||||
children,
|
||||
id,
|
||||
}: ModalDescriptionProps) => (
|
||||
<p id={id} className={cn('text-sm text-gray-500', className)}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
Modal.Header = ModalHeader;
|
||||
Modal.Content = ModalContent;
|
||||
Modal.Footer = ModalFooter;
|
||||
Modal.Title = ModalTitle;
|
||||
Modal.Description = ModalDescription;
|
||||
|
||||
export { Modal };
|
||||
export type {
|
||||
ModalProps,
|
||||
ModalHeaderProps,
|
||||
ModalContentProps,
|
||||
ModalFooterProps,
|
||||
ModalTitleProps,
|
||||
ModalDescriptionProps,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./otp-form"
|
||||
@@ -0,0 +1,61 @@
|
||||
// eslint-disable-next-line @nx/enforce-module-boundaries
|
||||
import { Input } from "@imphnen-frontend-service/ui/atoms";
|
||||
import { ChangeEvent, DetailedHTMLProps, FC, InputHTMLAttributes, ReactElement, useRef, useState } from "react"
|
||||
|
||||
type TForgotStepProps = Omit<
|
||||
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
||||
'size' | 'type'
|
||||
> & {
|
||||
|
||||
};
|
||||
|
||||
export const OtpForm: FC<TForgotStepProps> = ({
|
||||
step = 1,
|
||||
...rest
|
||||
}): ReactElement => {
|
||||
const [cell, setCell] = useState(new Array(6).fill(""))
|
||||
const inputRef = useRef([])
|
||||
|
||||
const handleChange = (index: number, e: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
if (!(/^[0-9]?$/.test(value))) return;
|
||||
|
||||
const newCell = [...cell]
|
||||
newCell[index] = value
|
||||
setCell(newCell)
|
||||
|
||||
if(value && index < 6 - 1){
|
||||
(inputRef.current[index + 1] as HTMLInputElement).focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if(e.key === "Backspace" && !cell[index] && index > 0){
|
||||
const newCell = [...cell]
|
||||
newCell[index] = ""
|
||||
setCell(newCell)
|
||||
|
||||
const ref = (inputRef.current[index - 1] as HTMLInputElement)
|
||||
ref.focus()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full grid grid-cols-6 gap-3 my-10">
|
||||
{cell.map((v, i) => (
|
||||
<Input
|
||||
placeholder=""
|
||||
ref={(el) => {
|
||||
inputRef.current.push(el as never)
|
||||
return inputRef.current[i]
|
||||
}}
|
||||
onChange={(e) => handleChange(i, e)}
|
||||
onKeyDown={(e) => handleKeyDown(i, e)}
|
||||
size="lg"
|
||||
value={cell[i]}
|
||||
className="sm:min-w-[10px]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './pagination';
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Pagination from './pagination';
|
||||
|
||||
import { PaginationProps } from './index'; // Import PaginationProps
|
||||
|
||||
const meta = {
|
||||
title: 'Molecules/Pagination',
|
||||
component: Pagination,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Pagination>;
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default: StoryObj<PaginationProps> = {
|
||||
args: {
|
||||
currentPage: 1,
|
||||
totalPages: 5,
|
||||
onPageChange: (page: number) => console.log('Page changed to:', page),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Table } from '@tanstack/react-table';
|
||||
import { ArrowLeftOutlined, ArrowRightOutlined } from '@ant-design/icons';
|
||||
|
||||
interface PaginationProps<T> {
|
||||
table: Table<T>;
|
||||
}
|
||||
|
||||
export const Pagination = <T,>({ table }: PaginationProps<T>) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-[40px]">
|
||||
<button
|
||||
className="disabled:opacity-50 cursor-pointer"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ArrowLeftOutlined className="text-[16px] text-neutral-800" />
|
||||
</button>
|
||||
|
||||
<div className="flex gap-4 items-baseline">
|
||||
{table.getPageCount() <= 8 ? (
|
||||
Array.from({ length: table.getPageCount() }, (_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={`size-[30px] py-[8px] flex items-center justify-center rounded-md cursor-pointer ${
|
||||
table.getState().pagination.pageIndex === index
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-primary-100 hover:bg-primary-200'
|
||||
}`}
|
||||
onClick={() => table.setPageIndex(index)}
|
||||
>
|
||||
{index + 1}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
className={`size-[30px] py-[8px] flex items-center justify-center rounded-md cursor-pointer ${
|
||||
table.getState().pagination.pageIndex === 0
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-primary-100 hover:bg-primary-200'
|
||||
}`}
|
||||
>
|
||||
1
|
||||
</button>
|
||||
{table.getState().pagination.pageIndex > 3 && <span>...</span>}
|
||||
{Array.from(
|
||||
{ length: 5 },
|
||||
(_, index) => table.getState().pagination.pageIndex - 2 + index
|
||||
)
|
||||
.filter((page) => page > 0 && page < table.getPageCount() - 1)
|
||||
.map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => table.setPageIndex(page)}
|
||||
className={`size-[30px] py-[8px] flex items-center justify-center rounded-md cursor-pointer ${
|
||||
table.getState().pagination.pageIndex === page
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-primary-100 hover:bg-primary-200'
|
||||
}`}
|
||||
>
|
||||
{page + 1}
|
||||
</button>
|
||||
))}
|
||||
{table.getState().pagination.pageIndex <
|
||||
table.getPageCount() - 4 && <span>...</span>}
|
||||
<button
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
className={`size-[30px] py-[8px] flex items-center justify-center rounded-md cursor-pointer ${
|
||||
table.getState().pagination.pageIndex ===
|
||||
table.getPageCount() - 1
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-primary-100 hover:bg-primary-200'
|
||||
}`}
|
||||
>
|
||||
{table.getPageCount()}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="disabled:opacity-50 cursor-pointer"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ArrowRightOutlined className="text-[16px] text-neutral-800" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './stepper';
|
||||