Compare commits

...
Author SHA1 Message Date
egagofur 638b64b715 fix: validation by query param 2025-04-04 16:42:27 +07:00
egagofur d61cb4a215 fix: split zod validation register modal 2025-04-04 16:38:21 +07:00
Maulana SodiqinandGitHub 272db82c8c Merge pull request #18 from IMPHNEN/feat/register-integeration
feat: integeration register modal
2025-04-04 06:03:13 +07:00
egagofur 9108ddb742 fix: unused field 2025-04-03 23:39:41 +07:00
egagofur 628e168342 fix: form type 2025-04-03 23:39:09 +07:00
egagofur 9aff9e2dc8 feat: integeration register modal 2025-04-03 23:32:44 +07:00
Maulana 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
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
68 changed files with 2841 additions and 1886 deletions
+1
View File
@@ -0,0 +1 @@
VITE_API_URL=https://api.example.com
+7 -1
View File
@@ -44,4 +44,10 @@ Thumbs.db
vite.config.*.timestamp* vite.config.*.timestamp*
vitest.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 ## How to run
npx nx serve dimentorin
```
To create a production bundle: ### Development
```sh Use the following commands to run in development mode:
npx nx build dimentorin
```
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 ### Build
npx nx show project dimentorin
```
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 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:
npx nx g @nx/react:app demo
```
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 ```sh
npx nx g @nx/react:lib mylib 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 ```sh
npx nx connect 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) 1. Fork the repository and clone it locally.
- [Task distribution across multiple machines](https://nx.dev/ci/features/distribute-task-execution?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) 2. Create a new branch for a new feature or fix:
- [Automated e2e test splitting](https://nx.dev/ci/features/split-e2e-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) ```sh
- [Task flakiness detection and rerunning](https://nx.dev/ci/features/flaky-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) 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 If you encounter any issues or problems, feel free to create a new Issue.
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)
+1 -1
View File
@@ -6,7 +6,7 @@
<base href="/" /> <base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <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" /> <link rel="stylesheet" href="/src/styles.css" />
</head> </head>
<body> <body>
@@ -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;
+126 -99
View File
@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { FC, ReactElement, useState } from 'react'; import { FC, Fragment, ReactElement, useState } from 'react';
import { import {
FilterOutlined, FilterOutlined,
SearchOutlined, SearchOutlined,
@@ -17,6 +17,8 @@ import {
useReactTable, useReactTable,
RowSelectionState, RowSelectionState,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import ModalEditAccount from './_components/modal-edit-account';
import { useQueryState } from '../../hook/use-query-state';
interface Account { interface Account {
id: number; id: number;
@@ -35,65 +37,20 @@ const mockData: Account[] = Array.from({ length: 90 }, (_, i) => ({
address: 'Jl. Pantai Cibaduyut Indah', 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 => { 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>({ const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 9, pageSize: 9,
@@ -102,6 +59,64 @@ export const Components: FC = (): ReactElement => {
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({}); const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const [showFilter, setShowFilter] = useState(false); 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({ const table = useReactTable({
data: mockData, data: mockData,
columns, columns,
@@ -119,49 +134,61 @@ export const Components: FC = (): ReactElement => {
}); });
return ( return (
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8"> <Fragment>
{/* Header */} <main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4"> {/* Header */}
<h1 className="text-p2 font-semibold">Data Akun</h1> <header className="bg-white py-4 px-8 rounded-lg shadow p-4">
</header> <h1 className="text-p2 font-semibold">Data Akun</h1>
</header>
{/* Account Table Section */} {/* Account Table Section */}
<section className="flex flex-col gap-6 p-8 bg-white rounded-md"> <section className="flex flex-col gap-6 p-8 bg-white rounded-md">
{/* Search and Filter */} {/* Search and Filter */}
<div className="flex justify-between items-center gap-8 mb-2"> <div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full"> <div className="relative w-full">
<Input <Input
placeholder="Cari berdasarkan nama lengkap, email" placeholder="Cari berdasarkan nama lengkap, email"
className="pl-12 w-full max-h-full" className="pl-12 w-full max-h-full"
/> />
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]"> <div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined /> <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>
</div> </div>
{/* Table */}
<DataTable data={mockData} columns={columns} table={table} />
</section>
</main>
<div className="relative"> {/* Modal Edit Account */}
<Button <ModalEditAccount
variant="primary" currentStep={currentStep}
size="md" isOpen={showModalEditAccount}
className="flex items-center gap-3" onClose={() => setShowModalEditAccount(false)}
disabled handleEditAccount={() => {
onClick={() => setShowFilter(!showFilter)} console.log('Account updated');
> }}
<FilterOutlined /> nextStep={nextStep}
Filters prevStep={prevStep}
</Button> resetStep={resetStep}
{showFilter && ( />
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg"> </Fragment>
<Filter onClose={() => setShowFilter(false)} />
</div>
)}
</div>
</div>
{/* Table */}
<DataTable data={mockData} columns={columns} table={table} />
</section>
</main>
); );
}; };
@@ -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;
+185 -120
View File
@@ -6,139 +6,204 @@ import {
UserSwitchOutlined, UserSwitchOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Button } from '@imphnen-frontend-service/ui/atoms'; 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 => { export const Components: FC = (): ReactElement => {
const [showModalAddItem, setShowModalAddItem] = useState(false);
const [showModalEditItem, setShowModalEditItem] = useState(false);
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
const {
step: currentStep,
nextStep,
prevStep,
resetStep,
} = useQueryState('step', {
defaultValue: 1,
maxValue: 2,
minValue: 1,
});
return ( return (
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8"> <Fragment>
{/* Dashboard Header */} <main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4"> {/* Dashboard Header */}
<h1 className="text-p2 font-semibold">Dashboard</h1> <header className="bg-white py-4 px-8 rounded-lg shadow p-4">
</header> <h1 className="text-p2 font-semibold">Dashboard</h1>
</header>
<div className="flex justify-between gap-[40px] p-8 bg-white rounded-md"> <div className="flex justify-between gap-[40px] p-8 bg-white rounded-md">
<div className="w-full flex flex-col gap-[40px]"> <div className="w-full flex flex-col gap-[40px]">
{/* Summary Section */} {/* Summary Section */}
<section> <section>
<h2 className="text-p2 font-medium text-primary-500 mb-8"> <h2 className="text-p2 font-medium text-primary-500 mb-8">
Summary Summary
</h2>
<div className="grid grid-cols-2 gap-4">
{/* Participants */}
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
<UsergroupAddOutlined className="text-[20px]" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">1000</h3>
<p className="text-label1 text-neutral-500">Participants</p>
</div>
</div>
{/* Roll and Reroll */}
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
<ReloadOutlined className="text-[20px]" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">1000</h3>
<p className="text-label1 text-neutral-500">
Roll and Reroll
</p>
</div>
</div>
{/* Redeem */}
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
<UserSwitchOutlined className="text-[20px]" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">1000</h3>
<p className="text-label1 text-neutral-500">Redeem</p>
</div>
</div>
{/* Inactive Users */}
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
<UsergroupDeleteOutlined className="text-[20px]" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">1000</h3>
<p className="text-label1 text-neutral-500">Inactive Users</p>
</div>
</div>
</div>
</section>
{/* Gacha Items Section */}
<section className="flex flex-col gap-8">
<div className="flex justify-between items-center">
<h2 className="text-p2 font-medium text-primary-500">
Gacha Items
</h2> </h2>
<Button variant="primary" size="sm" className="items-end gap-3"> <div className="grid grid-cols-2 gap-4">
<span>Tambah Item</span> {/* Participants */}
<PlusOutlined className="text-[16px]" /> <div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
</Button> <div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
</div> <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>
{/* Gacha Items List */} {/* Roll and Reroll */}
<div className="flex flex-col gap-4 max-h-140 overflow-auto"> <div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
{[1, 2, 3, 4, 5, 6].map((item) => ( <div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
<div <ReloadOutlined className="text-[20px]" />
key={item} </div>
className="bg-white max-h-[80px] overflow-clip rounded-lg shadow-sm flex justify-between border border-neutral-100" <div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">1000</h3>
<p className="text-label1 text-neutral-500">
Roll and Reroll
</p>
</div>
</div>
{/* Redeem */}
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
<UserSwitchOutlined className="text-[20px]" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">1000</h3>
<p className="text-label1 text-neutral-500">Redeem</p>
</div>
</div>
{/* Inactive Users */}
<div className="bg-white rounded-lg shadow-sm py-4 px-6 flex items-center border border-neutral-100">
<div className="mr-4 text-primary-500 bg-primary-100 p-[8px] rounded-md">
<UsergroupDeleteOutlined className="text-[20px]" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-p1 font-semibold">1000</h3>
<p className="text-label1 text-neutral-500">
Inactive Users
</p>
</div>
</div>
</div>
</section>
{/* Gacha Items Section */}
<section className="flex flex-col gap-8">
<div className="flex justify-between items-center">
<h2 className="text-p2 font-medium text-primary-500">
Gacha Items
</h2>
<Button
variant="primary"
size="sm"
className="items-end gap-3"
onClick={() => setShowModalAddItem(true)}
> >
<div className="flex flex-col py-4 px-6 gap-4"> <span>Tambah Item</span>
<div> <PlusOutlined className="text-[16px]" />
<h3 className="text-p3 text-primary-500 font-medium"> </Button>
Lanyard IMPHNEN </div>
</h3>
<div className="flex items-center gap-2 text-label2 text-gray-500 mt-1"> {/* Gacha Items List */}
<span>Prize {item}</span> <div className="flex flex-col gap-4 max-h-140 overflow-auto">
<span>Chance Rate: (0.1%)</span> {[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> </div>
<div className="flex justify-start gap-2">
<Button {/* Lebih baik gunakan gambar yang sudah di-clip dengan size height: 78px daripada hard-code object-position dan margin */}
variant="text" <img
size="sm" src="gacha-clip.webp"
className="text-[10px] text-neutral-500 p-0 font-normal hover:bg-transparent hover:text-primary-500" alt="Lanyard IMPHNEN"
> className="h-full"
Edit />
</Button>
<Button
variant="text"
size="sm"
className="text-[10px] text-red-500 p-0 font-normal hover:bg-transparent hover:text-red-700"
>
Delete
</Button>
</div>
</div> </div>
))}
</div>
</section>
</div>
{/* Lebih baik gunakan gambar yang sudah di-clip dengan size height: 78px daripada hard-code object-position dan margin */} {/* Right-side illustration */}
<img <img
src="gacha-clip.webp" src="gacha.webp"
alt="Lanyard IMPHNEN" alt=""
className="h-full" className="rounded-lg hidden xl:block xl:min-w-[436px] h-auto object-cover"
/> />
</div>
))}
</div>
</section>
</div> </div>
</main>
{/* Right-side illustration */} {/* Modal Add Item */}
<img <ModalAddItem
src="gacha.webp" currentStep={currentStep}
alt="" isOpen={showModalAddItem}
className="rounded-lg hidden xl:block xl:min-w-[436px] h-auto object-cover" onClose={() => setShowModalAddItem(false)}
/> handleAddItem={() => {
</div> console.log('Item added');
</main> }}
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 { FC, ReactElement } from 'react';
import { useNavigate } from 'react-router';
import { Button } from '@imphnen-frontend-service/ui/atoms'; 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 => { export const Components: FC = (): ReactElement => {
const navigate = useNavigate(); const { form, onSubmit } = useLogin();
return ( return (
<div className="flex justify-center items-center min-h-screen"> <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"> <h1 className="text-primary-500 text-p1 font-semibold">
Welcome to IMPHNEN Backoffice Welcome to IMPHNEN Backoffice
</h1> </h1>
<InputForm <form onSubmit={onSubmit} className="flex flex-col gap-8">
label="Email" <ControlledInputField
placeholder="Masukkan Email" control={form.control}
type="email" label="Email"
size="lg" placeholder="Masukkan Email"
className="w-full" type="email"
/> name="email"
<InputForm size="lg"
label="Password" className="w-full"
placeholder="Masukkan password" />
type="password" <ControlledInputField
size="lg" control={form.control}
className="w-full" label="Password"
/> placeholder="Masukkan Password"
<Button onClick={() => navigate('/dashboard')}>Login</Button> type="password"
name="password"
size="lg"
className="w-full"
/>
<Button type="submit" size="md" className="w-full">
Login
</Button>
</form>
</div> </div>
</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;
+169 -150
View File
@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { FC, ReactElement, useState } from 'react'; import { FC, Fragment, ReactElement, useState } from 'react';
import { import {
FilterOutlined, FilterOutlined,
SearchOutlined, SearchOutlined,
@@ -17,13 +17,15 @@ import {
useReactTable, useReactTable,
RowSelectionState, RowSelectionState,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import ModalProcessDelivery from './_components/modal-process-item';
type Status = 'valid' | 'invalid' | 'unchecked'; type OrderValid = 'valid' | 'invalid' | 'unchecked';
type Status = 'undelivered' | 'delivered';
interface Prize { interface Prize {
id: number; id: number;
name: string; name: string;
orderValid: Status; orderValid: OrderValid;
items: string; items: string;
address: string; address: string;
status: Status; status: Status;
@@ -46,119 +48,16 @@ const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({
? 'invalid' ? 'invalid'
: i % 5 === 0 : i % 5 === 0
? 'unchecked' ? 'unchecked'
: 'valid') as Status, : 'valid') as OrderValid,
items: items[i % items.length], items: items[i % items.length],
address: 'Jl. Pantai Cibaduyut Indah', address: 'Jl. Pantai Cibaduyut Indah',
status: (i % 3 === 0 status: (i % 3 === 0 ? 'undelivered' : 'delivered') as Status,
? 'unchecked'
: i % 5 === 0
? 'invalid'
: 'valid') as Status,
})); }));
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<Status, 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<Status, 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> = {
valid: 'bg-success-200 text-success-500',
invalid: 'bg-danger-200 text-danger-500',
unchecked: 'bg-warning-200 text-warning-900',
};
const statusText: Record<Status, 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();
// handleUpdate(row.id);
}}
className="flex items-center gap-2 w-full"
>
<AuditOutlined className="text-[16px]" /> Process
</Button>
),
},
];
export const Components: FC = (): ReactElement => { export const Components: FC = (): ReactElement => {
const [showModalProcessDelivery, setShowModalProcessDelivery] =
useState(false);
const [pagination, setPagination] = React.useState<PaginationState>({ const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 9, pageSize: 9,
@@ -167,6 +66,111 @@ export const Components: FC = (): ReactElement => {
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({}); const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const [showFilter, setShowFilter] = useState(false); 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({ const table = useReactTable({
data: mockData, data: mockData,
columns, columns,
@@ -184,48 +188,63 @@ export const Components: FC = (): ReactElement => {
}); });
return ( return (
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8"> <Fragment>
{/* Header */} <main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4"> {/* Header */}
<h1 className="text-p2 font-semibold">Data Pengiriman Hadiah</h1> <header className="bg-white py-4 px-8 rounded-lg shadow p-4">
</header> <h1 className="text-p2 font-semibold">Data Pengiriman Hadiah</h1>
</header>
{/* Account Table Section */} {/* Account Table Section */}
<section className="flex flex-col gap-6 p-8 bg-white rounded-md"> <section className="flex flex-col gap-6 p-8 bg-white rounded-md">
{/* Search and Filter */} {/* Search and Filter */}
<div className="flex justify-between items-center gap-8 mb-2"> <div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full"> <div className="relative w-full">
<Input <Input
placeholder="Cari berdasarkan nama lengkap, nomor order Shopee" placeholder="Cari berdasarkan nama lengkap, nomor order Shopee"
className="pl-12 w-full max-h-full" className="pl-12 w-full max-h-full"
/> />
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]"> <div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined /> <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>
</div> </div>
{/* Table */}
<DataTable data={mockData} columns={columns} table={table} />
</section>
</main>
<div className="relative"> {/* Modal Process Delivery */}
<Button <ModalProcessDelivery
variant="primary" isOpen={showModalProcessDelivery}
size="md" onClose={() => setShowModalProcessDelivery(false)}
className="flex items-center gap-3" handleProcessDelivery={() => {
onClick={() => setShowFilter(!showFilter)} console.log('Action ketika user menekan tombol Proses Pengiriman');
> }}
<FilterOutlined /> />
Filters </Fragment>
</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>
); );
}; };
@@ -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;
+141 -113
View File
@@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { FC, ReactElement, useState } from 'react'; import { FC, Fragment, ReactElement, useState } from 'react';
import { import {
FilterOutlined, FilterOutlined,
SearchOutlined, SearchOutlined,
@@ -16,6 +16,7 @@ import {
useReactTable, useReactTable,
RowSelectionState, RowSelectionState,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import ModalValidate from './_components/modal-validate';
type TransactionStatus = 'valid' | 'invalid' | 'unchecked'; type TransactionStatus = 'valid' | 'invalid' | 'unchecked';
@@ -38,81 +39,9 @@ const mockTransactions: Transaction[] = Array.from({ length: 20 }, (_, i) => ({
: 'valid') as TransactionStatus, : 'valid') as TransactionStatus,
})); }));
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();
// handleUpdate(row.id);
}}
className="flex items-center gap-2 w-full"
>
<AuditOutlined className="text-[16px]" /> Update
</Button>
),
},
];
export const Components: FC = (): ReactElement => { export const Components: FC = (): ReactElement => {
const [showModalValidate, setShowModalValidate] = useState(false);
const [pagination, setPagination] = React.useState<PaginationState>({ const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 9, pageSize: 9,
@@ -121,6 +50,86 @@ export const Components: FC = (): ReactElement => {
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({}); const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const [showFilter, setShowFilter] = useState(false); 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({ const table = useReactTable({
data: mockTransactions, data: mockTransactions,
columns, columns,
@@ -138,48 +147,67 @@ export const Components: FC = (): ReactElement => {
}); });
return ( return (
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8"> <Fragment>
{/* Header */} <main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4"> {/* Header */}
<h1 className="text-p2 font-semibold">Validasi Transaksi</h1> <header className="bg-white py-4 px-8 rounded-lg shadow p-4">
</header> <h1 className="text-p2 font-semibold">Validasi Transaksi</h1>
</header>
{/* Account Table Section */} {/* Account Table Section */}
<section className="flex flex-col gap-6 p-8 bg-white rounded-md"> <section className="flex flex-col gap-6 p-8 bg-white rounded-md">
{/* Search and Filter */} {/* Search and Filter */}
<div className="flex justify-between items-center gap-8 mb-2"> <div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full"> <div className="relative w-full">
<Input <Input
placeholder="Cari berdasarkan nama lengkap, nomor order Shopee" placeholder="Cari berdasarkan nama lengkap, nomor order Shopee"
className="pl-12 w-full max-h-full" className="pl-12 w-full max-h-full"
/> />
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]"> <div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined /> <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>
</div> </div>
<div className="relative"> {/* Table */}
<Button <DataTable data={mockTransactions} columns={columns} table={table} />
variant="primary" </section>
size="md" </main>
className="flex items-center gap-3" <ModalValidate
onClick={() => setShowFilter(!showFilter)} isOpen={showModalValidate}
> onClose={() => setShowModalValidate(false)}
<FilterOutlined /> handleValid={() => {
Filters console.log('Action ketika user klik Valid');
</Button> }}
{showFilter && ( handleInvalid={() => {
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg"> console.log('Action ketika user klik Tidak Valid');
<Filter onClose={() => setShowFilter(false)} /> }}
</div> />
)} </Fragment>
</div>
</div>
{/* Table */}
<DataTable data={mockTransactions} columns={columns} table={table} />
</section>
</main>
); );
}; };
+2 -2
View File
@@ -8,11 +8,11 @@ export default defineConfig(() => ({
root: __dirname, root: __dirname,
cacheDir: '../../node_modules/.vite/apps/backoffice', cacheDir: '../../node_modules/.vite/apps/backoffice',
server: { server: {
port: 4200, port: 3000,
host: 'localhost', host: 'localhost',
}, },
preview: { preview: {
port: 4300, port: 3001,
host: 'localhost', host: 'localhost',
}, },
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])], plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
+48 -20
View File
@@ -1,45 +1,73 @@
import { FC, ReactElement } from 'react'; import { FC, ReactElement } from 'react';
import { LoginBanner } from "@imphnen-frontend-service/ui/organisms" import { LoginBanner } from '@imphnen-frontend-service/ui/organisms';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { InputForm } from '@imphnen-frontend-service/ui/molecules'; import { InputField } from '@imphnen-frontend-service/ui/molecules';
export const Components: FC = (): ReactElement => { export const Components: FC = (): ReactElement => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const error = searchParams.get("error"); // Ambil nilai ?error= const error = searchParams.get('error'); // Ambil nilai ?error=
return ( return (
<div className='flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]'> <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'> <div className="bg-white min-w-[1120px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6">
<LoginBanner /> <LoginBanner />
<div className='border-2 border-primary-500/50 w-[596px] rounded-lg py-[70px] px-[96px] flex justify-center'> <div className="border-2 border-primary-500/50 w-[596px] rounded-lg py-[70px] px-[96px] flex justify-center">
<div className='w-[404px]'> <div className="w-[404px]">
<h2 className='text-4xl font-semibold text-primary-500 text-center mb-2'>Hallo Minna-san</h2> <h2 className="text-4xl font-semibold text-primary-500 text-center mb-2">
<h5 className='text-xl font-medium text-primary-500 text-center'>Welcome to Dimentorin by IMPHNEN</h5> Hallo Minna-san
<InputForm label='Email' error={error ? error : undefined} size='lg' className='w-full' placeholder='Masukkan email-mu, Senpai~! ✨ (Pastikan tidak typo, ya~ 😆)' /> </h2>
<InputForm label="password" error={error ? error : undefined} className='w-full' size='lg' type='password' placeholder='Masukkan password rahasiamu!' /> <h5 className="text-xl font-medium text-primary-500 text-center">
<div className='flex justify-end my-5'> Welcome to Dimentorin by IMPHNEN
<a href="/auth/forgot" className='text-primary-500 font-medium'>Lupa Password ?</a> </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> </div>
<Button className='w-full'>Enter Isekai</Button> <Button className="w-full">Enter Isekai</Button>
<div className='flex my-3 gap-3 justify-center'> <div className="flex my-3 gap-3 justify-center">
<h5>Belum Punya akun ?</h5> <h5>Belum Punya akun ?</h5>
<a href="/auth/register" className='text-primary-500 font-medium'>Daftar Disini</a> <a href="/auth/register" className="text-primary-500 font-medium">
Daftar Disini
</a>
</div> </div>
<div className="flex items-center w-full"> <div className="flex items-center w-full">
<div className="flex-grow border-t border-blue-400 opacity-50"></div> <div className="flex-grow border-t border-blue-400 opacity-50"></div>
<span className="px-3 text-blue-400">Or</span> <span className="px-3 text-blue-400">Or</span>
<div className="flex-grow border-t border-blue-400 opacity-50"></div> <div className="flex-grow border-t border-blue-400 opacity-50"></div>
</div> </div>
<Button className='w-full my-3 text-gray-500 gap-2' variant='secondary'> <Button
className="w-full my-3 text-gray-500 gap-2"
variant="secondary"
>
<p>Log In With Google</p> <p>Log In With Google</p>
<img src="/image/33978ce5bed2da9bf9d73acad802182a.webp" alt="Google Icon" width={24}/> <img
src="/image/33978ce5bed2da9bf9d73acad802182a.webp"
alt="Google Icon"
width={24}
/>
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
}; };
export default Components; export default Components;
@@ -1,11 +1,11 @@
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { import {
InputForm, InputField,
Modal, Modal,
Stepper, Stepper,
} from '@imphnen-frontend-service/ui/molecules'; } from '@imphnen-frontend-service/ui/molecules';
import { useQueryState } from '../../../hooks/use-query-state'; import { useQueryState } from '@imphnen-frontend-service/utils';
import { Fragment } from 'react/jsx-runtime';
interface IModalFormForgotPasswordProps { interface IModalFormForgotPasswordProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -62,7 +62,7 @@ interface IStepOneProps {
const StepOne = ({ nextStep, onClose }: IStepOneProps) => ( const StepOne = ({ nextStep, onClose }: IStepOneProps) => (
<> <>
<InputForm <InputField
label="Email" label="Email"
placeholder="Masukkan Email yang Terdaftar" placeholder="Masukkan Email yang Terdaftar"
type="email" type="email"
@@ -86,9 +86,8 @@ interface IStepTwoProps {
} }
const StepTwo = ({ nextStep, prevStep }: IStepTwoProps) => ( const StepTwo = ({ nextStep, prevStep }: IStepTwoProps) => (
<> <Fragment>
{/* TODO: Change component using OTP Input */} <InputField
<InputForm
label="Kode OTP" label="Kode OTP"
placeholder="Masukkan Kode OTP" placeholder="Masukkan Kode OTP"
type="text" type="text"
@@ -108,7 +107,7 @@ const StepTwo = ({ nextStep, prevStep }: IStepTwoProps) => (
Reset Password Reset Password
</Button> </Button>
</div> </div>
</> </Fragment>
); );
interface IStepThreeProps { interface IStepThreeProps {
@@ -118,14 +117,14 @@ interface IStepThreeProps {
const StepThree = ({ onClose, resetStep }: IStepThreeProps) => ( const StepThree = ({ onClose, resetStep }: IStepThreeProps) => (
<> <>
<InputForm <InputField
label="Password Baru" label="Password Baru"
placeholder="Masukkan Password Baru" placeholder="Masukkan Password Baru"
type="password" type="password"
size="lg" size="lg"
className="w-full" className="w-full"
/> />
<InputForm <InputField
label="Ulang Password" label="Ulang Password"
placeholder="Masukkan Ulang Password" placeholder="Masukkan Ulang Password"
type="password" type="password"
@@ -1,18 +1,23 @@
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { InputForm, Modal } from '@imphnen-frontend-service/ui/molecules'; import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { Link } from 'react-router-dom'; import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { useLogin } from '../../_hooks/use-login';
interface IModalFormLogin { interface IModalFormLogin {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onForgotPassword: () => void; onForgotPassword: () => void;
setIsOpenRegisterModal: (value: boolean) => void;
} }
const ModalFormLogin = ({ const ModalFormLogin = ({
isOpen, isOpen,
onClose, onClose,
onForgotPassword, onForgotPassword,
setIsOpenRegisterModal,
}: IModalFormLogin) => { }: IModalFormLogin) => {
const { form, onSubmit } = useLogin();
return ( return (
<Modal <Modal
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7" className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
@@ -25,38 +30,45 @@ const ModalFormLogin = ({
Login Login
</h1> </h1>
</Modal.Header> </Modal.Header>
<Modal.Content className="space-y-4"> <Modal.Content>
<InputForm <form className="space-y-4" onSubmit={onSubmit}>
label="Email" <ControlledInputField
placeholder="Masukkan Email" control={form.control}
type="email" label="Email"
size="lg" placeholder="Masukkan Email"
className="w-full" type="email"
/> name="email"
<InputForm size="lg"
label="Password" className="w-full"
placeholder="Masukkan Password" />
type="password" <ControlledInputField
size="lg" control={form.control}
className="w-full" label="Password"
/> placeholder="Masukkan Password"
<h1 className="text-end text-primary-500 text-xl font-medium"> type="password"
<Button variant="text" onClick={onForgotPassword}> name="password"
Lupa 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> </Button>
</h1> <div className="flex justify-center gap-2 pt-2.5 font-medium">
<Button size="md" className="w-full"> <p className="text-neutral-500">Belum punya akun?</p>
Login <Button
</Button> variant="text"
<div className="flex justify-center gap-2 pt-2.5 font-medium"> className="text-primary-500 hover:text-primary-600 m-0 p-0"
<p className="text-neutral-500">Belum punya akun?</p> onClick={() => setIsOpenRegisterModal(true)}
<Link >
className="text-primary-500 hover:text-primary-600" Daftar
to="/register" </Button>
> </div>
Daftar </form>
</Link>
</div>
</Modal.Content> </Modal.Content>
</Modal> </Modal>
); );
@@ -1,6 +1,11 @@
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { InputForm, Modal } from '@imphnen-frontend-service/ui/molecules'; import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { useQueryState } from '../../../hooks/use-query-state'; import { useQueryState } from '@imphnen-frontend-service/utils';
import { useRegister } from '../../_hooks/use-register';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { UseFormReturn } from 'react-hook-form';
import { TRegisterRequest } from '@imphnen-frontend-service/service';
import { useEffect } from 'react';
interface IModalFormRegisterProps { interface IModalFormRegisterProps {
isOpen: boolean; isOpen: boolean;
@@ -8,6 +13,8 @@ interface IModalFormRegisterProps {
} }
const ModalFormRegister = ({ isOpen, onClose }: IModalFormRegisterProps) => { const ModalFormRegister = ({ isOpen, onClose }: IModalFormRegisterProps) => {
const { form, onSubmit, isStepOneValid } = useRegister();
const { const {
step: currentStep, step: currentStep,
nextStep, nextStep,
@@ -19,6 +26,12 @@ const ModalFormRegister = ({ isOpen, onClose }: IModalFormRegisterProps) => {
minValue: 1, minValue: 1,
}); });
useEffect(() => {
if (!isStepOneValid && currentStep === 2) {
prevStep();
}
}, [isStepOneValid, currentStep, prevStep]);
return ( return (
<Modal <Modal
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7" className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
@@ -37,74 +50,118 @@ const ModalFormRegister = ({ isOpen, onClose }: IModalFormRegisterProps) => {
{currentStep === 1 ? 'Info Akun' : 'Informasi Pengiriman Hadiah'} {currentStep === 1 ? 'Info Akun' : 'Informasi Pengiriman Hadiah'}
</h2> </h2>
</Modal.Header> </Modal.Header>
<Modal.Content className="space-y-6"> <Modal.Content>
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} />} <form onSubmit={onSubmit} className="space-y-6">
{currentStep === 2 && ( {currentStep === 1 && (
<StepTwo nextStep={nextStep} prevStep={prevStep} /> <StepOne
)} form={form}
nextStep={nextStep}
isStepOneValid={isStepOneValid}
/>
)}
{currentStep === 2 && <StepTwo form={form} prevStep={prevStep} />}
</form>
</Modal.Content> </Modal.Content>
</Modal> </Modal>
); );
}; };
interface IStepOneProps { interface IStepOneProps {
form: UseFormReturn<TRegisterRequest, any, TRegisterRequest>;
nextStep: () => void; nextStep: () => void;
onClose: () => void; isStepOneValid: boolean;
} }
const StepOne = ({ nextStep, onClose }: IStepOneProps) => ( const StepOne = ({ form, nextStep, isStepOneValid }: IStepOneProps) => {
<> return (
<InputForm <>
label="Nama Lengkap" <ControlledInputField
placeholder="Masukkan Nama Lengkap" control={form.control}
type="text" name="fullname"
size="lg" label="Nama Lengkap"
className="w-full" placeholder="Masukkan Nama Lengkap"
/> type="text"
<InputForm size="lg"
label="Email" className="w-full"
placeholder="Masukkan Email Anda" />
type="email" <ControlledInputField
size="lg" control={form.control}
className="w-full" label="Email"
/> placeholder="Masukkan Email Anda"
<InputForm type="email"
label="Password" name="email"
placeholder="Masukkan Password" size="lg"
type="password" className="w-full"
size="lg" />
className="w-full" <ControlledInputField
/> control={form.control}
<InputForm name="password"
label="Ulangi Password" label="Password"
placeholder="Masukkan Ulang Password" placeholder="Masukkan Password"
type="password" type="password"
size="lg" size="lg"
className="w-full" className="w-full"
/> />
<Button size="md" className="w-full" onClick={nextStep}> <ControlledInputField
Selanjutnya control={form.control}
</Button> name="confirm_password"
</> label="Ulangi Password"
); placeholder="Masukkan Ulang Password"
type="password"
size="lg"
className="w-full"
/>
<Button
size="md"
className="w-full"
onClick={nextStep}
disabled={!isStepOneValid}
>
Selanjutnya
</Button>
</>
);
};
interface IStepTwoProps { interface IStepTwoProps {
nextStep: () => void; form: UseFormReturn<TRegisterRequest, any, TRegisterRequest>;
prevStep: () => void; prevStep: () => void;
} }
const StepTwo = ({ nextStep, prevStep }: IStepTwoProps) => ( const StepTwo = ({ form, prevStep }: IStepTwoProps) => (
<> <>
<InputForm <ControlledInputField
control={form.control}
name="phone_number"
label="Nomor Telepon" label="Nomor Telepon"
placeholder="Masukkan Nomor Telepon Aktif" placeholder="Masukkan Nomor Telepon Aktif"
type="text" type="text"
size="lg" size="lg"
className="w-full" className="w-full"
/> />
<InputForm <ControlledInputField
label="Alamat Pengiriman" control={form.control}
placeholder="Masukkan Alamat Pengiriman" 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" type="text"
size="lg" size="lg"
className="w-full" className="w-full"
@@ -113,7 +170,7 @@ const StepTwo = ({ nextStep, prevStep }: IStepTwoProps) => (
<Button size="md" variant="text" className="w-[40%]" onClick={prevStep}> <Button size="md" variant="text" className="w-[40%]" onClick={prevStep}>
Kembali Kembali
</Button> </Button>
<Button size="md" className="w-full" onClick={nextStep}> <Button size="md" className="w-full" type="submit">
Gacha Sekarang Gacha Sekarang
</Button> </Button>
</div> </div>
@@ -0,0 +1,29 @@
import { cn } from '@imphnen-frontend-service/utils';
import { FC, ReactElement } from 'react';
type TGachaItem = {
src: string;
label: string;
className?: string;
};
export const GachaItem: FC<TGachaItem> = ({
src,
label,
className,
}): ReactElement => {
return (
<div className="snap-center flex-auto justify-center items-center flex flex-col min-w-full overflow-hidden">
<div className="max-h-[180px] md:max-h-[270px] md:max-w-[500px] mb-2 md:mb-5 flex flex-1 justify-center items-center">
<img
src={src}
alt="Banner"
width={255}
height={255}
className={cn('h-[150px] md:h-[255px] object-contain', className)}
/>
</div>
<p className="font-semibold text-center md:text-p2">{label}</p>
</div>
);
};
+26
View File
@@ -0,0 +1,26 @@
import { useForm } from 'react-hook-form';
import {
authLoginSchema,
TLoginRequest,
usePostLogin,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
export const useLogin = () => {
const postLogin = usePostLogin();
const form = useForm<TLoginRequest>({
resolver: zodResolver(authLoginSchema),
mode: 'all',
});
const onSubmit = form.handleSubmit((data) => {
postLogin.mutate(data, {
onSuccess: () => console.log('Success Login'),
});
});
return {
form,
onSubmit,
};
};
+58
View File
@@ -0,0 +1,58 @@
import { useForm } from 'react-hook-form';
import {
authRegisterSchema,
stepOneRegisterSchema,
TRegisterRequest,
usePostRegister,
} from '@imphnen-frontend-service/service';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { useEffect, useState } from 'react';
export const useRegister = () => {
const postRegister = usePostRegister();
const form = useForm<TRegisterRequest>({
resolver: zodResolver(authRegisterSchema),
mode: 'all',
defaultValues: {
fullname: '',
email: '',
password: '',
confirm_password: '',
phone_number: '',
referral_code: '',
referred_by: '',
student_type: '',
},
});
const [stepValid, setStepValid] = useState(false);
useEffect(() => {
const subscription = form.watch(() => {
const { fullname, email, password, confirm_password } = form.getValues();
const valid = stepOneRegisterSchema.safeParse({
fullname,
email,
password,
confirm_password,
}).success;
setStepValid(valid);
});
return () => subscription.unsubscribe();
}, [form]);
const onSubmit = form.handleSubmit((data) => {
postRegister.mutate(data, {
onSuccess: (data) => toast.success(data.message),
onError: (error) => toast.error(error.message),
});
});
return {
form,
onSubmit,
isStepOneValid: stepValid,
};
};
+9 -7
View File
@@ -1,14 +1,16 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
import { Navbar } from '@imphnen-frontend-service/ui/organisms'; import { 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 => { export const AppLayout: FC = (): ReactElement => {
return ( return (
<main className="bg-primary-50 min-h-screen"> <ModalLoginProvider>
<Navbar /> <main className="bg-primary-50 min-h-screen">
<Outlet /> <Navbar />
</main> <Outlet />
</main>
</ModalLoginProvider>
); );
}; };
export default AppLayout; export default AppLayout;
+16 -77
View File
@@ -1,20 +1,15 @@
import { ArrowDownOutlined } from '@ant-design/icons'; import { ArrowDownOutlined } from '@ant-design/icons';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils'; import { FC, Fragment, ReactElement, useState } from 'react';
import { FC, Fragment, ReactElement, useEffect, useState } from 'react';
import ModalFormForgotPassword from './_components/form/modal-form-forgot-password'; import ModalFormForgotPassword from './_components/form/modal-form-forgot-password';
import ModalFormLogin from './_components/form/modal-form-login'; import ModalFormLogin from './_components/form/modal-form-login';
import ModalFormRegister from './_components/form/modal-form-register'; import ModalFormRegister from './_components/form/modal-form-register';
import { GachaItem } from './_components/item/gacha-item';
interface GachaItemProps { import { useModalLogin } from '@imphnen-frontend-service/utils';
src: string;
label: string;
className?: string;
}
export const Components: FC = (): ReactElement => { export const Components: FC = (): ReactElement => {
const { showModalLogin, setShowModalLogin } = useModalLogin();
const [showModalForgotPassword, setShowModalForgotPassword] = useState(false); const [showModalForgotPassword, setShowModalForgotPassword] = useState(false);
const [showModalLogin, setShowModalLogin] = useState(false);
const [showModalRegister, setShowModalRegister] = useState(false); const [showModalRegister, setShowModalRegister] = useState(false);
const scrollToRoulette = () => { const scrollToRoulette = () => {
@@ -24,49 +19,6 @@ 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 = () => { const handleForgotPasswordClick = () => {
setShowModalLogin(false); setShowModalLogin(false);
setShowModalForgotPassword(true); setShowModalForgotPassword(true);
@@ -74,7 +26,6 @@ export const Components: FC = (): ReactElement => {
return ( return (
<Fragment> <Fragment>
{/* Landing Page */}
<section <section
id="landing-page" 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" 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"
@@ -137,22 +88,28 @@ export const Components: FC = (): ReactElement => {
~ 175k ~ ~ 175k ~
</div> </div>
</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]"> <div className="relative h-[130px] md:h-[230px]">
<img <img
width={400}
height={400}
src="/merch/2.png" src="/merch/2.png"
alt="Merch 2" alt="Merch 2"
className="relative top-[5px] md:top-[8px] left-0 md:left-[5px] z-10 w-[165px] md:w-[277px]" className="relative top-[5px] md:top-[8px] left-0 md:left-[5px] z-10 w-[165px] md:w-[277px]"
/> />
<img <img
width={400}
height={400}
className="absolute top-0 -left-[5px] md:-left-[8px] z-0 min-w-[174px] md:min-w-[302px]" className="absolute top-0 -left-[5px] md:-left-[8px] z-0 min-w-[174px] md:min-w-[302px]"
src="/merch/Vector-2.svg" src="/merch/Vector-2.svg"
alt="" alt="Merch 3"
/> />
<img <img
width={400}
height={400}
className="absolute hidden lg:block -bottom-[100px] -left-[55px]" className="absolute hidden lg:block -bottom-[100px] -left-[55px]"
src="/landing-arrow-2.svg" src="/landing-arrow-2.svg"
alt="" alt="Merch 1"
/> />
</div> </div>
<div className="px-3 py-1 text-primary-500 font-semibold text-base md:text-p2 bg-white shadow-md rounded"> <div className="px-3 py-1 text-primary-500 font-semibold text-base md:text-p2 bg-white shadow-md rounded">
@@ -163,7 +120,7 @@ export const Components: FC = (): ReactElement => {
</div> </div>
</div> </div>
</section> </section>
{/* Roulette Page */}
<section <section
id="roulette" 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" 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"
@@ -191,10 +148,7 @@ export const Components: FC = (): ReactElement => {
</ol> </ol>
</div> </div>
<div <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">
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="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"> <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 Here Take Your Prize
</div> </div>
@@ -223,31 +177,17 @@ export const Components: FC = (): ReactElement => {
Spin Now Spin Now
</Button> </Button>
</div> </div>
{/* TODO: Change using real button trigger */}
<div className="flex gap-4 col-span-4 md:col-span-8 lg:col-span-6 justify-center items-center">
<Button onClick={() => setShowModalLogin(true)}>
Open Modal Login
</Button>
<Button onClick={() => setShowModalForgotPassword(true)}>
Open Modal Forgot Password
</Button>
<Button onClick={() => setShowModalRegister(true)}>
Open Modal Register
</Button>
</div>
</section> </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> <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>
{/* Login Modal */}
<ModalFormLogin <ModalFormLogin
isOpen={showModalLogin} isOpen={showModalLogin}
onClose={() => setShowModalLogin(false)} onClose={() => setShowModalLogin(false)}
onForgotPassword={handleForgotPasswordClick} onForgotPassword={handleForgotPasswordClick}
setIsOpenRegisterModal={setShowModalRegister}
key="login" key="login"
/> />
{/* Register Modal */}
<ModalFormRegister <ModalFormRegister
isOpen={showModalRegister} isOpen={showModalRegister}
onClose={() => { onClose={() => {
@@ -256,7 +196,6 @@ export const Components: FC = (): ReactElement => {
key="register" key="register"
/> />
{/* Forgot Password Modal */}
<ModalFormForgotPassword <ModalFormForgotPassword
isOpen={showModalForgotPassword} isOpen={showModalForgotPassword}
onClose={() => { onClose={() => {
+2
View File
@@ -8,6 +8,7 @@ import {
convertPagesToRoute, convertPagesToRoute,
QueryProvider, QueryProvider,
} from '@imphnen-frontend-service/utils'; } from '@imphnen-frontend-service/utils';
import { Toaster } from 'sonner';
import './index.css'; import './index.css';
const files = import.meta.glob('./app/**/*(page|layout).tsx'); 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( createRoot(rootElement).render(
<StrictMode> <StrictMode>
<QueryProvider> <QueryProvider>
<Toaster />
<RouterProvider router={router} /> <RouterProvider router={router} />
</QueryProvider> </QueryProvider>
</StrictMode> </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 { import {
TLoginRequest, TLoginRequest,
TLoginResponse, TLoginResponse,
+8
View File
@@ -1,3 +1,11 @@
import axios, { AxiosRequestConfig } from 'axios';
export * from './auth'; export * from './auth';
export * from './gacha'; export * from './gacha';
export * from './users'; 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, TRegisterRequest,
TVerifyEmailRequest, TVerifyEmailRequest,
} from '../../types/auth'; } from '../../types/auth';
import { SessionToken, SessionUser } from '@imphnen-frontend-service/utils';
import { TResponseError, TResponseMessage } from '../../types/common'; import { TResponseError, TResponseMessage } from '../../types/common';
export const usePostLogin = (): UseMutationResult< export const usePostLogin = (): UseMutationResult<
@@ -17,6 +19,11 @@ export const usePostLogin = (): UseMutationResult<
return useMutation({ return useMutation({
mutationKey: ['post-login'], mutationKey: ['post-login'],
mutationFn: async (payload) => await postLogin(payload), 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 './api';
export * from './hooks'; export * from './hooks';
export * from './types'; export * from './types';
export * from './schemas';
+78
View File
@@ -0,0 +1,78 @@
import { z } from 'zod';
export const authLoginSchema = z.object({
email: z
.string({
required_error: 'Email tidak boleh kosong',
invalid_type_error: 'Email harus berupa string',
})
.min(1, 'Email tidak boleh kosong')
.email('Email harus valid'),
password: z
.string({
required_error: 'Password tidak boleh kosong',
invalid_type_error: 'Password harus berupa string',
})
.min(1, 'Password tidak boleh kosong'),
});
export const stepOneRegisterSchema = z
.object({
email: z
.string({
required_error: 'Email tidak boleh kosong',
invalid_type_error: 'Email harus berupa string',
})
.min(1, 'Email tidak boleh kosong')
.email('Email harus valid'),
fullname: z
.string({
required_error: 'Nama tidak boleh kosong',
invalid_type_error: 'Nama harus berupa string',
})
.min(1, 'Nama tidak boleh kosong')
.max(50, 'Nama tidak boleh lebih dari 50 karakter'),
password: z
.string({
required_error: 'Password tidak boleh kosong',
invalid_type_error: 'Password harus berupa string',
})
.min(1, 'Password tidak boleh kosong')
.min(8, 'Password harus lebih dari 8 karakter')
.max(50, 'Password tidak boleh lebih dari 50 karakter'),
confirm_password: z
.string({
required_error: 'Konfirmasi password tidak boleh kosong',
})
.min(1, 'Konfirmasi password tidak boleh kosong')
.max(50, 'Konfirmasi password tidak boleh lebih dari 50 karakter'),
})
.refine((data) => data.password === data.confirm_password, {
message: 'Password dan Konfirmasi Password harus sama',
path: ['confirm_password'],
});
const stepTwoRegisterSchema = z.object({
phone_number: z
.string({
required_error: 'Nomor telepon tidak boleh kosong',
invalid_type_error: 'Nomor telepon harus berupa string',
})
.min(1, 'Nomor telepon tidak boleh kosong')
.max(15, 'Nomor telepon tidak boleh lebih dari 15 karakter'),
referral_code: z
.string()
.min(1, 'Kode referral tidak boleh kosong')
.max(4, 'Kode referral tidak boleh lebih dari 4 karakter')
.optional(),
referred_by: z
.string()
.min(1, 'Kode referral tidak boleh kosong')
.max(50, 'Kode referral tidak boleh lebih dari 50 karakter')
.optional(),
student_type: z.string(),
});
export const authRegisterSchema = stepOneRegisterSchema.and(
stepTwoRegisterSchema
);
+1
View File
@@ -0,0 +1 @@
export * from './auth';
+9 -6
View File
@@ -1,3 +1,5 @@
import { TUserItem } from '../users';
export type TLoginRequest = { export type TLoginRequest = {
email: string; email: string;
password: string; password: string;
@@ -9,18 +11,19 @@ export type TLoginResponse = {
access_token: string; access_token: string;
refresh_token: string; refresh_token: string;
}; };
user: { user: TUserItem;
fullname: string;
email: string;
is_active: boolean;
};
}; };
}; };
export type TRegisterRequest = { export type TRegisterRequest = {
fullname: string;
email: string; email: string;
fullname: string;
password: string; password: string;
phone_number: string;
referral_code?: string;
referred_by?: string;
student_type: string;
confirm_password: string;
}; };
export type TVerifyEmailRequest = { export type TVerifyEmailRequest = {
+2
View File
@@ -1,3 +1,5 @@
export * from './auth'; export * from './auth';
export * from './gacha'; export * from './gacha';
export * from './users'; 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 -2
View File
@@ -9,7 +9,7 @@ import { EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons'; // Import
import { cn } from '@imphnen-frontend-service/utils'; import { cn } from '@imphnen-frontend-service/utils';
import { Button } from '../button'; import { Button } from '../button';
type TInputType = 'text' | 'email' | 'password'; type TInputType = 'text' | 'email' | 'password' | 'file';
type TInputSize = 'sm' | 'md' | 'lg'; type TInputSize = 'sm' | 'md' | 'lg';
type TInputProps = Omit< type TInputProps = Omit<
@@ -40,7 +40,8 @@ export const Input: FC<TInputProps> = ({
}): ReactElement => { }): ReactElement => {
const [showPassword, setShowPassword] = useState(false); // State for password visibility const [showPassword, setShowPassword] = useState(false); // State for password visibility
const togglePasswordVisibility = () => { const togglePasswordVisibility = (e: React.FormEvent) => {
e.preventDefault();
if (!disabled) setShowPassword((prev) => !prev); if (!disabled) setShowPassword((prev) => !prev);
}; };
@@ -63,6 +64,7 @@ export const Input: FC<TInputProps> = ({
{type === 'password' && ( {type === 'password' && (
<div className="absolute end-0 px-[12px] h-full flex items-center"> <div className="absolute end-0 px-[12px] h-full flex items-center">
<Button <Button
type="button"
variant="text" variant="text"
size={size} size={size}
onClick={togglePasswordVisibility} onClick={togglePasswordVisibility}
+1 -1
View File
@@ -1,6 +1,6 @@
export * from './forgot-step'; export * from './forgot-step';
export * from './otp-form'; export * from './otp-form';
export * from './input-form'; export * from './input-field';
export * from './pagination'; export * from './pagination';
export * from './modal/modal'; export * from './modal/modal';
export * from './stepper'; export * from './stepper';
@@ -0,0 +1 @@
export * from './input-field';
@@ -1,9 +1,9 @@
import { render, screen } from '@testing-library/react'; 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', () => { 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'); const input = screen.getByLabelText('Test Label');
expect(input).toBeDisabled(); expect(input).toBeDisabled();
@@ -11,7 +11,7 @@ describe('InputForm Component', () => {
}); });
it('renders correctly without disabled prop', () => { 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'); const input = screen.getByLabelText('Test Label');
expect(input).not.toBeDisabled(); expect(input).not.toBeDisabled();
@@ -1,9 +1,9 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { InputForm } from './input-form'; import { InputField } from './input-field';
const meta = { const meta = {
title: 'Molecules/Input Form', title: 'Molecules/Input Form',
component: InputForm, component: InputField,
parameters: { parameters: {
layout: 'centered', layout: 'centered',
docs: { docs: {
@@ -27,7 +27,7 @@ Cek dan inspect element pada story With HtmlFor untuk melihat hasilnya.
}, },
}, },
tags: ['autodocs'], tags: ['autodocs'],
} satisfies Meta<typeof InputForm>; } satisfies Meta<typeof InputField>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
@@ -7,10 +7,9 @@ import {
import { Input } from '../../atoms'; import { Input } from '../../atoms';
import { cn } from '@imphnen-frontend-service/utils'; import { cn } from '@imphnen-frontend-service/utils';
type TInputType = 'text' | 'email' | 'password'; export type TInputType = 'text' | 'email' | 'password' | 'file';
type TInputSize = 'sm' | 'md' | 'lg'; export type TInputSize = 'sm' | 'md' | 'lg';
export type TInputFieldProps = Omit<
type TInputFormProps = Omit<
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
'size' | 'type' 'size' | 'type'
> & { > & {
@@ -19,7 +18,6 @@ type TInputFormProps = Omit<
size?: TInputSize; size?: TInputSize;
error?: string; error?: string;
disabled?: boolean; disabled?: boolean;
helperText?: string; helperText?: string;
htmlFor?: 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, label,
placeholder, placeholder,
type = 'text', type = 'text',
@@ -77,10 +75,15 @@ export const InputForm: FC<TInputFormProps> = ({
{...rest} {...rest}
/> />
{error ? ( {error ? (
<p className="text-danger-500 text-xs mt-1">{error}</p> <p className="text-danger-500 text-label1 text-left">{error}</p>
) : ( ) : (
helperText && ( helperText && (
<p className={cn('text-cs mt-1', sizeClasses[size].helperText)}> <p
className={cn(
'text-label2 text-left',
sizeClasses[size].helperText
)}
>
{helperText} {helperText}
</p> </p>
) )
@@ -88,5 +91,3 @@ export const InputForm: FC<TInputFormProps> = ({
</div> </div>
); );
}; };
export default InputForm;
@@ -1 +0,0 @@
export * from './input-form';
@@ -7,20 +7,19 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Button } from '../../atoms'; import { Button } from '../../atoms';
import { FC, ReactElement } from 'react'; 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 => { export const BackofficeSidebar: FC = (): ReactElement => {
const { signOut } = useSession();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const isActive = (path: string) => location.pathname.includes(path); const isActive = (path: string) => location.pathname.includes(path);
return ( 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"> <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"> <div className="flex flex-col gap-20 justify-between items-center">
{/* Logo */}
<img src="/logos/simple.svg" alt="IMPHNEN Logo" className="w-[150px]" /> <img src="/logos/simple.svg" alt="IMPHNEN Logo" className="w-[150px]" />
{/* Navigation Menu */}
<nav className="flex flex-col gap-4 w-full"> <nav className="flex flex-col gap-4 w-full">
<Link <Link
to="/dashboard" to="/dashboard"
@@ -72,14 +71,10 @@ export const BackofficeSidebar: FC = (): ReactElement => {
</nav> </nav>
</div> </div>
{/* Log Out Button */}
<div className="w-full"> <div className="w-full">
<hr className="mb-5 border-primary-200" /> <hr className="mb-5 border-primary-200" />
<Button <Button
onClick={() => { onClick={signOut}
navigate('/');
}}
variant="text" variant="text"
className="items-start justify-start gap-3 px-[8px] py-[10px] text-gray-700 hover:text-red-500 transition-colors w-full" 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';
+33 -20
View File
@@ -42,13 +42,31 @@ const Radio = ({
interface FilterProps { interface FilterProps {
onClose?: () => void; onClose?: () => void;
options: Array<{
id: string;
value: string;
label: string;
}>;
selectedValue?: string;
onFilterChange?: (value: string) => void;
title?: string;
} }
export const Filter = ({ onClose }: FilterProps) => { export const Filter = ({
const [selectedStatus, setSelectedStatus] = useState('delivered'); onClose,
options,
selectedValue,
onFilterChange,
title = 'Status',
}: FilterProps) => {
const [selectedStatus, setSelectedStatus] = useState(
selectedValue || options[0]?.value || ''
);
const handleStatusChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleStatusChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSelectedStatus(e.target.value); const newValue = e.target.value;
setSelectedStatus(newValue);
onFilterChange?.(newValue);
}; };
return ( return (
@@ -63,24 +81,19 @@ export const Filter = ({ onClose }: FilterProps) => {
</button> </button>
</div> </div>
<hr className="border-primary-200" /> <hr className="border-primary-200" />
<span className="font-semibold text-primary-500">Status</span> <span className="font-semibold text-primary-500">{title}</span>
<div className="flex flex-col gap-[10px]"> <div className="flex flex-col gap-[10px]">
<Radio {options.map((option) => (
id="option1" <Radio
name="status" key={option.id}
value="undelivered" id={option.id}
label="Undelivered" name="status"
checked={selectedStatus === 'undelivered'} value={option.value}
onChange={handleStatusChange} label={option.label}
/> checked={selectedStatus === option.value}
<Radio onChange={handleStatusChange}
id="option2" />
name="status" ))}
value="delivered"
label="Delivered"
checked={selectedStatus === 'delivered'}
onChange={handleStatusChange}
/>
</div> </div>
</div> </div>
); );
+6 -5
View File
@@ -1,6 +1,7 @@
export * from './navbar'; export * from './navbar';
export * from "./modals-gacha"; export * from './modals-gacha';
export * from "./auth-banner"; export * from './auth-banner';
export * from './backoffice-sidebar' export * from './backoffice-sidebar';
export * from './datatable' export * from './datatable';
export * from './filter' export * from './filter';
export * from './controlled-field';
@@ -1 +0,0 @@
export * from './modal-backoffice';
@@ -1,25 +0,0 @@
import { ReactElement, ReactNode } from 'react';
interface ModalBackofficeProps {
children: ReactNode;
className?: string;
}
export const ModalBackoffice = ({
children,
className,
...rest
}: ModalBackofficeProps): ReactElement => {
return (
<div
className={`w-[400px] bg-primary-50 rounded-lg p-[40px] gap-[32px] ${
className || ''
}`}
{...rest}
>
{children}
</div>
);
};
export default ModalBackoffice;
@@ -1,373 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import { ModalBackoffice } from './modal-backoffice';
import { InputForm } from '../../molecules';
import { Button } from '../../atoms';
const meta: Meta<typeof ModalBackoffice> = {
title: 'Organisms/Modal Backoffice',
component: ModalBackoffice,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof ModalBackoffice>;
export default meta;
type Story = StoryObj<typeof ModalBackoffice>;
export const Default: Story = {
args: {
// isOpen: true,
// onClose: () => console.log('Modal closed'),
// title: 'Sample Modal',
children: (
<>
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Modal Title
</h2>
<p className="text-p3 text-neutral-400">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quas repellat
rerum doloremque dolorem fugit architecto eos blanditiis ratione
facere? Esse dolores inventore deleniti. Dicta voluptatem dolore vel
quia amet dolorem?
</p>
<div className="flex flex-col gap-4"></div>
</>
),
},
};
export const TambahItemGacha: Story = {
args: {
children: (
<div className="flex flex-col gap-8">
<div>
<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>
</div>
<div className="flex flex-col gap-4">
<InputForm
label="Nama Hadiah"
type="text"
placeholder="Masukkan Nama Hadiah"
size="lg"
className="w-full"
/>
<InputForm
label="Chance Rate"
type="text"
placeholder="Masukkan Chance Rate"
size="lg"
className="w-full"
/>
<InputForm
label="Foto Barang"
type="text"
placeholder=".jpg, .jpeg, atau .png"
size="lg"
className="w-full"
/>
</div>
<Button variant="primary" size="lg" className="w-full">
Tambahkan Item
</Button>
</div>
),
},
};
export const TambahItem: Story = {
args: {
children: (
<div className="flex flex-col gap-8 text-center">
<div>
<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>
</div>
<div className="flex gap-4">
<Button variant="bordered" size="lg" className="w-full">
Batal
</Button>
<Button variant="primary" size="lg" className="w-full">
Tambahkan
</Button>
</div>
</div>
),
},
};
export const EditItemGacha: Story = {
args: {
children: (
<div className="flex flex-col gap-8">
<div>
<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>
</div>
<div className="flex flex-col gap-4">
<InputForm
label="Nama Hadiah"
type="text"
placeholder="Masukkan Nama Hadiah"
size="lg"
className="w-full"
/>
<InputForm
label="Chance Rate"
type="text"
placeholder="Masukkan Chance Rate"
size="lg"
className="w-full"
/>
<InputForm
label="Foto Barang"
type="text"
placeholder=".jpg, .jpeg, atau .png"
size="lg"
className="w-full"
/>
</div>
<Button variant="primary" size="lg" className="w-full">
Perbarui Item
</Button>
</div>
),
},
};
export const UpdateItem: Story = {
args: {
children: (
<div className="flex flex-col gap-8 text-center">
<div>
<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>
</div>
<div className="flex gap-4">
<Button variant="bordered" size="lg" className="w-full">
Batal
</Button>
<Button variant="primary" size="lg" className="w-full">
Update
</Button>
</div>
</div>
),
},
};
export const DeleteItem: Story = {
args: {
children: (
<div className="flex flex-col gap-8 text-center">
<img
src="/chibi-delete.webp"
alt="Delete item?"
width={148}
className="self-center"
/>
<div>
<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>
<div className="flex gap-4">
<Button variant="secondary" size="lg" className="w-full">
Batal Hapus
</Button>
<Button variant="danger" size="lg" className="w-full">
Hapus Item
</Button>
</div>
</div>
),
},
};
export const EditDataAkun: Story = {
args: {
children: (
<div className="flex flex-col gap-8">
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Edit Data Akun
</h2>
<div className="flex flex-col gap-4">
<InputForm
label="Nama Lengkap"
type="text"
placeholder="Masukkan Nama Lengkap"
value="Ahmad Wiyana"
size="lg"
className="w-full"
/>
<InputForm
label="Email"
type="text"
placeholder="Masukkan Email"
value="fullname23@gmail.com"
size="lg"
className="w-full"
/>
<InputForm
label="Nomor Telepon"
type="text"
placeholder="Masukkan Nomor Telepon"
value="081904423804"
size="lg"
className="w-full"
/>
<InputForm
label="Alamat"
type="text"
placeholder="Masukkan Alamat"
value="Jl. Pantai Cibaduyut Indah"
size="lg"
className="w-full"
/>
</div>
<Button variant="primary" size="lg" className="w-full">
Perbarui Data
</Button>
</div>
),
},
};
export const UpdateDataAkun: Story = {
args: {
children: (
<div className="flex flex-col gap-8 text-center">
<div>
<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>
</div>
<div className="flex gap-4">
<Button variant="bordered" size="lg" className="w-full">
Batal
</Button>
<Button variant="primary" size="lg" className="w-full">
Update
</Button>
</div>
</div>
),
},
};
export const ValidasiTransaksi: Story = {
args: {
children: (
<div className="flex flex-col gap-8">
<h2 className="text-p1 font-semibold text-primary-500 ">
Validasi Transaksi
</h2>
<InputForm
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"
>
Tidak Valid
</Button>
<Button variant="primary" size="lg" className="w-full">
Valid
</Button>
</div>
</div>
),
},
};
export const ProsesDelivery: Story = {
args: {
children: (
<div className="flex flex-col gap-8">
<div>
<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>
</div>
<div className="flex flex-col gap-4">
<InputForm
label="Nama Lengkap"
type="text"
placeholder="Masukkan Nama Lengkap"
value="Ahmad Wiyana"
size="lg"
className="w-full"
/>
<InputForm
label="Item yang didapatkan"
type="text"
placeholder="Masukkan Nama Item"
value="Lanyard + ID Card"
size="lg"
className="w-full"
/>
<InputForm
label="Alamat Pengiriman"
type="text"
placeholder="Masukkan Alamat Pengiriman"
value="Jl. Pantai Cibaduyut Indah"
size="lg"
className="w-full"
/>
<InputForm
label="Status"
type="text"
placeholder="Isi Status Pengiriman"
value="Lanyard + ID Card"
size="lg"
className="w-full"
/>
</div>
<Button variant="primary" size="lg" className="w-full">
Tambahkan Item
</Button>
</div>
),
},
};
+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( const { container }: RenderResult = render(
<BrowserRouter> <BrowserRouter>
<Navbar /> <Navbar />
@@ -171,6 +171,6 @@ describe('Navbar', () => {
); );
const header: HTMLElement | null = container.querySelector('header'); 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 { MenuOutlined } from '@ant-design/icons';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { FC, ReactElement, useState } from 'react'; import { FC, ReactElement, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Button } from '../../atoms/button';
import { useModalLogin, useSession } from '@imphnen-frontend-service/utils';
export const Navbar: FC = (): ReactElement => { export const Navbar: FC = (): ReactElement => {
const [isDropdownOpen, setDropdownOpen] = useState(false); const { session, signOut, isAuthenticated } = useSession();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { setShowModalLogin } = useModalLogin();
return ( 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"> <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 <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" 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 w-full items-center justify-between p-4 md:px-[32px] md:py-[10px]">
<div className="flex items-center"> <div className="flex items-center">
@@ -40,20 +43,27 @@ export const Navbar: FC = (): ReactElement => {
<Link to="#">Merch Gacha</Link> <Link to="#">Merch Gacha</Link>
</Button> </Button>
</li> </li>
<li> {!isAuthenticated ? (
<Button <li>
size="md" <Button onClick={() => setShowModalLogin(true)}>Login</Button>
className="lg:text-[19px] lg:max-h-[44px] text-neutral-50 hover:text-neutral-200 transition-colors" </li>
> ) : (
<Link to="/login">Login</Link> <li className="flex gap-x-4">
</Button> <span className="text-lg">{session.user?.fullname}</span>
</li> <div
onClick={signOut}
className="text-lg text-red-500 font-bold"
>
Logout
</div>
</li>
)}
</ul> </ul>
<button <button
className={`md:hidden duration-200 ${ className={`md:hidden duration-200 ${
isDropdownOpen ? 'transform rotate-90' : '' isDropdownOpen ? 'transform rotate-90' : ''
}`} }`}
onClick={() => setDropdownOpen(!isDropdownOpen)} onClick={() => setIsDropdownOpen(!isDropdownOpen)}
> >
<MenuOutlined style={{ color: '#1a8ce6' }} /> <MenuOutlined style={{ color: '#1a8ce6' }} />
</button> </button>
@@ -77,14 +87,18 @@ export const Navbar: FC = (): ReactElement => {
Merch Gacha Merch Gacha
</Link> </Link>
</li> </li>
<li> {!isAuthenticated ? (
<Link <li>
to="#" <Button
className="block text-gray-600 transition-colors px-4 py-2 text-center font-semibold" onClick={() => setShowModalLogin(true)}
> className="block w-full text-gray-100 transition-colors px-4 py-2 text-center font-semibold"
Login >
</Link> Login
</li> </Button>
</li>
) : (
<li>{session.user?.fullname}</li>
)}
</ul> </ul>
</div> </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';
+28
View File
@@ -0,0 +1,28 @@
import { createContext, ReactNode, useContext, useState } from 'react';
interface ModalLoginContextType {
showModalLogin: boolean;
setShowModalLogin: (value: boolean) => void;
}
const ModalLoginContext = createContext<ModalLoginContextType | undefined>(
undefined
);
export const ModalLoginProvider = ({ children }: { children: ReactNode }) => {
const [showModalLogin, setShowModalLogin] = useState(false);
return (
<ModalLoginContext.Provider value={{ showModalLogin, setShowModalLogin }}>
{children}
</ModalLoginContext.Provider>
);
};
export const useModalLogin = () => {
const context = useContext(ModalLoginContext);
if (!context) {
throw new Error('useModalLogin must be used within an ModalLoginProvider');
}
return context;
};
+71
View File
@@ -0,0 +1,71 @@
import { useCallback, useEffect, useState } from 'react';
interface UseQueryStateOptions {
defaultValue: number;
maxValue?: number;
minValue?: number;
}
export const useQueryState = (key: string, options: UseQueryStateOptions) => {
const { defaultValue, maxValue = Infinity, minValue = 1 } = options;
const getQueryParam = (param: string): string | null => {
if (typeof window === 'undefined') return null;
const searchParams = new URLSearchParams(window.location.search);
return searchParams.get(param);
};
const setQueryParam = (param: string, value: string) => {
const searchParams = new URLSearchParams(window.location.search);
searchParams.set(param, value);
const newUrl = `${window.location.pathname}?${searchParams.toString()}`;
window.history.replaceState(null, '', newUrl);
};
const initialValue = () => {
const queryValue = getQueryParam(key);
const parsedValue = queryValue ? parseInt(queryValue, 10) : defaultValue;
return Math.max(minValue, Math.min(maxValue, parsedValue));
};
const [value, setValue] = useState<number>(initialValue);
useEffect(() => {
const handlePopState = () => {
const queryValue = getQueryParam(key);
const newValue = queryValue ? parseInt(queryValue, 10) : defaultValue;
setValue(Math.max(minValue, Math.min(maxValue, newValue)));
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [key, defaultValue, minValue, maxValue]);
const updateValue = useCallback(
(newValue: number) => {
const constrainedValue = Math.max(minValue, Math.min(maxValue, newValue));
setValue(constrainedValue);
setQueryParam(key, constrainedValue.toString());
},
[key, minValue, maxValue]
);
const nextStep = useCallback(() => {
updateValue(value + 1);
}, [value, updateValue]);
const prevStep = useCallback(() => {
updateValue(value - 1);
}, [value, updateValue]);
const resetStep = useCallback(() => {
updateValue(defaultValue);
}, [defaultValue, updateValue]);
return {
step: value,
nextStep,
prevStep,
resetStep,
};
};
+22
View File
@@ -0,0 +1,22 @@
import { SessionToken, SessionUser } from '../local-storage';
export const useSession = () => {
const session = {
user: SessionUser.get(),
token: SessionToken.get(),
};
const isAuthenticated = !!session.token?.access_token;
const signOut = () => {
SessionUser.remove();
SessionToken.remove();
window.location.reload();
};
return {
isAuthenticated,
session,
signOut,
};
};
+2 -1
View File
@@ -1,4 +1,5 @@
export * from './react-query'; export * from './react-query';
export * from './react-router'; export * from './react-router';
export * from './tailwind-merge'; export * from './tailwind-merge';
export * from './axios'; export * from './hooks';
export * from './local-storage';
+60
View File
@@ -0,0 +1,60 @@
export type TPermissionItem = {
id: string;
name: string;
created_at: string;
updated_at: string;
};
type TRoleItem = {
id: string;
name: string;
created_at: string;
updated_at: string;
permissions: TPermissionItem[];
};
type TUserItem = {
id: string;
avatar: string;
birthdate: string;
email: string;
fullname: string;
gender: string;
identity_number: string;
is_active: boolean;
is_profile_completed: boolean;
phone_number: string;
referral_code: string;
referred_by: string;
religion: string;
student_type: string;
role: TRoleItem;
};
export const SessionUser = {
set: (val: TUserItem) => localStorage.setItem('users', JSON.stringify(val)),
get: (): TUserItem | undefined => {
const users = localStorage.getItem('users');
return users ? JSON.parse(users) : undefined;
},
remove: () => localStorage.removeItem('users'),
};
export const SessionToken = {
set: (val: { access_token: string; refresh_token: string }) => {
localStorage.setItem('access_token', val.access_token);
localStorage.setItem('refresh_token', val.refresh_token);
},
get: ():
| { access_token?: string | null; refresh_token?: string | null }
| undefined => {
return {
access_token: localStorage.getItem('access_token'),
refresh_token: localStorage.getItem('refresh_token'),
};
},
remove: () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
},
};
+602 -616
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -8,7 +8,7 @@
"gacha:prod": "serve ./dist/apps/gacha", "gacha:prod": "serve ./dist/apps/gacha",
"backoffice:dev": "nx serve backoffice", "backoffice:dev": "nx serve backoffice",
"backoffice:build": "nx build backoffice", "backoffice:build": "nx build backoffice",
"backoffice:prod": "serve ./dist/apps/gacha", "backoffice:prod": "serve ./dist/apps/backoffice",
"dimentorin:dev": "nx serve dimentorin", "dimentorin:dev": "nx serve dimentorin",
"dimentorin:build": "nx build dimentorin", "dimentorin:build": "nx build dimentorin",
"dimentorin:prod": "serve ./dist/apps/dimentorin", "dimentorin:prod": "serve ./dist/apps/dimentorin",
@@ -19,14 +19,20 @@
"private": true, "private": true,
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.6.1", "@ant-design/icons": "^5.6.1",
"@hookform/resolvers": "^5.0.1",
"@tanstack/react-query": "^5.67.3", "@tanstack/react-query": "^5.67.3",
"@tanstack/react-store": "^0.7.0",
"@tanstack/react-table": "^8.21.2", "@tanstack/react-table": "^8.21.2",
"axios": "^1.8.3", "axios": "^1.8.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"js-cookie": "^3.0.5",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.55.0",
"react-router-dom": "^7.3.0", "react-router-dom": "^7.3.0",
"tailwind-merge": "^3.0.2" "sonner": "^2.0.3",
"tailwind-merge": "^3.0.2",
"zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.14.5", "@babel/core": "^7.14.5",
@@ -48,7 +54,7 @@
"@storybook/test-runner": "^0.19.0", "@storybook/test-runner": "^0.19.0",
"@storybook/testing-library": "^0.2.2", "@storybook/testing-library": "^0.2.2",
"@swc-node/register": "~1.9.1", "@swc-node/register": "~1.9.1",
"@swc/cli": "~0.3.12", "@swc/cli": "^0.6.0",
"@swc/core": "~1.5.7", "@swc/core": "~1.5.7",
"@swc/helpers": "~0.5.11", "@swc/helpers": "~0.5.11",
"@tailwindcss/postcss": "^4.0.13", "@tailwindcss/postcss": "^4.0.13",
@@ -56,6 +62,7 @@
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0", "@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/js-cookie": "^3.0.6",
"@types/node": "18.16.9", "@types/node": "18.16.9",
"@types/react": "19.0.0", "@types/react": "19.0.0",
"@types/react-dom": "19.0.0", "@types/react-dom": "19.0.0",