Compare commits

...
Author SHA1 Message Date
egagofur 638b64b715 fix: validation by query param 2025-04-04 16:42:27 +07:00
egagofur d61cb4a215 fix: split zod validation register modal 2025-04-04 16:38:21 +07:00
Maulana Sodiqin 272db82c8c Merge pull request #18 from IMPHNEN/feat/register-integeration
feat: integeration register modal
2025-04-04 06:03:13 +07:00
egagofur 9108ddb742 fix: unused field 2025-04-03 23:39:41 +07:00
egagofur 628e168342 fix: form type 2025-04-03 23:39:09 +07:00
egagofur 9aff9e2dc8 feat: integeration register modal 2025-04-03 23:32:44 +07:00
Maulana Sodiqin 585ad482f2 Merge pull request #16 from IMPHNEN/feat/backoffice-login
Backoffice login
2025-04-03 23:02:36 +08:00
Hafid Nur 988476b409 refactor auth and logout
- Pindah hard-code API version ke environment variable
- Gunakan helper untuk logout
2025-04-03 21:59:31 +07:00
Hafid Nur 38e6d4643b Merge branch 'develop' into feat/backoffice-login 2025-04-03 21:51:00 +07:00
Maulana Sodiqin 79cb9d81fc Merge pull request #15 from IMPHNEN/docs
Update README
2025-04-03 22:25:19 +08:00
Maulana Sodiqin 64e702df5d Merge pull request #17 from IMPHNEN/feat/handle-state-modal
Feat/handle state modal
2025-04-03 21:01:05 +08:00
egagofur 280afd68ab fix: template github 2025-04-03 19:51:31 +07:00
egagofur 9777b7bea9 feat: add toast library 2025-04-03 19:40:19 +07:00
egagofur 6bad504d44 feat: change wording validation 2025-04-03 19:40:04 +07:00
egagofur 6b959a2f22 feat: change from dummy button to navbar button 2025-04-03 19:39:53 +07:00
Hafid Nur 33c04707f8 feat: login backoffice 2025-04-03 16:28:54 +07:00
Hafid Nur f87dc4c782 Merge branch 'develop' into feat/backoffice-login 2025-04-03 15:45:23 +07:00
Hafid Nur b5428d8552 Update README.md 2025-04-03 14:15:52 +07:00
Hafid Nur 0a846b0512 docs: fix 2025-04-03 14:03:38 +07:00
Hafid Nur 3a950ea867 docs: align center logo 2025-04-03 14:01:14 +07:00
Hafid Nur 4693284b37 docs: Update README 2025-04-03 13:59:27 +07:00
Maulana Sodiqin f8f796e071 chore: fix global error build 2025-04-03 12:31:16 +07:00
Hafid Nur fe1928e8ef Merge branch 'develop' into feat/backoffice-login 2025-04-03 12:29:28 +07:00
Maulana Sodiqin 9c7b8c1c63 chore: kesalahan pemula 2025-04-03 12:27:22 +07:00
Maulana Sodiqin 2bd767d20b chore: kesalahan pemula 2025-04-03 12:26:45 +07:00
Maulana Sodiqin a6fea630a4 feat: login example 2025-04-03 12:21:10 +07:00
Maulana Sodiqin c05061becf feat: login example 2025-04-03 12:21:08 +07:00
Hafid Nur a9cc728ade feat(backoffice): implement login and logout
- Tambah fitur login dan logout pada Backoffice
- Ubah port development server dan atasi CORS
- Update type login response
- Update component Input agar toggle password berfungsi dengan benar
2025-04-03 06:10:07 +07:00
Hafid Nur 2602657201 feat(backoffice): send login request
- buat .env untuk menyimpan API dan memberikan .env.example juga untuk contohnya. Tambah .gitignore juga
- tambah fungsi login pada Backoffice
2025-04-02 22:42:02 +07:00
Maulana Sodiqin c70e5706bc Merge pull request #13 from IMPHNEN/feat/register-form
Feat/register form
2025-04-01 21:16:59 +08:00
Maulana Sodiqin 49ca2a9fb0 Merge pull request #12 from IMPHNEN/feat/backoffice-modal
Slicing the design of the Modal component in Backoffice
2025-04-01 21:10:53 +08:00
egagofur 47ebd3ad73 feat: add mr template 2025-04-01 10:17:42 +07:00
egagofur d544e43abc feat: add register form and change hooks to modal component 2025-04-01 10:14:46 +07:00
Hafid Nur d7a90f47d0 feat: dynamic radio button for Filter component 2025-03-31 22:46:09 +07:00
Hafid Nur 984a31c587 fix: create custom hook and stepper to make multi-step modal functional
- Tambah custom hook 'use-query-state' dan implementasi stepper pada modal dengan tahapan lebih dari 1
- Hapus component ModalBackoffice karena sudah menggunakan component umum Modal
2025-03-31 22:36:37 +07:00
Hafid Nur a575e534b5 feat: slicing modal for backoffice 'data akun' page 2025-03-31 21:53:18 +07:00
Hafid Nur 989503f930 feat: slicing modal for backoffice 'validasi transaksi' page
- Tambah fungsi untuk handle aksi ketika button pada modal ditekan
2025-03-31 21:40:00 +07:00
Hafid Nur 3b979119ed feat: slicing modal for backoffice 'data pengiriman hadiah' page 2025-03-31 17:32:30 +07:00
Hafid Nur ecec423d30 feat: slicing modal for backoffice dashboard page 2025-03-31 01:33:18 +07:00
Hafid Nur 3bbdba7015 fix: add type=file on Input & InputForm components
Juga fix script untuk serve backoffice
2025-03-31 01:13:21 +07:00
Hafid Nur f7a7acaf65 feat(backoffice): add modal delete gacha item 2025-03-31 00:30:05 +07:00
Maulana Sodiqin 809eeee931 Merge pull request #11 from IMPHNEN/feat/forgot-password
feat: dyamic stepper form forgot password
2025-03-30 23:49:15 +08:00
egagofur f39466936f feat: add todo change using real button trigger 2025-03-30 22:47:29 +07:00
egagofur 5d418830b9 feat: add todo component otp input 2025-03-30 22:44:59 +07:00
egagofur d58ad58ef8 feat: dyamic stepper form forgot password 2025-03-30 22:41:06 +07:00
Maulana Sodiqin b62c762cf9 Merge pull request #7 from IMPHNEN/DimentorinAuth
feat: Auth
2025-03-29 22:19:13 +08:00
Naufal Azmi 4e665cca76 Merge branch 'develop' into DimentorinAuth 2025-03-29 21:00:10 +07:00
boboiazumi 74c0ebc5dd fix: bugs 2025-03-29 20:57:48 +07:00
boboiazumi eadd554a32 fix: conflict 2025-03-29 20:47:38 +07:00
boboiazumi ecdaa4a82e feat: Forgot Password Slicing, fix: webp image 2025-03-29 20:42:54 +07:00
Maulana Sodiqin 747e75e684 Merge pull request #8 from IMPHNEN/feat/backoffice
Backoffice Update
2025-03-29 21:18:10 +08:00
Maulana Sodiqin 76637dd051 Merge pull request #10 from IMPHNEN/feat/modal-unit-test-and-storybook
Feat/modal unit test and storybook
2025-03-29 21:16:51 +08:00
egagofur 3dda8149ba Merge branch 'develop' into feat/modal-unit-test-and-storybook 2025-03-29 17:56:39 +07:00
egagofur cc21735468 feat: unit test and storybook modal 2025-03-29 17:56:18 +07:00
Maulana Sodiqin a6bc6de72e Merge pull request #9 from IMPHNEN/feat/gacha-modal-login
feat: add modal login gacha page
2025-03-29 18:33:12 +08:00
egagofur 340bd8c4bf feat: add modal login gacha page 2025-03-29 17:26:54 +07:00
Hafid Nur 1594be9d46 feat: slicing modal backoffice
- Slicing desain modal untuk backoffice di storybook component ModalBackoffice
- Update static directory untuk storybook agar dapat membaca gambar pada folder public
- Minor edit component button & input
2025-03-29 02:34:02 +07:00
Hafid Nur 7eecf2621d feat: add filter popover component for delivery status
- Tambah fungsional close button untuk komponen Filter
- Penyesuaian warna neutral sesuai Design System
2025-03-29 01:17:32 +07:00
Hafid Nur 088a0fc41f feat: Add Filter component 2025-03-29 00:59:25 +07:00
Hafid Nur 9178c65c4a feat: Add page "Data Pengiriman Hadiah"
- Update sidebar link untuk halaman Data Pengiriman Hadiah
- Penyesuaian kecil pada style DataTable dan header page
2025-03-28 22:41:55 +07:00
Hafid Nur f93d741cef feat: Minor adjustment
- Optimize gambar dengan webp
- Perlebar tampilan utama di layar besar
- Fix DataTable props
2025-03-28 21:46:10 +07:00
Naufal Azmi 81fa73935f Merge branch 'develop' into DimentorinAuth 2025-03-28 19:09:37 +07:00
Maulana Sodiqin 88c71ccdfa Merge pull request #6 from IMPHNEN/backoffice/auth
Backoffice Login, Dashboard, Accounts, Transactions page
2025-03-28 20:05:34 +08:00
Hafid Nur 5b3b9e44fa feat: Implement DataTable to backoffice pages
- Halaman Data Akun
- Halaman Validasi Transaksi
2025-03-28 18:50:54 +07:00
Hafid Nur ac2ff2ec92 feat: Combine Pagination component into DataTable 2025-03-28 18:50:17 +07:00
Hafid Nur 79e0d39280 feat: separate DataTable component 2025-03-28 18:17:13 +07:00
Hafid Nur fd9113109c feat: Working TanStack pagination
- Menerapkan TanStack Table dan Pagination
2025-03-28 17:29:06 +07:00
Hafid Nur da7e1067d2 feat: Implement pagination
- Terapkan pagination pada page Data Akun
2025-03-28 16:50:58 +07:00
Hafid Nur 75779b0373 feat: Simple implementation TanStack table
Coba implementasikan versi paling sederhana TanStack Table v8 langsung ke page Data Akun
2025-03-28 16:21:27 +07:00
boboiazumi a3887519f9 feat: Login Page 2025-03-28 11:46:27 +07:00
Hafid Nur 2ec6ad5b5c feat: Add TanStack Table v8 and implement it to Datatable component 2025-03-28 00:19:01 +07:00
Hafid Nur 2d541c71ba Merge branch into backoffice/auth 2025-03-27 23:19:45 +07:00
Hafid Nur 46f3bd6014 fix: Sidebar stick to left. Fit content for different desktop viewports 2025-03-27 23:17:43 +07:00
Hafid Nur c374c97e66 fix: Sidebar stick to left. Fit content for different desktop viewports 2025-03-27 23:15:11 +07:00
Hafid Nur a4ca53004f feat: fix datatable storybook 2025-03-27 22:52:35 +07:00
Hafid Nur 2d9473ea7c feat: Adjustment
- Sesuaikan color palette success, info, danger, warning sesuai dengan design system Figma
- Sesuai background pagination
- Fix width form login tidak maksimal
2025-03-27 22:28:40 +07:00
Hafid Nur 6bfa48bed8 feat: Change data table component dynamically
- Ubah komponen DataTable agar bisa render menyesuaikan jumlah kolom dan baris pada data
2025-03-27 17:22:04 +07:00
Hafid Nur 43167fe7a1 feat: Draft transaction page 2025-03-27 16:58:24 +07:00
Hafid Nur 39391e5522 feat: Page Data Akun 2025-03-27 16:55:54 +07:00
Hafid Nur ec509a7b43 feat: Styling pagination 2025-03-27 16:41:42 +07:00
Hafid Nur a76bd89ec1 feat: Styling data table component 2025-03-27 16:29:09 +07:00
Hafid Nur cafdb29024 feat: Refactor datatable and pagination component and add storybook
- Refactor nama component dari datatable dan pagination mengikuti codebase
- Buat storybook untuk preview dari masing-masing komponen
2025-03-27 15:59:02 +07:00
Hafid Nur d79d0d3d09 feat: Draft Data Akun page
- Vibes coding untuk siapkan base dari halaman Data Akun
2025-03-27 15:17:04 +07:00
Hafid Nur 7606c5b4e2 feat: Divider on top of logout button sidebar 2025-03-27 15:08:22 +07:00
Hafid Nur bc9a0fd740 feat: Another page template
- Template untuk page Data Akun dan Validasi Transaksi
2025-03-27 15:06:30 +07:00
Hafid Nur 7f9a106fca feat: Fix gacha image preview
- Menggunakan pendekatan simpan gambar clipped langsung daripada mengatur secara dinamis menggunakan CSS object-position
2025-03-27 15:00:04 +07:00
Hafid Nur 0bcc8f7f6a feat: gacha section in dashboard 2025-03-27 14:31:30 +07:00
Hafid Nur 31b7587571 feat: summary section in dashboard page 2025-03-27 13:35:13 +07:00
Hafid Nur 82fb1ea2bc feat: Update sidebar
- Perbaiki tampilan sidebar sesuai desain
2025-03-27 13:18:14 +07:00
Hafid Nur bd5e46e712 feat: Draft dashboard AI-generated 2025-03-27 07:46:15 +07:00
Hafid Nur 542469bc67 feat: Update InputForm, Input, and auth page
- Perbaiki page login ke halaman utama backoffice
- Perbaiki component Input dan InputForm
2025-03-27 07:19:46 +07:00
Hafid Nur 631684cb05 feat: Update InputForm to handle different sizes
- Tambah spec test pada komponen InputForm
- Tambah autodocs pada komponen Input
2025-03-27 06:00:03 +07:00
Hafid Nur 54f4572533 feat: Update InputForm component
- Perbarui docs untuk component Input dan InputForm
- Perbarui component InputForm agar bekerja dengan helper text, error, dan label/input
2025-03-27 05:25:02 +07:00
Hafid Nur ca933f624a feat: Create a separate "InputForm" molecule component
- Develop the "InputForm" molecule component
- Remove empty modal-gacha component stories and test files that prevent Storybook from running
2025-03-27 04:35:41 +07:00
Hafid Nur 90ab974e03 feat: backoffice login page 2025-03-27 02:42:57 +07:00
Maulana Sodiqin daeeb09798 feat: integrate with new api 2025-03-19 23:38:42 +07:00
Maulana Sodiqin c8935b885e Merge pull request #5 from IMPHNEN/ModalsGacha
Modals gacha
2025-03-17 18:34:21 +07:00
138 changed files with 5923 additions and 1189 deletions
+1
View File
@@ -0,0 +1 @@
VITE_API_URL=https://api.example.com
+30
View File
@@ -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...
-->
+7 -1
View File
@@ -44,4 +44,10 @@ Thumbs.db
vite.config.*.timestamp*
vitest.config.*.timestamp*
storybook-static
storybook-static
.env
.env.prod
.env.develop
.env.staging
+89 -73
View File
@@ -1,101 +1,117 @@
# ImphnenFrontendService
# IMPHNEN Frontend Service
<a alt="Nx logo" href="https://nx.dev" target="_blank" rel="noreferrer"><img src="https://raw.githubusercontent.com/nrwl/nx/master/images/nx-logo.png" width="45"></a>
<p align="center">
<img src="docs/logo.svg" alt="IMPHNEN">
</p>
✨ Your new, shiny [Nx workspace](https://nx.dev) is ready ✨.
This repository is a **monorepo** for all frontend services of IMPHNEN. The monorepo includes three main applications:
[Learn more about this workspace setup and its capabilities](https://nx.dev/getting-started/tutorials/react-monorepo-tutorial?utm_source=nx_project&amp;utm_medium=readme&amp;utm_campaign=nx_projects) or run `npx nx graph` to visually explore what was created. Now, let's get you up to speed!
1. **Gacha** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">gacha website</a>.
2. **Backoffice** - Application for internal management.
3. **Dimentorin** - Application for mentoring services.
## Run tasks
## How to install
To run the dev server for your app, use:
1. Clone this repository:
```sh
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git
cd imphnen-frontend-service
```
2. Install all dependencies:
```sh
npm install
```
```sh
npx nx serve dimentorin
```
## How to run
To create a production bundle:
### Development
```sh
npx nx build dimentorin
```
Use the following commands to run in development mode:
To see all available targets to run for a project, run:
- **Gacha**:
```sh
npm run gacha:dev
```
- **Backoffice**:
```sh
npm run backoffice:dev
```
- **Dimentorin**:
```sh
npm run dimentorin:dev
```
```sh
npx nx show project dimentorin
```
### Build
These targets are either [inferred automatically](https://nx.dev/concepts/inferred-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) or defined in the `project.json` or `package.json` files.
Use the following commands to build the applications:
[More about running tasks in the docs &raquo;](https://nx.dev/features/run-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
- **Gacha**:
```sh
npm run gacha:build
```
- **Backoffice**:
```sh
npm run backoffice:build
```
- **Dimentorin**:
```sh
npm run dimentorin:build
```
## Add new projects
### Production
While you could add new projects to your workspace manually, you might want to leverage [Nx plugins](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) and their [code generation](https://nx.dev/features/generate-code?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) feature.
Use the following commands to run the applications in production mode:
Use the plugin's generator to create new projects.
- **Gacha**:
```sh
npm run gacha:prod
```
- **Backoffice**:
```sh
npm run backoffice:prod
```
- **Dimentorin**:
```sh
npm run dimentorin:prod
```
To generate a new application, use:
### Storybook
```sh
npx nx g @nx/react:app demo
```
This repository uses Storybook to develop, test, and document UI components in an isolated and interactive environment. Below are the commands to work with Storybook:
To generate a new library, use:
- **Run Storybook**
This command starts Storybook in development mode, allowing you to view and test UI components interactively.
```sh
npx nx g @nx/react:lib mylib
```
```sh
npm run ui:storybook
```
You can use `npx nx list` to get a list of installed plugins. Then, run `npx nx list <plugin-name>` to learn about more specific capabilities of a particular plugin. Alternatively, [install Nx Console](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) to browse plugins and generators in your IDE.
- **Run Unit Test**
[Learn more about Nx plugins &raquo;](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) | [Browse the plugin registry &raquo;](https://nx.dev/plugin-registry?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
This command runs unit tests for the UI components to ensure they function as expected.
## Set up CI!
```sh
npm run ui:test
```
### Step 1
- **Build Components**
To connect to Nx Cloud, run the following command:
This command generates a static build of Storybook, which can be deployed for sharing and documentation purposes.
```sh
npx nx connect
```
```sh
npm run ui:build
```
Connecting to Nx Cloud ensures a [fast and scalable CI](https://nx.dev/ci/intro/why-nx-cloud?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) pipeline. It includes features such as:
## How to contribute
- [Remote caching](https://nx.dev/ci/features/remote-cache?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
- [Task distribution across multiple machines](https://nx.dev/ci/features/distribute-task-execution?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
- [Automated e2e test splitting](https://nx.dev/ci/features/split-e2e-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
- [Task flakiness detection and rerunning](https://nx.dev/ci/features/flaky-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
1. Fork the repository and clone it locally.
2. Create a new branch for a new feature or fix:
```sh
git checkout -b feat/feature-name
```
3. Make changes, commit, and push to your forked repository.
4. Create a pull request to this repository `develop` branch.
### Step 2
Use the following command to configure a CI workflow for your workspace:
```sh
npx nx g ci-workflow
```
[Learn more about Nx on CI](https://nx.dev/ci/intro/ci-with-nx#ready-get-started-with-your-provider?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
## Install Nx Console
Nx Console is an editor extension that enriches your developer experience. It lets you run tasks, generate code, and improves code autocompletion in your IDE. It is available for VSCode and IntelliJ.
[Install Nx Console &raquo;](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
## Useful links
Learn more:
- [Learn more about this workspace setup](https://nx.dev/getting-started/tutorials/react-monorepo-tutorial?utm_source=nx_project&amp;utm_medium=readme&amp;utm_campaign=nx_projects)
- [Learn about Nx on CI](https://nx.dev/ci/intro/ci-with-nx?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
- [Releasing Packages with Nx release](https://nx.dev/features/manage-releases?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
- [What are Nx plugins?](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
And join the Nx community:
- [Discord](https://go.nx.dev/community)
- [Follow us on X](https://twitter.com/nxdevtools) or [LinkedIn](https://www.linkedin.com/company/nrwl)
- [Our Youtube channel](https://www.youtube.com/@nxdevtools)
- [Our blog](https://nx.dev/blog?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
If you encounter any issues or problems, feel free to create a new Issue.
+1 -1
View File
@@ -6,7 +6,7 @@
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
<link rel="stylesheet" href="/src/styles.css" />
</head>
<body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 351 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 511 KiB

@@ -0,0 +1,31 @@
import { useForm } from 'react-hook-form';
import {
authLoginSchema,
TLoginRequest,
usePostLogin,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useNavigate } from 'react-router-dom';
export const useLogin = () => {
const postLogin = usePostLogin();
const form = useForm<TLoginRequest>({
resolver: zodResolver(authLoginSchema),
mode: 'all',
});
const navigate = useNavigate();
const onSubmit = form.handleSubmit((data) => {
postLogin.mutate(data, {
onSuccess: () => {
console.log('Success Login');
navigate('/dashboard');
},
});
});
return {
form,
onSubmit,
};
};
@@ -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;
+195
View File
@@ -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;
+210
View File
@@ -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;
-2
View File
@@ -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>
);
+38 -1
View File
@@ -1,7 +1,44 @@
import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useLogin } from './_hooks/use-login';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
export const Components: FC = (): ReactElement => {
return <>Hallo</>;
const { form, onSubmit } = useLogin();
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>
<form onSubmit={onSubmit} className="flex flex-col gap-8">
<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"
/>
<Button type="submit" size="md" className="w-full">
Login
</Button>
</form>
</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;
+18
View File
@@ -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;
+251
View File
@@ -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,
};
};
+44 -48
View File
@@ -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;
}
}
}
+2 -2
View File
@@ -8,11 +8,11 @@ export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/apps/backoffice',
server: {
port: 4200,
port: 3000,
host: 'localhost',
},
preview: {
port: 4300,
port: 3001,
host: 'localhost',
},
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

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,180 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { useQueryState } from '@imphnen-frontend-service/utils';
import { useRegister } from '../../_hooks/use-register';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { UseFormReturn } from 'react-hook-form';
import { TRegisterRequest } from '@imphnen-frontend-service/service';
import { useEffect } from 'react';
interface IModalFormRegisterProps {
isOpen: boolean;
onClose: () => void;
}
const ModalFormRegister = ({ isOpen, onClose }: IModalFormRegisterProps) => {
const { form, onSubmit, isStepOneValid } = useRegister();
const {
step: currentStep,
nextStep,
prevStep,
resetStep,
} = useQueryState('registerStep', {
defaultValue: 1,
maxValue: 2,
minValue: 1,
});
useEffect(() => {
if (!isStepOneValid && currentStep === 2) {
prevStep();
}
}, [isStepOneValid, currentStep, prevStep]);
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>
<form onSubmit={onSubmit} className="space-y-6">
{currentStep === 1 && (
<StepOne
form={form}
nextStep={nextStep}
isStepOneValid={isStepOneValid}
/>
)}
{currentStep === 2 && <StepTwo form={form} prevStep={prevStep} />}
</form>
</Modal.Content>
</Modal>
);
};
interface IStepOneProps {
form: UseFormReturn<TRegisterRequest, any, TRegisterRequest>;
nextStep: () => void;
isStepOneValid: boolean;
}
const StepOne = ({ form, nextStep, isStepOneValid }: IStepOneProps) => {
return (
<>
<ControlledInputField
control={form.control}
name="fullname"
label="Nama Lengkap"
placeholder="Masukkan Nama Lengkap"
type="text"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Email"
placeholder="Masukkan Email Anda"
type="email"
name="email"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="password"
label="Password"
placeholder="Masukkan Password"
type="password"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="confirm_password"
label="Ulangi Password"
placeholder="Masukkan Ulang Password"
type="password"
size="lg"
className="w-full"
/>
<Button
size="md"
className="w-full"
onClick={nextStep}
disabled={!isStepOneValid}
>
Selanjutnya
</Button>
</>
);
};
interface IStepTwoProps {
form: UseFormReturn<TRegisterRequest, any, TRegisterRequest>;
prevStep: () => void;
}
const StepTwo = ({ form, prevStep }: IStepTwoProps) => (
<>
<ControlledInputField
control={form.control}
name="phone_number"
label="Nomor Telepon"
placeholder="Masukkan Nomor Telepon Aktif"
type="text"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="referral_code"
label="Kode Referral"
placeholder="Masukkan Kode Referral"
type="text"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="referred_by"
label="Referral By"
placeholder="Masukkan Referral By"
type="text"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="student_type"
label="Tipe Pelajar"
placeholder="Masukkan Tipe Pelajar"
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" type="submit">
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>
);
};
+26
View File
@@ -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,
};
};
+58
View File
@@ -0,0 +1,58 @@
import { useForm } from 'react-hook-form';
import {
authRegisterSchema,
stepOneRegisterSchema,
TRegisterRequest,
usePostRegister,
} from '@imphnen-frontend-service/service';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { useEffect, useState } from 'react';
export const useRegister = () => {
const postRegister = usePostRegister();
const form = useForm<TRegisterRequest>({
resolver: zodResolver(authRegisterSchema),
mode: 'all',
defaultValues: {
fullname: '',
email: '',
password: '',
confirm_password: '',
phone_number: '',
referral_code: '',
referred_by: '',
student_type: '',
},
});
const [stepValid, setStepValid] = useState(false);
useEffect(() => {
const subscription = form.watch(() => {
const { fullname, email, password, confirm_password } = form.getValues();
const valid = stepOneRegisterSchema.safeParse({
fullname,
email,
password,
confirm_password,
}).success;
setStepValid(valid);
});
return () => subscription.unsubscribe();
}, [form]);
const onSubmit = form.handleSubmit((data) => {
postRegister.mutate(data, {
onSuccess: (data) => toast.success(data.message),
onError: (error) => toast.error(error.message),
});
});
return {
form,
onSubmit,
isStepOneValid: stepValid,
};
};
+9 -7
View File
@@ -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;
+47 -76
View File
@@ -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>
);
};
+44 -48
View File
@@ -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;
}
}
}
+2
View File
@@ -8,6 +8,7 @@ import {
convertPagesToRoute,
QueryProvider,
} from '@imphnen-frontend-service/utils';
import { Toaster } from 'sonner';
import './index.css';
const files = import.meta.glob('./app/**/*(page|layout).tsx');
@@ -34,6 +35,7 @@ if (!rootElement) throw new Error('Failed to find the root element');
createRoot(rootElement).render(
<StrictMode>
<QueryProvider>
<Toaster />
<RouterProvider router={router} />
</QueryProvider>
</StrictMode>
+9
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 351 KiB

+12
View File
@@ -0,0 +1,12 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
+7
View File
@@ -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/).
+12
View File
@@ -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: {},
},
];
+12
View File
@@ -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"
}
}
}
+23
View File
@@ -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"
}
}
}
}
+41
View File
@@ -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;
};
+1
View File
@@ -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,
};
+1
View File
@@ -0,0 +1 @@
export {};
+52
View File
@@ -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),
});
};
+1
View File
@@ -0,0 +1 @@
export {};
+3
View File
@@ -0,0 +1,3 @@
export * from './auth';
export * from './gacha';
export * from './users';
+1
View File
@@ -0,0 +1 @@
export {};
+4
View File
@@ -0,0 +1,4 @@
export * from './api';
export * from './hooks';
export * from './types';
export * from './schemas';
+78
View File
@@ -0,0 +1,78 @@
import { z } from 'zod';
export const authLoginSchema = z.object({
email: z
.string({
required_error: 'Email tidak boleh kosong',
invalid_type_error: 'Email harus berupa string',
})
.min(1, 'Email tidak boleh kosong')
.email('Email harus valid'),
password: z
.string({
required_error: 'Password tidak boleh kosong',
invalid_type_error: 'Password harus berupa string',
})
.min(1, 'Password tidak boleh kosong'),
});
export const stepOneRegisterSchema = z
.object({
email: z
.string({
required_error: 'Email tidak boleh kosong',
invalid_type_error: 'Email harus berupa string',
})
.min(1, 'Email tidak boleh kosong')
.email('Email harus valid'),
fullname: z
.string({
required_error: 'Nama tidak boleh kosong',
invalid_type_error: 'Nama harus berupa string',
})
.min(1, 'Nama tidak boleh kosong')
.max(50, 'Nama tidak boleh lebih dari 50 karakter'),
password: z
.string({
required_error: 'Password tidak boleh kosong',
invalid_type_error: 'Password harus berupa string',
})
.min(1, 'Password tidak boleh kosong')
.min(8, 'Password harus lebih dari 8 karakter')
.max(50, 'Password tidak boleh lebih dari 50 karakter'),
confirm_password: z
.string({
required_error: 'Konfirmasi password tidak boleh kosong',
})
.min(1, 'Konfirmasi password tidak boleh kosong')
.max(50, 'Konfirmasi password tidak boleh lebih dari 50 karakter'),
})
.refine((data) => data.password === data.confirm_password, {
message: 'Password dan Konfirmasi Password harus sama',
path: ['confirm_password'],
});
const stepTwoRegisterSchema = z.object({
phone_number: z
.string({
required_error: 'Nomor telepon tidak boleh kosong',
invalid_type_error: 'Nomor telepon harus berupa string',
})
.min(1, 'Nomor telepon tidak boleh kosong')
.max(15, 'Nomor telepon tidak boleh lebih dari 15 karakter'),
referral_code: z
.string()
.min(1, 'Kode referral tidak boleh kosong')
.max(4, 'Kode referral tidak boleh lebih dari 4 karakter')
.optional(),
referred_by: z
.string()
.min(1, 'Kode referral tidak boleh kosong')
.max(50, 'Kode referral tidak boleh lebih dari 50 karakter')
.optional(),
student_type: z.string(),
});
export const authRegisterSchema = stepOneRegisterSchema.and(
stepTwoRegisterSchema
);
+1
View File
@@ -0,0 +1 @@
export * from './auth';
+32
View File
@@ -0,0 +1,32 @@
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 = {
email: string;
fullname: string;
password: string;
phone_number: string;
referral_code?: string;
referred_by?: string;
student_type: string;
confirm_password: string;
};
export type TVerifyEmailRequest = {
email: string;
otp: number;
};
+11
View File
@@ -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>;
+1
View File
@@ -0,0 +1 @@
export {};
+5
View File
@@ -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;
};
+9
View File
@@ -0,0 +1,9 @@
import { TPermissionItem } from '../permissions';
export type TRoleItem = {
id: string;
name: string;
created_at: string;
updated_at: string;
permissions: TPermissionItem[];
};
+19
View File
@@ -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;
};
+21
View File
@@ -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"
}
+35
View File
@@ -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"]
}
+28
View File
@@ -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"
]
}
+59
View File
@@ -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,
},
},
}));
+4
View File
@@ -11,6 +11,10 @@ const config: StorybookConfig = {
},
},
},
staticDirs: [
'../../../apps/backoffice/public',
'../../../apps/gacha/public',
'../../../apps/dimentorin/public', ],
};
export default config;
+1 -1
View File
@@ -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
View File
@@ -1,4 +1,3 @@
export * from './button';
export * from './input';
export * from './password-input';
export * from './textarea';
+7 -28
View File
@@ -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();
});
});
+12 -19
View File
@@ -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',
},
};
+47 -18
View File
@@ -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,72 @@ 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 = (e: React.FormEvent) => {
e.preventDefault();
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
type="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>}
</>
);
};
+80 -64
View File
@@ -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';
+6 -1
View File
@@ -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,93 @@
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-label1 text-left">{error}</p>
) : (
helperText && (
<p
className={cn(
'text-label2 text-left',
sizeClasses[size].helperText
)}
>
{helperText}
</p>
)
)}
</div>
);
};
+190
View File
@@ -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,
};
+106
View File
@@ -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');
});
});

Some files were not shown because too many files have changed in this diff Show More