Compare commits

..
Author SHA1 Message Date
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 SodiqinandGitHub 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 SodiqinandGitHub 79cb9d81fc Merge pull request #15 from IMPHNEN/docs
Update README
2025-04-03 22:25:19 +08:00
Maulana SodiqinandGitHub 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 NurandGitHub 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 SodiqinandGitHub 9c7b8c1c63 chore: kesalahan pemula 2025-04-03 12:27:22 +07:00
Maulana SodiqinandGitHub 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 SodiqinandGitHub c70e5706bc Merge pull request #13 from IMPHNEN/feat/register-form
Feat/register form
2025-04-01 21:16:59 +08:00
Maulana SodiqinandGitHub 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 SodiqinandGitHub 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 SodiqinandGitHub b62c762cf9 Merge pull request #7 from IMPHNEN/DimentorinAuth
feat: Auth
2025-03-29 22:19:13 +08:00
Naufal AzmiandGitHub 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 SodiqinandGitHub 747e75e684 Merge pull request #8 from IMPHNEN/feat/backoffice
Backoffice Update
2025-03-29 21:18:10 +08:00
Maulana SodiqinandGitHub 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 SodiqinandGitHub 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
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 AzmiandGitHub 81fa73935f Merge branch 'develop' into DimentorinAuth 2025-03-28 19:09:37 +07:00
boboiazumi a3887519f9 feat: Login Page 2025-03-28 11:46:27 +07:00
107 changed files with 4237 additions and 1225 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.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 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;
+1 -1
View File
@@ -7,7 +7,7 @@ export const AppLayout: FC = (): ReactElement => {
<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] mx-auto">
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
<Outlet />
</div>
</div>
+129 -93
View File
@@ -1,13 +1,13 @@
import * as React from 'react';
import { FC, ReactElement } 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 } from '@imphnen-frontend-service/ui/organisms';
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
@@ -17,6 +17,8 @@ import {
useReactTable,
RowSelectionState,
} from '@tanstack/react-table';
import ModalEditAccount from './_components/modal-edit-account';
import { useQueryState } from '../../hook/use-query-state';
interface Account {
id: number;
@@ -32,74 +34,88 @@ const mockData: Account[] = Array.from({ length: 90 }, (_, i) => ({
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
email: 'fullname23@gmail.com',
phone: '081904423804',
address: 'Jl. Pantai Cibaduyut Indonesia',
address: 'Jl. Pantai Cibaduyut Indah',
}));
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();
// handleEdit(row.id);
}}
className="flex items-center gap-2"
>
<EditOutlined /> Edit
</Button>
),
},
];
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,
@@ -118,41 +134,61 @@ export const Components: FC = (): ReactElement => {
});
return (
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
{/* Header */}
<header className="bg-white py-4 px-8 rounded-md 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 />
<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>
<Button
variant="primary"
disabled
size="md"
className="flex items-center gap-3"
>
<FilterOutlined />
Filters
</Button>
</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>
);
};
@@ -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;
+1 -1
View File
@@ -7,7 +7,7 @@ export const AppLayout: FC = (): ReactElement => {
<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] mx-auto">
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
<Outlet />
</div>
</div>
+185 -86
View File
@@ -1,110 +1,209 @@
import { ArrowDownOutlined, PlusOutlined } from '@ant-design/icons';
import {
PlusOutlined,
ReloadOutlined,
UsergroupAddOutlined,
UsergroupDeleteOutlined,
UserSwitchOutlined,
} from '@ant-design/icons';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { FC, ReactElement } from 'react';
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 => {
return (
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
{/* Dashboard Header */}
<header className="bg-white py-4 px-8 rounded-md shadow p-4">
<h1 className="text-p2 font-semibold">Dashboard</h1>
</header>
const [showModalAddItem, setShowModalAddItem] = useState(false);
const [showModalEditItem, setShowModalEditItem] = useState(false);
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
<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">
{/* Summary Cards */}
{[1, 2, 3, 4].map((item) => (
<div
key={item}
className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100"
>
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">
<ArrowDownOutlined className="text-[20px]" />
<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">
Total Participants
Roll and Reroll
</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">
<span>Tambah Item</span>
<PlusOutlined className="text-[16px]" />
</Button>
</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>
{/* 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"
{/* 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)}
>
<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>
<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>
<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"
>
Edit
</Button>
<Button
variant="text"
size="sm"
className="text-[10px] text-neutral-500 p-0 font-normal hover:bg-transparent hover:text-red-500"
>
Delete
</Button>
</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>
{/* Lebih baik gunakan gambar yang sudah di-clip dengan size height: 78px daripada hard-code object-position dan margin */}
<img
src="gacha-clip.png"
alt="Lanyard IMPHNEN"
className="h-full"
/>
</div>
))}
</div>
</section>
{/* Right-side illustration */}
<img
src="gacha.webp"
alt=""
className="rounded-lg hidden xl:block xl:min-w-[436px] h-auto object-cover"
/>
</div>
</main>
{/* Right-side illustration */}
<img
src="gacha.png"
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>
);
};
+26 -18
View File
@@ -1,10 +1,10 @@
import { FC, ReactElement } from 'react';
import { useNavigate } from 'react-router';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { InputForm } from '@imphnen-frontend-service/ui/molecules';
import { useLogin } from './_hooks/use-login';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
export const Components: FC = (): ReactElement => {
const navigate = useNavigate();
const { form, onSubmit } = useLogin();
return (
<div className="flex justify-center items-center min-h-screen">
@@ -13,21 +13,29 @@ export const Components: FC = (): ReactElement => {
<h1 className="text-primary-500 text-p1 font-semibold">
Welcome to IMPHNEN Backoffice
</h1>
<InputForm
label="Email"
placeholder="Masukkan Email"
type="email"
size="lg"
className="w-full"
/>
<InputForm
label="Password"
placeholder="Masukkan password"
type="password"
size="lg"
className="w-full"
/>
<Button onClick={() => navigate('/dashboard')}>Login</Button>
<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>
);
@@ -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;
@@ -7,7 +7,7 @@ export const AppLayout: FC = (): ReactElement => {
<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] mx-auto">
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
<Outlet />
</div>
</div>
+143 -107
View File
@@ -1,13 +1,12 @@
import * as React from 'react';
import { FC, ReactElement } 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 } from '@imphnen-frontend-service/ui/organisms';
import { DataTable, Filter } from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
@@ -17,6 +16,7 @@ import {
useReactTable,
RowSelectionState,
} from '@tanstack/react-table';
import ModalValidate from './_components/modal-validate';
type TransactionStatus = 'valid' | 'invalid' | 'unchecked';
@@ -39,87 +39,96 @@ const mockTransactions: Transaction[] = Array.from({ length: 20 }, (_, i) => ({
: 'valid') as TransactionStatus,
}));
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: 'Nomor Transaksi',
accessorKey: 'transactionNumber',
},
{
header: 'Order Valid?',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<TransactionStatus, string> = {
valid: 'bg-success-500 text-white',
invalid: 'bg-danger-500 text-white',
unchecked: 'bg-yellow-400 text-black',
};
const statusText: Record<TransactionStatus, string> = {
valid: 'Valid',
invalid: 'Invalid',
unchecked: 'Unchecked',
};
return (
<div
className={`py-1 px-3 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
},
{
header: 'Action',
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
// handleUpdate(row.id);
}}
className="flex items-center gap-2 w-full"
>
<AuditOutlined className="text-[16px]" /> Update
</Button>
),
},
];
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,
@@ -138,40 +147,67 @@ export const Components: FC = (): ReactElement => {
});
return (
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
{/* Header */}
<header className="bg-white py-4 px-8 rounded-md shadow p-4">
<h1 className="text-p2 font-semibold">Validasi Transaksi</h1>
</header>
<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 />
{/* 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>
<Button
variant="primary"
size="md"
className="flex items-center gap-3"
>
<FilterOutlined />
Filters
</Button>
</div>
{/* Table */}
<DataTable data={mockTransactions} columns={columns} table={table} />
</section>
</main>
{/* 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>
);
};
@@ -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,
};
};
+7 -7
View File
@@ -17,14 +17,14 @@
--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-100: #e0fbd8;
+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,162 @@
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';
interface IModalFormRegisterProps {
isOpen: boolean;
onClose: () => void;
}
const ModalFormRegister = ({ isOpen, onClose }: IModalFormRegisterProps) => {
const { form, onSubmit } = useRegister();
const {
step: currentStep,
nextStep,
prevStep,
resetStep,
} = useQueryState('registerStep', {
defaultValue: 1,
maxValue: 2,
minValue: 1,
});
return (
<Modal
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
isOpen={isOpen}
onClose={() => {
onClose();
resetStep();
}}
>
<Modal.Header className="space-y-4">
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
<h1 className="text-primary-500 text-p1 text-center font-semibold">
Register
</h1>
<h2 className="text-neutral-500 text-lg text-center">
{currentStep === 1 ? 'Info Akun' : 'Informasi Pengiriman Hadiah'}
</h2>
</Modal.Header>
<Modal.Content>
<form onSubmit={onSubmit} className="space-y-6">
{currentStep === 1 && (
<StepOne form={form} nextStep={nextStep} onClose={onClose} />
)}
{currentStep === 2 && <StepTwo form={form} prevStep={prevStep} />}
</form>
</Modal.Content>
</Modal>
);
};
interface IStepOneProps {
form: UseFormReturn<TRegisterRequest, any, TRegisterRequest>;
nextStep: () => void;
onClose: () => void;
}
const StepOne = ({ form, nextStep, onClose }: IStepOneProps) => (
<>
<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}>
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,
};
};
+28
View File
@@ -0,0 +1,28 @@
import { useForm } from 'react-hook-form';
import {
authRegisterSchema,
TRegisterRequest,
usePostRegister,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
export const useRegister = () => {
const postRegister = usePostRegister();
const form = useForm<TRegisterRequest>({
resolver: zodResolver(authRegisterSchema),
mode: 'all',
});
const onSubmit = form.handleSubmit((data) => {
postRegister.mutate(data, {
onSuccess: (data) => toast.success(data.message),
onError: (error) => toast.error(error.message),
});
});
return {
form,
onSubmit,
};
};
+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 -117
View File
@@ -1,19 +1,16 @@
import { ArrowDownOutlined } from '@ant-design/icons';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { InputForm, Modal } from '@imphnen-frontend-service/ui/molecules';
import { cn } from '@imphnen-frontend-service/utils';
import { FC, Fragment, ReactElement, useEffect, useState } from 'react';
import { Link } from 'react-router';
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');
@@ -22,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"
@@ -130,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">
@@ -156,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"
@@ -184,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>
@@ -217,63 +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 */}
<button onClick={() => setShowModal(true)}>Open Modal</button>
<Modal
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
isOpen={showModal}
onClose={() => setShowModal(false)}
>
<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 className="space-y-4">
<InputForm
label="Email"
placeholder="Masukkan Email"
type="email"
size="lg"
className="w-full"
/>
<InputForm
label="Password"
placeholder="Masukkan password"
type="password"
size="lg"
className="w-full"
/>
<Link to="/forgot-password">
<h1 className="text-end text-primary-500 text-xl font-medium">
Lupa Password?
</h1>
</Link>
</Modal.Content>
<Modal.Footer className="flex flex-col md:flex-col pt-4">
<Button
size="md"
className="w-full"
onClick={() => console.log('Login')}
>
Login
</Button>
<div className="flex justify-center gap-2 pt-2.5 font-medium">
<p className="text-neutral-500">Belum punya akun?</p>
<Link
className="text-primary-500 hover:text-primary-600"
to="/register"
>
Daftar disini
</Link>
</div>
</Modal.Footer>
</Modal>
{/**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>
);
};
+7 -7
View File
@@ -17,14 +17,14 @@
--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-100: #e0fbd8;
+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

+1 -1
View File
@@ -1,4 +1,4 @@
import { api } from '@imphnen-frontend-service/utils';
import { api } from '../';
import {
TLoginRequest,
TLoginResponse,
+8
View File
@@ -1,3 +1,11 @@
import axios, { AxiosRequestConfig } from 'axios';
export * from './auth';
export * from './gacha';
export * from './users';
const config: AxiosRequestConfig = {
baseURL: import.meta.env.VITE_API_URL,
};
export const api = axios.create(config);
+7
View File
@@ -6,6 +6,8 @@ import {
TRegisterRequest,
TVerifyEmailRequest,
} from '../../types/auth';
import { SessionToken, SessionUser } from '@imphnen-frontend-service/utils';
import { TResponseError, TResponseMessage } from '../../types/common';
export const usePostLogin = (): UseMutationResult<
@@ -17,6 +19,11 @@ export const usePostLogin = (): UseMutationResult<
return useMutation({
mutationKey: ['post-login'],
mutationFn: async (payload) => await postLogin(payload),
onSuccess: (res) => {
SessionUser.set(res.data.user);
SessionToken.set(res.data.token);
window.location.reload();
},
});
};
+1
View File
@@ -1,3 +1,4 @@
export * from './api';
export * from './hooks';
export * from './types';
export * from './schemas';
+76
View File
@@ -0,0 +1,76 @@
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 authRegisterSchema = 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'),
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(),
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'),
})
.superRefine((val, ctx) => {
if (val.password !== val.confirm_password) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['confirm_password'],
message: `No duplicates allowed.`,
});
}
});
+1
View File
@@ -0,0 +1 @@
export * from './auth';
+9 -6
View File
@@ -1,3 +1,5 @@
import { TUserItem } from '../users';
export type TLoginRequest = {
email: string;
password: string;
@@ -9,18 +11,19 @@ export type TLoginResponse = {
access_token: string;
refresh_token: string;
};
user: {
fullname: string;
email: string;
is_active: boolean;
};
user: TUserItem;
};
};
export type TRegisterRequest = {
fullname: string;
email: string;
fullname: string;
password: string;
phone_number: string;
referral_code?: string;
referred_by?: string;
student_type: string;
confirm_password: string;
};
export type TVerifyEmailRequest = {
+2
View File
@@ -1,3 +1,5 @@
export * from './auth';
export * from './gacha';
export * from './users';
export * from './roles';
export * from './permissions';
@@ -0,0 +1,6 @@
export type TPermissionItem = {
id: string;
name: string;
created_at: string;
updated_at: string;
};
+9
View File
@@ -0,0 +1,9 @@
import { TPermissionItem } from '../permissions';
export type TRoleItem = {
id: string;
name: string;
created_at: string;
updated_at: string;
permissions: TPermissionItem[];
};
+19 -1
View File
@@ -1 +1,19 @@
export {};
import { TRoleItem } from '../roles';
export type TUserItem = {
id: string;
avatar: string;
birthdate: string;
email: string;
fullname: string;
gender: string;
identity_number: string;
is_active: boolean;
is_profile_completed: boolean;
phone_number: string;
referral_code: string;
referred_by: string;
religion: string;
student_type: string;
role: TRoleItem;
};
+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],
+5 -3
View File
@@ -9,7 +9,7 @@ import { EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons'; // Import
import { cn } from '@imphnen-frontend-service/utils';
import { Button } from '../button';
type TInputType = 'text' | 'email' | 'password';
type TInputType = 'text' | 'email' | 'password' | 'file';
type TInputSize = 'sm' | 'md' | 'lg';
type TInputProps = Omit<
@@ -40,12 +40,13 @@ export const Input: FC<TInputProps> = ({
}): ReactElement => {
const [showPassword, setShowPassword] = useState(false); // State for password visibility
const togglePasswordVisibility = () => {
const togglePasswordVisibility = (e: React.FormEvent) => {
e.preventDefault();
if (!disabled) setShowPassword((prev) => !prev);
};
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 min-w-70',
'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,
className
@@ -63,6 +64,7 @@ export const Input: FC<TInputProps> = ({
{type === 'password' && (
<div className="absolute end-0 px-[12px] h-full flex items-center">
<Button
type="button"
variant="text"
size={size}
onClick={togglePasswordVisibility}
+7 -7
View File
@@ -17,14 +17,14 @@
--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-100: #e0fbd8;
@@ -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';
+5 -2
View File
@@ -1,3 +1,6 @@
export * from './input-form';
export * from './forgot-step';
export * from './otp-form';
export * from './input-field';
export * from './pagination';
export * from './modal';
export * from './modal/modal';
export * from './stepper';
@@ -0,0 +1 @@
export * from './input-field';
@@ -1,9 +1,9 @@
import { render, screen } from '@testing-library/react';
import InputForm from './input-form';
import { InputField } from './input-field';
describe('InputForm Component', () => {
describe('InputField Component', () => {
it('renders correctly with disabled prop', () => {
render(<InputForm label="Test Label" disabled={true} />);
render(<InputField label="Test Label" disabled={true} />);
const input = screen.getByLabelText('Test Label');
expect(input).toBeDisabled();
@@ -11,7 +11,7 @@ describe('InputForm Component', () => {
});
it('renders correctly without disabled prop', () => {
render(<InputForm label="Test Label" disabled={false} />);
render(<InputField label="Test Label" disabled={false} />);
const input = screen.getByLabelText('Test Label');
expect(input).not.toBeDisabled();
@@ -1,9 +1,9 @@
import type { Meta, StoryObj } from '@storybook/react';
import { InputForm } from './input-form';
import { InputField } from './input-field';
const meta = {
title: 'Molecules/InputForm',
component: InputForm,
title: 'Molecules/Input Form',
component: InputField,
parameters: {
layout: 'centered',
docs: {
@@ -27,7 +27,7 @@ Cek dan inspect element pada story With HtmlFor untuk melihat hasilnya.
},
},
tags: ['autodocs'],
} satisfies Meta<typeof InputForm>;
} satisfies Meta<typeof InputField>;
export default meta;
type Story = StoryObj<typeof meta>;
@@ -7,10 +7,9 @@ import {
import { Input } from '../../atoms';
import { cn } from '@imphnen-frontend-service/utils';
type TInputType = 'text' | 'email' | 'password';
type TInputSize = 'sm' | 'md' | 'lg';
type TInputFormProps = Omit<
export type TInputType = 'text' | 'email' | 'password' | 'file';
export type TInputSize = 'sm' | 'md' | 'lg';
export type TInputFieldProps = Omit<
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
'size' | 'type'
> & {
@@ -19,7 +18,6 @@ type TInputFormProps = Omit<
size?: TInputSize;
error?: string;
disabled?: boolean;
helperText?: string;
htmlFor?: string;
};
@@ -39,7 +37,7 @@ const sizeClasses: Record<TInputSize, { label: string; helperText: string }> = {
},
};
export const InputForm: FC<TInputFormProps> = ({
export const InputField: FC<TInputFieldProps> = ({
label,
placeholder,
type = 'text',
@@ -77,10 +75,15 @@ export const InputForm: FC<TInputFormProps> = ({
{...rest}
/>
{error ? (
<p className="text-danger-500 text-xs mt-1">{error}</p>
<p className="text-danger-500 text-label1 text-left">{error}</p>
) : (
helperText && (
<p className={cn('text-cs mt-1', sizeClasses[size].helperText)}>
<p
className={cn(
'text-label2 text-left',
sizeClasses[size].helperText
)}
>
{helperText}
</p>
)
@@ -88,5 +91,3 @@ export const InputForm: FC<TInputFormProps> = ({
</div>
);
};
export default InputForm;
@@ -1 +0,0 @@
export * from './input-form';
+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');
});
});
@@ -0,0 +1,84 @@
import { Modal } from './modal';
import { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Modal> = {
title: 'Components/Modal',
component: Modal,
argTypes: {
isOpen: { control: 'boolean' },
onClose: { action: 'closed' },
className: { control: 'text' },
overlayClassName: { control: 'text' },
closeButtonClassName: { control: 'text' },
disableEscapeKeyDown: { control: 'boolean' },
'aria-label': { control: 'text' },
'aria-labelledby': { control: 'text' },
'aria-describedby': { control: 'text' },
},
};
export default meta;
type Story = StoryObj<typeof Modal>;
export const Default: Story = {
args: {
isOpen: true,
onClose: () => {
console.log('Modal closed');
},
children: (
<>
<Modal.Header>
<Modal.Title>Modal Title</Modal.Title>
<Modal.Description>
This is a simple modal description.
</Modal.Description>
</Modal.Header>
<Modal.Content>This is the main content of the modal.</Modal.Content>
<Modal.Footer>
<button className="px-4 py-2 bg-blue-500 text-white rounded">
Confirm
</button>
<button className="px-4 py-2 bg-gray-300 rounded">Cancel</button>
</Modal.Footer>
</>
),
},
};
export const CustomClasses: Story = {
args: {
...Default.args,
className: 'bg-red-100',
overlayClassName: 'bg-opacity-50',
closeButtonClassName: 'text-red-500',
},
};
export const NoEscapeClose: Story = {
args: {
...Default.args,
disableEscapeKeyDown: true,
},
};
export const WithAria: Story = {
args: {
...Default.args,
'aria-label': 'Custom Modal',
'aria-labelledby': 'modal-title',
'aria-describedby': 'modal-desc',
children: (
<>
<Modal.Header>
<Modal.Title id="modal-title">Modal Title</Modal.Title>
<Modal.Description id="modal-desc">
This is a modal with ARIA attributes.
</Modal.Description>
</Modal.Header>
<Modal.Content>Modal content here.</Modal.Content>
</>
),
},
};
+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,
};
+1
View File
@@ -0,0 +1 @@
export * from "./otp-form"
@@ -0,0 +1,61 @@
// eslint-disable-next-line @nx/enforce-module-boundaries
import { Input } from "@imphnen-frontend-service/ui/atoms";
import { ChangeEvent, DetailedHTMLProps, FC, InputHTMLAttributes, ReactElement, useRef, useState } from "react"
type TForgotStepProps = Omit<
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
'size' | 'type'
> & {
};
export const OtpForm: FC<TForgotStepProps> = ({
step = 1,
...rest
}): ReactElement => {
const [cell, setCell] = useState(new Array(6).fill(""))
const inputRef = useRef([])
const handleChange = (index: number, e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
if (!(/^[0-9]?$/.test(value))) return;
const newCell = [...cell]
newCell[index] = value
setCell(newCell)
if(value && index < 6 - 1){
(inputRef.current[index + 1] as HTMLInputElement).focus()
}
}
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
if(e.key === "Backspace" && !cell[index] && index > 0){
const newCell = [...cell]
newCell[index] = ""
setCell(newCell)
const ref = (inputRef.current[index - 1] as HTMLInputElement)
ref.focus()
}
}
return (
<div className="w-full grid grid-cols-6 gap-3 my-10">
{cell.map((v, i) => (
<Input
placeholder=""
ref={(el) => {
inputRef.current.push(el as never)
return inputRef.current[i]
}}
onChange={(e) => handleChange(i, e)}
onKeyDown={(e) => handleKeyDown(i, e)}
size="lg"
value={cell[i]}
className="sm:min-w-[10px]"
/>
))}
</div>
)
}
+1
View File
@@ -0,0 +1 @@
export * from './stepper';
@@ -0,0 +1,48 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Stepper, StepperProps } from './Stepper';
vi.mock('@imphnen-frontend-service/utils', () => ({
cn: (...args: string[]) => args.filter(Boolean).join(' '),
}));
describe('Stepper', () => {
const renderStepper = (props: StepperProps) => render(<Stepper {...props} />);
it('renders with correct step information', () => {
renderStepper({ currentStep: 2, totalSteps: 5 });
expect(screen.getByText('Langkah 2 dari 5')).toBeInTheDocument();
});
it('applies custom className correctly', () => {
const customClass = 'custom-stepper';
renderStepper({
currentStep: 1,
totalSteps: 3,
className: customClass,
});
const stepperElement = screen.getByText('Langkah 1 dari 3').parentElement;
expect(stepperElement).toHaveClass('text-center');
expect(stepperElement).toHaveClass('mb-4');
expect(stepperElement).toHaveClass(customClass);
});
it('handles step 0 correctly', () => {
renderStepper({ currentStep: 0, totalSteps: 3 });
expect(screen.getByText('Langkah 0 dari 3')).toBeInTheDocument();
});
it('handles equal currentStep and totalSteps', () => {
renderStepper({ currentStep: 3, totalSteps: 3 });
expect(screen.getByText('Langkah 3 dari 3')).toBeInTheDocument();
});
it('applies correct text styling classes', () => {
renderStepper({ currentStep: 1, totalSteps: 2 });
const textElement = screen.getByText('Langkah 1 dari 2');
expect(textElement).toHaveClass('text-neutral-400');
expect(textElement).toHaveClass('text-lg');
});
});
@@ -0,0 +1,61 @@
import { Meta, StoryObj } from '@storybook/react';
import { Stepper } from './Stepper';
const meta: Meta<typeof Stepper> = {
title: 'Components/Stepper',
component: Stepper,
argTypes: {
currentStep: {
control: { type: 'number', min: 0 },
description: 'Current step number',
},
totalSteps: {
control: { type: 'number', min: 1 },
description: 'Total number of steps',
},
className: {
control: 'text',
description: 'Custom CSS class',
},
},
parameters: {
docs: {
description: {
component: 'A simple stepper component showing progress through steps',
},
},
},
};
export default meta;
type Story = StoryObj<typeof Stepper>;
export const Default: Story = {
args: {
currentStep: 1,
totalSteps: 3,
},
};
export const MiddleStep: Story = {
args: {
currentStep: 2,
totalSteps: 4,
},
};
export const LastStep: Story = {
args: {
currentStep: 3,
totalSteps: 3,
},
};
export const WithCustomClass: Story = {
args: {
currentStep: 1,
totalSteps: 5,
className: 'custom-class',
},
};
+25
View File
@@ -0,0 +1,25 @@
import { cn } from '@imphnen-frontend-service/utils';
import React from 'react';
interface StepperProps {
currentStep: number;
totalSteps: number;
className?: string;
}
const Stepper: React.FC<StepperProps> = ({
currentStep,
totalSteps,
className,
}) => {
return (
<div className={cn('text-center mb-4', className)}>
<p className="text-neutral-400 text-lg">
Langkah {currentStep} dari {totalSteps}
</p>
</div>
);
};
export { Stepper };
export type { StepperProps };
@@ -0,0 +1,29 @@
import { ArrowLeftOutlined } from "@ant-design/icons";
// eslint-disable-next-line @nx/enforce-module-boundaries
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { FC, ReactElement } from "react";
function AuthBanner({text, href}: {text: string, href: string}){
return (
<div className='relative rounded-md'>
<img src="/image/95319c4f9953dfe6180200e529dfcea5.webp" alt="Banner" className='w-[420px] h-[632px] object-[80%] object-cover rounded-lg' />
<div className='absolute top-0 bg-gradient-to-b from-primary-500 to-transparent w-full rounded-t-lg h-[163px] py-5 px-5'>
<Button variant='secondary' className='gap-2' onClick={() => document.location.href = `${href}`}>
<ArrowLeftOutlined />
<p>{text}</p>
</Button>
</div>
<div className='absolute bottom-0 bg-gradient-to-t from-primary-500 to-transparent w-full rounded-b-lg h-[263px] flex flex-col items-center justify-center'>
<img src="/image/9261045e09137f3fcb925a78c55b6ddb.webp" alt="Logo" width={317} />
</div>
</div>
)
}
export const LoginBanner: FC = (): ReactElement => {
return AuthBanner({text: "Back To Homepage", href: "/"})
}
export const RegisterResetBanner: FC = (): ReactElement => {
return AuthBanner({text: "Back To Login", href: "/auth/login"})
}
@@ -0,0 +1 @@
export * from './auth-banner';
@@ -1,25 +1,25 @@
import {
AppstoreOutlined,
AuditOutlined,
InboxOutlined,
LogoutOutlined,
UserOutlined,
} from '@ant-design/icons';
import { Button } from '../../atoms';
import { FC, ReactElement } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import { useSession } from '@imphnen-frontend-service/utils';
export const BackofficeSidebar: FC = (): ReactElement => {
const { signOut } = useSession();
const location = useLocation();
const navigate = useNavigate();
const isActive = (path: string) => location.pathname.includes(path);
return (
<aside className="sticky top-0 left-0 w-[280px] bg-white min-h-screen py-[60px] px-[28px] shadow-xl flex flex-col justify-between">
<div className="flex flex-col gap-20 justify-between items-center">
{/* Logo */}
<img src="/logos/simple.svg" alt="IMPHNEN Logo" className="w-[150px]" />
{/* Navigation Menu */}
<nav className="flex flex-col gap-4 w-full">
<Link
to="/dashboard"
@@ -56,17 +56,25 @@ export const BackofficeSidebar: FC = (): ReactElement => {
<AuditOutlined className="text-[20px]" />
<span className="text-p3 font-medium">Validasi Transaksi</span>
</Link>
<Link
to="/prizes"
className={`flex items-center justify-items-start gap-3 px-[8px] py-[10px] ${
isActive('/prizes')
? 'bg-primary-500 text-white rounded-md'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<InboxOutlined className="text-[20px]" />
<span className="text-p3 font-medium">Data Pengiriman Hadiah</span>
</Link>
</nav>
</div>
{/* Log Out Button */}
<div className="w-full">
<hr className="mb-5 border-primary-200" />
<Button
onClick={() => {
navigate('/');
}}
onClick={signOut}
variant="text"
className="items-start justify-start gap-3 px-[8px] py-[10px] text-gray-700 hover:text-red-500 transition-colors w-full"
>
@@ -0,0 +1,21 @@
import {
InputField,
TInputFieldProps,
} from '@imphnen-frontend-service/ui/molecules';
import {
FieldValues,
useController,
UseControllerProps,
} from 'react-hook-form';
export type TControlledInputFieldProps<T extends FieldValues> =
UseControllerProps<T> & TInputFieldProps;
export const ControlledInputField = <T extends FieldValues>(
props: TControlledInputFieldProps<T>
) => {
const { field, fieldState } = useController<T>(props);
return (
<InputField error={fieldState.error?.message} {...{ ...props, ...field }} />
);
};
@@ -0,0 +1 @@
export * from './controlled-input-field';
@@ -5,6 +5,7 @@ import {
getPaginationRowModel,
flexRender,
ColumnDef,
Table,
} from '@tanstack/react-table';
import { Pagination } from '../../molecules';
@@ -13,6 +14,7 @@ import React from 'react';
interface DataTableProps<T> {
data: T[];
columns: ColumnDef<T>[];
table: Table<T>;
pageSize?: number;
}
@@ -47,7 +49,7 @@ export const DataTable = <T,>({
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="py-3 px-5 font-normal first:rounded-l-lg last:rounded-r-lg"
className="py-4 px-5 font-normal first:rounded-l-lg last:rounded-r-lg"
>
{header.isPlaceholder
? null
@@ -60,7 +62,7 @@ export const DataTable = <T,>({
</tr>
))}
</thead>
<tbody className="mt-3">
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="bg-primary-100 odd:bg-white">
{row.getVisibleCells().map((cell, index) => (
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Filter } from './filter';
const meta: Meta<typeof Filter> = {
title: 'Organisms/Filter',
component: Filter,
tags: ['autodocs'],
parameters: {
backgrounds: {
default: 'neutral',
values: [{ name: 'neutral', value: '#e7e7e7' }],
},
},
} satisfies Meta<typeof Filter>;
export default meta;
type Story = StoryObj<typeof Filter>;
export const Default: Story = {
args: {},
};
+102
View File
@@ -0,0 +1,102 @@
import { CloseOutlined } from '@ant-design/icons';
import { useState } from 'react';
interface RadioProps {
checked?: boolean;
disabled?: boolean;
id: string;
label?: string;
name: string;
value: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
const Radio = ({
checked,
disabled,
id,
label,
name,
value,
onChange,
}: RadioProps) => (
<div className="flex gap-2 items-center">
<div className="relative grid place-items-center mt-1">
<input
type="radio"
id={id}
name={name}
value={value}
checked={checked}
onChange={onChange}
disabled={disabled}
className="peer col-start-1 row-start-1 appearance-none shrink-0 size-[10px] bg-primary-100 rounded-full disabled:border-gray-400"
/>
<div className="pointer-events-none col-start-1 row-start-1 size-[6px] rounded-full peer-checked:bg-primary-500 peer-checked:peer-disabled:bg-gray-400" />
</div>
<label htmlFor={id} className="text-start text-neutral-400 text-label2">
{label || 'This is the radio label'}
</label>
</div>
);
interface FilterProps {
onClose?: () => void;
options: Array<{
id: string;
value: string;
label: string;
}>;
selectedValue?: string;
onFilterChange?: (value: string) => void;
title?: string;
}
export const Filter = ({
onClose,
options,
selectedValue,
onFilterChange,
title = 'Status',
}: FilterProps) => {
const [selectedStatus, setSelectedStatus] = useState(
selectedValue || options[0]?.value || ''
);
const handleStatusChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setSelectedStatus(newValue);
onFilterChange?.(newValue);
};
return (
<div className="inline-flex flex-col p-[20px] bg-white rounded-lg gap-4 w-[122px] shadow">
<div className="flex justify-between items-baseline">
<span className="font-semibold text-p3 text-primary-500">Filters</span>
<button
onClick={onClose}
className="cursor-pointer text-neutral-400 hover:text-neutral-600"
>
<CloseOutlined className="text-[12px]" />
</button>
</div>
<hr className="border-primary-200" />
<span className="font-semibold text-primary-500">{title}</span>
<div className="flex flex-col gap-[10px]">
{options.map((option) => (
<Radio
key={option.id}
id={option.id}
name="status"
value={option.value}
label={option.label}
checked={selectedStatus === option.value}
onChange={handleStatusChange}
/>
))}
</div>
</div>
);
};
export default Filter;
+1
View File
@@ -0,0 +1 @@
export * from './filter';
+5 -2
View File
@@ -1,4 +1,7 @@
export * from './navbar';
export * from './modals-gacha';
export * from './backoffice-sidebar'
export * from './datatable'
export * from './auth-banner';
export * from './backoffice-sidebar';
export * from './datatable';
export * from './filter';
export * from './controlled-field';
+2 -2
View File
@@ -163,7 +163,7 @@ describe('Navbar', () => {
}
});
it('has correct ARIA role for navigation', () => {
it('has correct ARIA role for nav', () => {
const { container }: RenderResult = render(
<BrowserRouter>
<Navbar />
@@ -171,6 +171,6 @@ describe('Navbar', () => {
);
const header: HTMLElement | null = container.querySelector('header');
expect(header).toHaveAttribute('role', 'navigation');
expect(header).toHaveAttribute('role', 'nav');
});
});
+34 -20
View File
@@ -1,16 +1,19 @@
import { MenuOutlined } from '@ant-design/icons';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { FC, ReactElement, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button } from '../../atoms/button';
import { useModalLogin, useSession } from '@imphnen-frontend-service/utils';
export const Navbar: FC = (): ReactElement => {
const [isDropdownOpen, setDropdownOpen] = useState(false);
const { session, signOut, isAuthenticated } = useSession();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { setShowModalLogin } = useModalLogin();
return (
<div className="bg-primary-50 w-full px-[32px] pt-[32px] md:px-[60px] md:pt-[60px] lg:px-[80px] sticky top-0 z-50">
<header
className="bg-white shadow-lg rounded-lg min-h-[47px] max-h-[47px] md:min-h-[60px] md:max-h-[60px] lg:min-h-[71px] lg:max-h-[71px] flex justify-between w-full max-w-[1280px] xl:mx-auto"
role="navigation"
role="nav"
>
<div className="flex w-full items-center justify-between p-4 md:px-[32px] md:py-[10px]">
<div className="flex items-center">
@@ -40,20 +43,27 @@ export const Navbar: FC = (): ReactElement => {
<Link to="#">Merch Gacha</Link>
</Button>
</li>
<li>
<Button
size="md"
className="lg:text-[19px] lg:max-h-[44px] text-neutral-50 hover:text-neutral-200 transition-colors"
>
<Link to="/login">Login</Link>
</Button>
</li>
{!isAuthenticated ? (
<li>
<Button onClick={() => setShowModalLogin(true)}>Login</Button>
</li>
) : (
<li className="flex gap-x-4">
<span className="text-lg">{session.user?.fullname}</span>
<div
onClick={signOut}
className="text-lg text-red-500 font-bold"
>
Logout
</div>
</li>
)}
</ul>
<button
className={`md:hidden duration-200 ${
isDropdownOpen ? 'transform rotate-90' : ''
}`}
onClick={() => setDropdownOpen(!isDropdownOpen)}
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
<MenuOutlined style={{ color: '#1a8ce6' }} />
</button>
@@ -77,14 +87,18 @@ export const Navbar: FC = (): ReactElement => {
Merch Gacha
</Link>
</li>
<li>
<Link
to="#"
className="block text-gray-600 transition-colors px-4 py-2 text-center font-semibold"
>
Login
</Link>
</li>
{!isAuthenticated ? (
<li>
<Button
onClick={() => setShowModalLogin(true)}
className="block w-full text-gray-100 transition-colors px-4 py-2 text-center font-semibold"
>
Login
</Button>
</li>
) : (
<li>{session.user?.fullname}</li>
)}
</ul>
</div>
)}
-7
View File
@@ -1,7 +0,0 @@
import axios, { AxiosRequestConfig } from 'axios';
const config: AxiosRequestConfig = {
baseURL: import.meta.env.VITE_API_URL,
};
export const api = axios.create(config);
-1
View File
@@ -1 +0,0 @@
export * from './api';
+3
View File
@@ -0,0 +1,3 @@
export * from './use-query-state';
export * from './use-session';
export * from './use-modal-login';

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