Compare commits

..
Author SHA1 Message Date
arraysid 5aa66540ba feat: add feature request navigation 2025-05-23 01:20:29 +07:00
arraysid 01da8339f4 feat: implement signup and verification basic functionality with forms and actions 2025-05-23 01:15:12 +07:00
arraysid e13e5b4de2 feat: implement events and testimonials base pages 2025-05-23 01:14:49 +07:00
arraysid 08b4c39c03 feat: update form label and message styles & add select component 2025-05-23 01:14:10 +07:00
arraysid 610a1b7556 feat: create fetchPostSignin function for handling signin API requests 2025-05-20 10:02:18 +07:00
arraysid f5f715175c feat: restructure signin functionality with new hooks and improved validation 2025-05-20 10:00:52 +07:00
arraysid 1ddb2f72c6 feat: refactor signin action and form to use validation schema 2025-05-20 04:10:29 +07:00
arraysid b04f687199 feat: add signin page with functionality 2025-05-19 19:01:55 +07:00
arraysid 50cc24e46f feat: implement cookie management for access and refresh tokens 2025-05-19 15:02:02 +07:00
arraysid 4c7bb18a4f feat: replace ThemeProvider with Providers component 2025-05-19 14:10:11 +07:00
arraysid 0bc7d084d5 feat: change all public page to public dir 2025-05-19 13:39:03 +07:00
arraysid 795bcb6ab1 feat: add open api client and types for API integration 2025-05-19 13:33:09 +07:00
arraysid 2de5fd56d8 feat: add signin and signup page 2025-05-19 13:32:40 +07:00
arraysid d078999eec feat: add basic events section 2025-05-19 12:45:52 +07:00
arraysid a8dd867e1a feat: update button text in testimonials component for clarity 2025-05-17 15:37:40 +07:00
arraysid ff65424ff9 feat: add CallToAction component to encourage community engagement 2025-05-17 15:31:00 +07:00
arraysid 2fc625e454 feat: add testimonials component to showcase user feedback 2025-05-17 15:18:48 +07:00
arraysid d1bc9e10d7 feat: enhance communities component with improved layout and updated content 2025-05-17 11:41:39 +07:00
arraysid 017b5309a5 feat: add communites basic component to enhance community engagement section 2025-05-17 11:14:51 +07:00
arraysid e5844327a5 feat: remove unused images 2025-05-17 10:41:22 +07:00
arraysid 7874800d5e feat: add herostat component 2025-05-17 10:35:40 +07:00
arraysid 32d482ba4c chore: move unused components to deprecated dir 2025-05-17 09:17:33 +07:00
arraysid 502e45b4ff feat: reforming hero section 2025-05-17 08:46:07 +07:00
arraysid 7411fd2a13 chore: update import and tsconfig 2025-05-17 05:42:47 +07:00
arraysid 9df5bf8bbf fix: refactor homepage into dedicated home directory 2025-05-17 05:19:18 +07:00
arraysid 9548272340 feat: implement poppins font 2025-05-17 05:12:27 +07:00
arraysid d1abc3c7d4 feat: make header components atomic & remove unused menu 2025-05-17 03:52:41 +07:00
268 changed files with 11265 additions and 5953 deletions
+14
View File
@@ -0,0 +1,14 @@
# API Endpoint
VITE_API_URL=
# Payload CMS Secret Key (minimum 32 characters)
CMS_SECRET=your-very-long-secret-key
# PostgreSQL Connection String
CMS_POSTGRES_URL=postgres://user:password@host:port/database
# S3 Storage Configuration
CMS_STORAGE_ENDPOINT=https://your-s3-endpoint.com
CMS_STORAGE_ACCESS_KEY_ID=your-access-key-id
CMS_STORAGE_SECRET_ACCESS_KEY=your-secret-access-key
CMS_STORAGE_REGION=ap-southeast-1
-33
View File
@@ -1,33 +0,0 @@
name: Deploy Landing
on:
push:
paths:
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
set -e
if [ ! -d ~/imphnen-frontend-service ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
else
cd ~/imphnen-frontend-service && git pull
fi
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" > ~/imphnen-frontend-service/apps/landing/.env
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
docker image prune -af
+6
View File
@@ -62,3 +62,9 @@ storybook-static
# Next.js # Next.js
.next .next
out out
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
+5 -26
View File
@@ -6,10 +6,9 @@
This repository is a **monorepo** for all frontend services of IMPHNEN. The monorepo includes three main applications: This repository is a **monorepo** for all frontend services of IMPHNEN. The monorepo includes three main applications:
1. **Gacha** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Gacha Website</a>. 1. **Gacha** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">gacha website</a>.
2. **Backoffice** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Internal Management Website</a>. 2. **Backoffice** - Application for internal management.
3. **Dimentorin** - Application for <a href="https://dimentorin.imphnen.dev/" target="_blank">Mentoring Service</a>. 3. **Dimentorin** - Application for mentoring services.
4. **Landing Page** - Application for <a href="https://imphnen.dev/" target="_blank">Landing Page</a>.
## How to install ## How to install
@@ -25,15 +24,6 @@ This repository is a **monorepo** for all frontend services of IMPHNEN. The mono
## How to run ## How to run
### Setup Environment Variables
Before running the applications, you need to set up the environment variables. You can do this by copying the `.env.example` file to `.env` and modifying the values according to your needs.
```sh
cd apps/{appname}
cp .env.example .env
```
### Development ### Development
Use the following commands to run in development mode: Use the following commands to run in development mode:
@@ -50,10 +40,6 @@ Use the following commands to run in development mode:
```sh ```sh
npm run dimentorin:dev npm run dimentorin:dev
``` ```
- **Landing Page**:
```sh
npm run landing:dev
```
### Build ### Build
@@ -71,10 +57,6 @@ Use the following commands to build the applications:
```sh ```sh
npm run dimentorin:build npm run dimentorin:build
``` ```
- **Landing Page**:
```sh
npm run landing:build
```
### Production ### Production
@@ -92,22 +74,19 @@ Use the following commands to run the applications in production mode:
```sh ```sh
npm run dimentorin:prod npm run dimentorin:prod
``` ```
- **Landing Page**:
```sh
npm run landing:prod
```
### Storybook ### Storybook
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: 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:
- **Run Storybook** - **Run Storybook**
This command starts Storybook in development mode, allowing you to view and test UI components interactively. This command starts Storybook in development mode, allowing you to view and test UI components interactively.
```sh ```sh
npm run ui:storybook npm run ui:storybook
``` ```
- **Run Unit Test** - **Run Unit Test**
-1
View File
@@ -1 +0,0 @@
VITE_API_URL=
@@ -2,19 +2,24 @@ import { useForm } from 'react-hook-form';
import { import {
authLoginSchema, authLoginSchema,
TLoginRequest, TLoginRequest,
usePostLogin,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useSession } from '@imphnen-frontend-service/utils'; import { toast } from 'sonner';
export const useLogin = () => { export const useLogin = () => {
const postLogin = usePostLogin();
const form = useForm<TLoginRequest>({ const form = useForm<TLoginRequest>({
resolver: zodResolver(authLoginSchema), resolver: zodResolver(authLoginSchema),
mode: 'all', mode: 'all',
}); });
const { signIn } = useSession(); const onSubmit = form.handleSubmit((data) => {
postLogin.mutate(data, {
const onSubmit = form.handleSubmit((data) => signIn(data)); onSuccess: () => toast.success("Login sukses"),
onError: (error) => toast.error(error.message),
});
});
return { return {
form, form,
@@ -18,7 +18,7 @@ import {
RowSelectionState, RowSelectionState,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import ModalEditAccount from './_components/modal-edit-account'; import ModalEditAccount from './_components/modal-edit-account';
import { useQueryState } from '@imphnen-frontend-service/utils'; import { useQueryState } from '../../hook/use-query-state';
interface Account { interface Account {
id: number; id: number;
@@ -166,7 +166,7 @@ export const Components: FC = (): ReactElement => {
</Button> </Button>
{showFilter && ( {showFilter && (
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg"> <div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
<Filter onClose={() => setShowFilter(false)} options={[]} /> <Filter onClose={() => setShowFilter(false)} />
</div> </div>
)} )}
</div> </div>
@@ -0,0 +1,18 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
export const AppLayout: FC = (): ReactElement => {
return (
<div className="bg-primary-50 min-h-screen flex justify-center">
<div className="bg-primary-50 min-h-screen w-full flex">
<BackofficeSidebar />
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
<Outlet />
</div>
</div>
</div>
);
};
export default AppLayout;
@@ -10,7 +10,7 @@ import { FC, Fragment, ReactElement, useState } from 'react';
import ModalAddItem from './_components/modal-add-item'; import ModalAddItem from './_components/modal-add-item';
import ModalEditItem from './_components/modal-edit-item'; import ModalEditItem from './_components/modal-edit-item';
import ModalDeleteItem from './_components/modal-delete-item'; import ModalDeleteItem from './_components/modal-delete-item';
import { useQueryState } from '@imphnen-frontend-service/utils'; import { useQueryState } from '../../hook/use-query-state';
export const Components: FC = (): ReactElement => { export const Components: FC = (): ReactElement => {
const [showModalAddItem, setShowModalAddItem] = useState(false); const [showModalAddItem, setShowModalAddItem] = useState(false);
@@ -0,0 +1,18 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
export const AppLayout: FC = (): ReactElement => {
return (
<div className="bg-primary-50 min-h-screen flex justify-center">
<div className="bg-primary-50 min-h-screen w-full flex">
<BackofficeSidebar />
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
<Outlet />
</div>
</div>
</div>
);
};
export default AppLayout;
@@ -32,16 +32,7 @@ export const Components: FC = (): ReactElement => {
size="lg" size="lg"
className="w-full" className="w-full"
/> />
<Button <Button type="submit" size="md" className="w-full">
disabled={
form.formState.isSubmitting ||
form.formState.isValidating ||
!form.formState.isValid
}
type="submit"
size="md"
className="w-full"
>
Login Login
</Button> </Button>
</form> </form>
@@ -0,0 +1,18 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
export const AppLayout: FC = (): ReactElement => {
return (
<div className="bg-primary-50 min-h-screen flex justify-center">
<div className="bg-primary-50 min-h-screen w-full flex">
<BackofficeSidebar />
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
<Outlet />
</div>
</div>
</div>
);
};
export default AppLayout;
+18
View File
@@ -0,0 +1,18 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
export const AppLayout: FC = (): ReactElement => {
return (
<div className="bg-primary-50 min-h-screen flex justify-center">
<div className="bg-primary-50 min-h-screen w-full flex">
<BackofficeSidebar />
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
<Outlet />
</div>
</div>
</div>
);
};
export default AppLayout;
+18
View File
@@ -0,0 +1,18 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
export const AppLayout: FC = (): ReactElement => {
return (
<div className="bg-primary-50 min-h-screen flex justify-center">
<div className="bg-primary-50 min-h-screen w-full flex">
<BackofficeSidebar />
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
<Outlet />
</div>
</div>
</div>
);
};
export default AppLayout;
@@ -0,0 +1,18 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
export const AppLayout: FC = (): ReactElement => {
return (
<div className="bg-primary-50 min-h-screen flex justify-center">
<div className="bg-primary-50 min-h-screen w-full flex">
<BackofficeSidebar />
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
<Outlet />
</div>
</div>
</div>
);
};
export default AppLayout;
+3 -100
View File
@@ -1,108 +1,11 @@
import { import { LoaderFunctionArgs } from 'react-router-dom';
PERMISSIONS,
SessionToken,
SessionUser,
} from '@imphnen-frontend-service/utils';
import { LoaderFunctionArgs, redirect } from 'react-router';
const mappingPublicRoutes = [
'/auth/login',
'/auth/forgot',
'/auth/new-password',
];
const mappingRoutePermissions = [
{
path: '/dashboard',
permissions: [],
},
{
path: '/users',
permissions: [PERMISSIONS.USERS.READ_LIST],
},
{
path: '/users/create',
permissions: [PERMISSIONS.USERS.CREATE],
},
{
path: '/users/update',
permissions: [PERMISSIONS.USERS.UPDATE],
},
{
path: '/users/detail',
permissions: [PERMISSIONS.USERS.READ_DETAIL],
},
{
path: '/roles',
permissions: [PERMISSIONS.ROLES.READ_LIST],
},
{
path: '/roles/create',
permissions: [PERMISSIONS.ROLES.CREATE],
},
{
path: '/roles/update',
permissions: [PERMISSIONS.ROLES.UPDATE],
},
{
path: '/roles/detail',
permissions: [PERMISSIONS.ROLES.READ_DETAIL],
},
{
path: '/permissions',
permissions: [PERMISSIONS.PERMISSIONS.READ_LIST],
},
{
path: '/permissions/create',
permissions: [PERMISSIONS.PERMISSIONS.CREATE],
},
{
path: '/permissions/update',
permissions: [PERMISSIONS.PERMISSIONS.UPDATE],
},
{
path: '/permissions/detail',
permissions: [PERMISSIONS.PERMISSIONS.READ_DETAIL],
},
];
//TODO : Fix this later
// const redirectToFirstAccessibleRoute = (userPermissions: string[]) => {
// const fallback = mappingRoutePermissions.find((route) =>
// route.permissions.some((perm) => userPermissions.includes(perm))
// );
// return redirect(fallback?.path ?? '/auth/login');
// };
export const middleware = async ({ request }: LoaderFunctionArgs) => { export const middleware = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url); const url = new URL(request.url);
const pathname = url.pathname; const pathname = url.pathname;
const session = SessionUser.get();
const session_token = SessionToken.get();
const token = session_token?.token?.access_token;
const userPermissions =
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
if (mappingPublicRoutes.includes(pathname)) { if (pathname) return null;
if (token) return redirect('/dashboard');
return null;
}
if (!session) return redirect('/auth/login');
const matchedRoute = mappingRoutePermissions.find(
(route) => route.path === pathname
);
if (matchedRoute) {
const hasPermission =
!matchedRoute.permissions ||
matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
if (!hasPermission) {
return '/dashboard';
}
}
return null; return null;
}; };
-1
View File
@@ -1 +0,0 @@
VITE_API_URL=
Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 351 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 511 KiB

@@ -1,73 +0,0 @@
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { motion } from 'framer-motion'
export function HeroSection() {
return (
<section className="relative w-full py-20 md:py-28 lg:py-32">
{/* Background gradients and shapes */}
<div className="absolute inset-0 -z-10 overflow-x-clip hidden md:block">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
className="absolute top-10 -left-64 size-[448px] bg-primary-200 rounded-full justify-center items-center hidden lg:flex"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.3, delay: 0.1 }}
viewport={{ once: true }}
className="size-[296px] bg-primary-50 rounded-full"
/>
</motion.div>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
className="absolute top-3/5 -right-56 size-96 bg-primary-500 rounded-full flex justify-center items-center lg:top-1/2 lg:-right-64 lg:size-[600px]"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.3, delay: 0.1 }}
viewport={{ once: true }}
className="size-64 bg-primary-50 rounded-full lg:size-[400px]"
/>
</motion.div>
<div className="absolute -top-28 -left-20 size-[248px] bg-radial from-primary-300 rounded-full blur-3xl" />
<div className="absolute top-0 -right-72 size-[480px] bg-radial from-primary-400 rounded-full blur-[80px]" />
</div>
<div className="flex flex-col items-center gap-y-10">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
className="flex flex-col items-center gap-y-6"
>
<h1 className="text-2xl font-semibold text-primary-500 md:text-4xl md:font-bold lg:text-5xl">DIMENTORIN</h1>
<p className="max-w-52 text-center font-semibold text-xl leading-tight text-primary-500 md:max-w-72 lg:text-4xl lg:max-w-lg">
Learn IT smarter with AI & expert mentors!
</p>
<p className="max-w-3xs text-xs text-neutral-500 text-center font-medium md:max-w-lg md:text-base lg:text-lg lg:max-w-[646px]">
Apakah kamu siap untuk grind skill TI-mu ke level Ultimate? Dengan AI sebagai navigator dan mentor profesional sebagai sensei-mu, perjalanan belajarmu akan lebih cepat, efektif, dan penuh EXP!
</p>
</motion.div>
<motion.div initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: 0.2 }} viewport={{ once: true }}>
<Button
type="button"
size="sm"
className="text-[10px] h-auto px-2.5 py-2 md:text-sm"
>
Belajar Dengan AI Sekarang
</Button>
</motion.div>
</div>
</section>
)
}
@@ -1,53 +0,0 @@
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { FC } from "react";
import { motion } from 'framer-motion'
import { For } from "../../../_components/logic/for";
const OFFERS: { title: string; description: string; image: string }[] = [
{ title: "AI-Powered Learning", description: "Level Up Instantly!", image: "/image/what-we-offer/ai-powered-learning.webp" },
{ title: "1-on-1 Mentoring", description: "Sensei dari Dunia Nyata!", image: "/image/what-we-offer/1-on-1-mentoring.webp" },
{ title: "Community & Resources", description: "Connect dengan Fellow Devs!", image: "/image/what-we-offer/community.webp" },
]
export const WhatWeOfferSection: FC = () => {
return (
<section className="w-full mx-auto px-8 pb-4 md:px-[60px] lg:px-20 lg:pt-4">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="mb-6 flex justify-center lg:mb-10"
>
<Button
type="button"
size="sm"
variant="bordered"
className="h-auto px-3 py-2 font-semibold bg-primary-100 md:text-base md:px-5 md:py-2.5 lg:text-2xl lg:px-6 lg:py-3"
>
Apa yang Kami Tawarkan
</Button>
</motion.div>
<div className="grid gap-6 max-w-7xl mx-auto md:grid-cols-3">
<For data={OFFERS}>
{(offer) => (
<motion.div
className="relative aspect-[3/2] overflow-hidden rounded-2xl md:aspect-[3/4] lg:aspect-[5/6]"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<img src={offer.image} alt={offer.title} className="size-full object-cover" />
<div
className="absolute bottom-0 inset-x-0 p-4 h-1/2 text-primary-50 bg-gradient-to-t from-primary-500 via-primary-500/80 flex flex-col items-center justify-end gap-y-1 md:items-start lg:px-6 lg:pb-8"
>
<p className="font-semibold lg:text-[26px] lg:font-bold">{offer.title}</p>
<p className="text-xs lg:text-[22px] lg:font-medium">{offer.description}</p>
</div>
</motion.div>
)}
</For>
</div>
</section>
)
}
@@ -1,14 +0,0 @@
import { FC, ReactElement } from 'react';
import { HeroSection } from './_components/hero-section';
import { WhatWeOfferSection } from './_components/what-we-offer-section';
export const Components: FC = (): ReactElement => {
return (
<>
<HeroSection />
<WhatWeOfferSection />
</>
);
};
export default Components;
@@ -1,77 +0,0 @@
import { CloseOutlined, MenuOutlined } from "@ant-design/icons";
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { cn } from "@imphnen-frontend-service/utils";
import { FC, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { For } from "../../_components/logic/for";
import { Show } from "../../_components/logic/show";
const MENUS: { label: string; href: string }[] = [
{ label: "Home", href: "/" },
{ label: 'Mentoring', href: '/mentoring' },
{ label: 'Resources', href: '/resources' },
{ label: 'Articles', href: '/articles' },
]
export const Header: FC = () => {
const location = useLocation();
const [expandMenu, setExpandMenu] = useState(false);
return (
<div className="w-full px-8 pt-8 top-0 z-50 md:px-[60px] md:pt-[60px] lg:px-20 sticky">
<header
className="bg-white shadow-lg rounded-lg h-12 flex justify-between w-full max-w-7xl xl:mx-auto md:h-[60px] lg:h-[71px]"
aria-roledescription="nav"
>
<div className="flex w-full items-center justify-between p-4 md:px-8 md:py-2.5">
<div className="flex items-center">
<img
src="/logos/simple.svg"
alt="IMPHNEN Logo"
className="h-8 md:h-10 lg:h-[52px] w-auto"
/>
</div>
<nav
className={cn(
"flex flex-col items-center py-2 fixed top-24 bg-white shadow-lg rounded-lg transition-all duration-300",
"md:top-32 lg:static lg:py-0 lg:flex-row lg:bg-transparent lg:shadow-none lg:gap-x-4",
!expandMenu ? "-right-96" : "right-8 md:right-[60px]"
)}
>
<For data={MENUS}>
{(menu) => (
<Button
key={menu.label}
type="button"
variant="text"
className={cn(
"px-10 py-1.5 text-neutral-300 hover:text-neutral-400 lg:px-2.5 lg:py-2",
location.pathname === menu.href && "text-primary-500 hover:text-primary-500"
)}
>
<Link to={menu.href}>{menu.label}</Link>
</Button>
)}
</For>
<Button type="button" className="px-12 py-1 md:hidden">Login</Button>
</nav>
<div>
<Button type="button" className="px-5 py-2 hidden lg:block">Login</Button>
<Button
type="button"
variant="text"
className={cn("transition-transform duration-300 rotate-0 lg:hidden", expandMenu && "rotate-90")}
onClick={() => setExpandMenu(prev => !prev)}
>
<Show condition={!expandMenu} fallback={<CloseOutlined />}>
<MenuOutlined />
</Show>
</Button>
</div>
</div>
</header>
</div>
);
}
@@ -1,13 +0,0 @@
import { Outlet } from "react-router-dom";
import { Header } from "./_components/header";
export default function Layout() {
return (
<div className="flex min-h-screen flex-col bg-primary-50">
<Header />
<main className="flex-1 z-0">
<Outlet />
</main>
</div>
);
}
@@ -1,27 +0,0 @@
export interface ForProps<T, U> {
data: readonly T[]
children: (item: T, index: number) => U | null
fallback?: React.ReactNode | null
}
/**
* A functional component that renders a list of children components from a given
* array of data.
*
* @param data - The array of data to render
* @param children - A function that takes the current item and index and returns
* the child component to render
* @param fallback - An optional fallback component to render when the data is empty
*
* @returns An array of rendered child components if the data is not empty, otherwise
* the fallback component if it is provided, null otherwise
*/
export function For<T, U extends React.JSX.Element>({
data,
children,
fallback = null
}: ForProps<T, U>): (U | null)[] | React.ReactNode | null {
if (!Array.isArray(data) || !data?.length) return fallback
return data.map((item, idx) => children(item, idx))
}
@@ -1,19 +0,0 @@
export interface ShowProps {
condition: boolean
children: React.ReactNode
fallback?: React.ReactNode | null
}
/**
* Conditionally renders the `children` component if `condition` is true. If
* `condition` is false, renders the `fallback` component instead.
*
* If `fallback` is `null`, renders nothing.
*
* @param condition - The condition to check
* @param children - The component to render if `condition` is true
* @param fallback - The component to render if `condition` is false
*/
export function Show({ condition, children, fallback = null }: ShowProps) {
return condition ? children : fallback
}
+9 -5
View File
@@ -2,23 +2,27 @@ import { useForm } from 'react-hook-form';
import { import {
authLoginSchema, authLoginSchema,
TLoginRequest, TLoginRequest,
usePostLogin,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useSession } from '@imphnen-frontend-service/utils'; import { toast } from 'sonner';
export const useLogin = () => { export const useLogin = () => {
const postLogin = usePostLogin();
const form = useForm<TLoginRequest>({ const form = useForm<TLoginRequest>({
resolver: zodResolver(authLoginSchema), resolver: zodResolver(authLoginSchema),
mode: 'all', mode: 'all',
}); });
const { signIn, isLoading } = useSession(); const onSubmit = form.handleSubmit((data) => {
postLogin.mutate(data, {
const onSubmit = form.handleSubmit((data) => signIn(data)); onSuccess: () => toast.success("Login sukses"),
onError: (error) => toast.error(error.message),
});
});
return { return {
form, form,
onSubmit, onSubmit,
isLoading
}; };
}; };
-24
View File
@@ -1,24 +0,0 @@
import { useForm } from 'react-hook-form';
import {
TVerifyOtpRequest,
verifyEmailSchema,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useOtp } from '@imphnen-frontend-service/utils';
export const useOtpHook = () => {
const form = useForm<TVerifyOtpRequest>({
resolver: zodResolver(verifyEmailSchema),
mode: 'all',
});
const { otp, isLoading } = useOtp();
const onSubmit = form.handleSubmit((data) => otp(data));
return {
form,
onSubmit,
isLoading
};
};
@@ -1,24 +0,0 @@
import { useForm } from 'react-hook-form';
import {
authRegisterSchema,
TRegisterRequest,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRegister } from '@imphnen-frontend-service/utils';
export const useRegisterHook = () => {
const form = useForm<TRegisterRequest>({
resolver: zodResolver(authRegisterSchema),
mode: 'all',
});
const { register, isLoading } = useRegister();
const onSubmit = form.handleSubmit((data) => register(data));
return {
form,
onSubmit,
isLoading
};
};
@@ -1,10 +0,0 @@
import { useSendOTP } from '@imphnen-frontend-service/utils';
export const useResendOtpHook = () => {
const { resendOTP, isLoading } = useSendOTP();
return {
resendOTP
};
};
+2 -9
View File
@@ -5,7 +5,7 @@ import { useLogin } from '../../_hooks/use-login';
import { ArrowLeftOutlined } from '@ant-design/icons'; import { ArrowLeftOutlined } from '@ant-design/icons';
export const Components: FC = (): ReactElement => { export const Components: FC = (): ReactElement => {
const { form, onSubmit, isLoading } = useLogin(); const { form, onSubmit } = useLogin();
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]">
@@ -42,7 +42,7 @@ export const Components: FC = (): ReactElement => {
Lupa Password ? Lupa Password ?
</a> </a>
</div> </div>
<Button className="w-full" type='submit' disabled={(!form.formState.isValid || isLoading)}>Enter Isekai</Button> <Button className="w-full" type='submit'>Enter Isekai</Button>
</form> </form>
<div className="flex my-3 gap-3 justify-center"> <div className="flex my-3 gap-3 justify-center">
@@ -74,13 +74,6 @@ export const Components: FC = (): ReactElement => {
<ArrowLeftOutlined /> <ArrowLeftOutlined />
Kembali Ke Homepage Kembali Ke Homepage
</Button> </Button>
<Button
className='xl:hidden w-full gap-3'
variant='secondary'
>
<ArrowLeftOutlined />
Kembali Ke Homepage
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -1,12 +0,0 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
export const AppLayout: FC = (): ReactElement => {
return (
<main className="bg-primary-50 min-h-screen">
<Outlet />
</main>
);
};
export default AppLayout;
@@ -1,70 +0,0 @@
import { FC, ReactElement, useEffect, useState } from 'react';
import { ControlledInputField, RegisterResetBanner } from "@imphnen-frontend-service/ui/organisms";
import { useOtpHook } from '../../../_hooks/use-otp';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useSearchParams } from 'react-router-dom';
import { useResendOtpHook } from '../../../_hooks/use-resend-otp';
export const Components: FC = (): ReactElement => {
const { form, onSubmit, isLoading } = useOtpHook()
const [ searchParams ] = useSearchParams()
const [ disabled, setDisabled] = useState(true);
const [ timeLeft, setTimeLeft ] = useState(5 * 60);
const { resendOTP } = useResendOtpHook()
useEffect(() => {
if (disabled) {
const interval = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 1) {
clearInterval(interval);
setDisabled(false);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}
}, [disabled]);
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
};
return (
<div className='flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]'>
<div className='bg-white min-w-[1120px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6'>
<RegisterResetBanner />
<div className='border-2 border-primary-500/50 w-[596px] rounded-lg py-[70px] px-[48px] flex flex-col justify-center'>
<h3 className='mt-5 text-3xl font-semibold text-primary-500'>Verifikasi Email</h3>
<h5 className='mt-2 text-xl font-medium text-primary-500'>Yeay~! Pesan dari dunia lain sudah dikirimkan ke {searchParams.get("email")}</h5>
<form onSubmit={onSubmit} className='mt-7'>
<ControlledInputField
label="OTP"
size="lg"
className="w-full"
placeholder="XXXXXX"
max={6}
name={'otp'}
maxLength={6}
control={form.control}
/>
<Button type="button" className="mt-2" disabled={disabled} onClick={
() => {
resendOTP({email: searchParams.get("email") || ""})
setDisabled(true)
setTimeLeft(5 * 60)
}
}>Kirim ulang otp {disabled? `(${formatTime(timeLeft)})`: ""}</Button>
<Button className="xl:w-full mt-2" disabled={(!form.formState.isValid || isLoading)}>Linked Start !!!</Button>
</form>
</div>
</div>
</div>
);
};
export default Components;
+77 -89
View File
@@ -1,108 +1,96 @@
import { FC, ReactElement } from 'react'; import { FC, ReactElement } from 'react';
import { ControlledInputField, RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms'; import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { ArrowLeftOutlined } from '@ant-design/icons'; import { InputField } from '@imphnen-frontend-service/ui/molecules';
import { useRegisterHook } from '../../_hooks/use-register';
export const Components: FC = (): ReactElement => { export const Components: FC = (): ReactElement => {
const { form, onSubmit, isLoading } = useRegisterHook();
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 xl:min-w-[1130px] 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">
<RegisterResetBanner /> <RegisterResetBanner />
<div className="xl:border-2 xl:border-primary-500/50 xl:w-[726px] rounded-lg py-[32px] px-[48px] flex justify-center"> <div className="border-2 border-primary-500/50 w-[696px] rounded-lg py-[32px] px-[48px] flex justify-center">
<div className="xl:w-[714px]"> <div className="w-[704px]">
<Button
className='xl:hidden gap-3'
variant='secondary'
>
<ArrowLeftOutlined />
Login
</Button>
<h3 className="mt-5 text-3xl font-semibold text-primary-500"> <h3 className="mt-5 text-3xl font-semibold text-primary-500">
Register Register
</h3> </h3>
<h5 className="text-primary-500 font-medium"> <h5 className="text-primary-500 font-medium">
Yosha~! Saatnya Bergabung dengan Dimentorin Yosha~! Saatnya Bergabung dengan Dimentorin
</h5> </h5>
<form onSubmit={onSubmit}> <div className="grid grid-flow-row-dense my-7 grid-cols-2 gap-2">
<div className='my-7'> <InputField
<ControlledInputField label="First Name"
label="Full name"
size="lg"
className="w-full"
placeholder="Nama Lengkap"
name={'fullname'}
control={form.control}
/>
</div>
<div className="grid grid-flow-row-dense my-7 lg:grid-cols-2 gap-2">
<ControlledInputField
label="Email"
size="lg"
className="w-full"
placeholder="Contoh : yourname@mail.com"
type='email'
control={form.control}
name={'email'}
/>
<ControlledInputField
label="No Hp"
size="lg"
className="w-full"
placeholder="Contoh : 088877665544"
control={form.control}
name={'phone_number'}
/>
</div>
<ControlledInputField
label="Password"
className="w-full"
size="lg" size="lg"
type="password" placeholder="Nama Depan"
placeholder="Buat password sekeren jurus ultimate-mu!"
control={form.control}
name={'password'}
/> />
<h6 className="text-sm text-gray-500 mt-1"> <InputField
"Senpai~! Pastikan password-mu sekuat pertahanan kastil!" label="Last Name"
</h6>
<h6 className="text-sm text-gray-500 mt-2">
Tips membuat password yang OP:
</h6>
<h6 className="text-sm text-gray-500">
- Minimal 8 karakter (semakin panjang, semakin power-up!{' '}
<span role="img" aria-label="emoji">
</span>
)
</h6>
<h6 className="text-sm text-gray-500">
- Campur huruf besar, kecil, angka, dan simbol untuk kombinasi
ultimate!
<span role="img" aria-label="emoji">
🔥
</span>
</h6>
<h6 className="text-sm text-gray-500 mb-2">
- Jangan pakai password yang gampang ditebak, nanti ketahuan
musuh!
<span role="img" aria-label="emoji">
🚨
</span>
</h6>
<ControlledInputField
label="Repeat Password"
className="w-full"
size="lg" size="lg"
type="password" className="w-full"
placeholder="Ulangi password-mu, Senpai~!" placeholder="Nama Belakang"
control={form.control}
name={"confirm_password"}
/> />
<Button className="xl:w-full mt-2" disabled={(!form.formState.isValid || isLoading)}>Linked Start !!!</Button> <InputField
</form> label="Email"
size="lg"
placeholder="Contoh : yourname@mail.com"
/>
<div className="flex gap-2">
<div className="flex flex-col gap-2">
<h4 className="font-medium">OTP Code</h4>
<Input
className="w-full"
size="lg"
placeholder="Kode OTP"
/>
</div>
<div className="flex flex-col">
<Button size="sm" className="mt-[30px]">
Kirim OTP
</Button>
</div>
</div>
</div>
<InputField
label="Password"
className="w-full"
size="lg"
type="password"
placeholder="Buat password sekeren jurus ultimate-mu!"
/>
<h6 className="text-sm text-gray-500 mt-1">
"Senpai~! Pastikan password-mu sekuat pertahanan kastil!"
</h6>
<h6 className="text-sm text-gray-500 mt-2">
Tips membuat password yang OP:
</h6>
<h6 className="text-sm text-gray-500">
- Minimal 8 karakter (semakin panjang, semakin power-up!{' '}
<span role="img" aria-label="emoji">
</span>
)
</h6>
<h6 className="text-sm text-gray-500">
- Campur huruf besar, kecil, angka, dan simbol untuk kombinasi
ultimate!
<span role="img" aria-label="emoji">
🔥
</span>
</h6>
<h6 className="text-sm text-gray-500 mb-2">
- Jangan pakai password yang gampang ditebak, nanti ketahuan
musuh!
<span role="img" aria-label="emoji">
🚨
</span>
</h6>
<InputField
label="Repeat Password"
className="w-full"
size="lg"
type="password"
placeholder="Ulangi password-mu, Senpai~!"
/>
<Button className="w-full mt-2">Linked Start !!!</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -1,21 +1,15 @@
import { FC, ReactElement, useEffect } from 'react'; import { FC, ReactElement } from 'react';
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms'; import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { ArrowRightOutlined } from '@ant-design/icons'; import { ArrowRightOutlined } from '@ant-design/icons';
export const Components: FC = (): ReactElement => { export const Components: FC = (): ReactElement => {
useEffect(() => {
setTimeout(() => {
document.location.href = "/auth/login"
}, 10000)
}, [])
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 xl:min-w-[1220px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6"> <div className="bg-white min-w-[1220px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6">
<RegisterResetBanner /> <RegisterResetBanner />
<div className="border-2 border-primary-500/50 xl:w-[696px] rounded-lg py-[32px] px-[48px] flex justify-center bg-primary-50"> <div className="border-2 border-primary-500/50 w-[696px] rounded-lg py-[32px] px-[48px] flex justify-center bg-primary-50">
<div className="xl:w-[704px] flex flex-col justify-center items-center"> <div className="w-[704px] flex flex-col justify-center items-center">
<h2 className="text-4xl text-primary-500 font-semibold"> <h2 className="text-4xl text-primary-500 font-semibold">
Register Successfull Register Successfull
</h2> </h2>
@@ -47,12 +41,10 @@ export const Components: FC = (): ReactElement => {
<h4 className="mt-10 text-primary-500 font-medium text-xl"> <h4 className="mt-10 text-primary-500 font-medium text-xl">
Kamu akan memasuki isekai dalam 10 dtk Kamu akan memasuki isekai dalam 10 dtk
</h4> </h4>
<a href="/auth/login"> <Button className="gap-3 mt-10" size="lg">
<Button className="gap-3 mt-10" size="lg"> Masuk Isekai
Masuk Isekai <ArrowRightOutlined />
<ArrowRightOutlined /> </Button>
</Button>
</a>
</div> </div>
</div> </div>
</div> </div>
+7
View File
@@ -0,0 +1,7 @@
import { FC, ReactElement } from 'react';
export const Components: FC = (): ReactElement => {
return <>Hallo</>;
};
export default Components;
+3 -105
View File
@@ -1,113 +1,11 @@
import { import { LoaderFunctionArgs } from 'react-router-dom';
PERMISSIONS,
SessionToken,
SessionUser,
} from '@imphnen-frontend-service/utils';
import { LoaderFunctionArgs, redirect } from 'react-router';
const mappingPublicRoutes = [
'/',
'/auth/login',
'/auth/forgot',
'/auth/forgot/otp',
'/auth/register',
'/auth/register/otp',
'/auth/register/success',
'/auth/new-password',
];
const mappingRoutePermissions = [
{
path: '/dashboard',
permissions: [],
},
{
path: '/users',
permissions: [PERMISSIONS.USERS.READ_LIST],
},
{
path: '/users/create',
permissions: [PERMISSIONS.USERS.CREATE],
},
{
path: '/users/update',
permissions: [PERMISSIONS.USERS.UPDATE],
},
{
path: '/users/detail',
permissions: [PERMISSIONS.USERS.READ_DETAIL],
},
{
path: '/roles',
permissions: [PERMISSIONS.ROLES.READ_LIST],
},
{
path: '/roles/create',
permissions: [PERMISSIONS.ROLES.CREATE],
},
{
path: '/roles/update',
permissions: [PERMISSIONS.ROLES.UPDATE],
},
{
path: '/roles/detail',
permissions: [PERMISSIONS.ROLES.READ_DETAIL],
},
{
path: '/permissions',
permissions: [PERMISSIONS.PERMISSIONS.READ_LIST],
},
{
path: '/permissions/create',
permissions: [PERMISSIONS.PERMISSIONS.CREATE],
},
{
path: '/permissions/update',
permissions: [PERMISSIONS.PERMISSIONS.UPDATE],
},
{
path: '/permissions/detail',
permissions: [PERMISSIONS.PERMISSIONS.READ_DETAIL],
},
];
//TODO : Fix this later
// const redirectToFirstAccessibleRoute = (userPermissions: string[]) => {
// const fallback = mappingRoutePermissions.find((route) =>
// route.permissions.some((perm) => userPermissions.includes(perm))
// );
// return redirect(fallback?.path ?? '/auth/login');
// };
export const middleware = async ({ request }: LoaderFunctionArgs) => { export const middleware = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url); const url = new URL(request.url);
const pathname = url.pathname; const pathname = url.pathname;
const session = SessionUser.get();
const session_token = SessionToken.get();
const token = session_token?.token?.access_token;
const userPermissions =
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
if (mappingPublicRoutes.includes(pathname)) { if (pathname) return null;
if (token) return redirect('/dashboard');
return null;
}
if (!session) return redirect('/auth/login');
const matchedRoute = mappingRoutePermissions.find(
(route) => route.path === pathname
);
if (matchedRoute) {
const hasPermission =
!matchedRoute.permissions ||
matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
if (!hasPermission) {
return '/dashboard';
}
}
return null; return null;
}; };
-1
View File
@@ -1 +0,0 @@
VITE_API_URL=
@@ -2,7 +2,6 @@ import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Modal } from '@imphnen-frontend-service/ui/molecules'; import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms'; import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { useLogin } from '../../_hooks/use-login'; import { useLogin } from '../../_hooks/use-login';
import ModalFormVerifyEmail from './modal-form-verify-email';
interface IModalFormLogin { interface IModalFormLogin {
isOpen: boolean; isOpen: boolean;
@@ -17,87 +16,61 @@ const ModalFormLogin = ({
onForgotPassword, onForgotPassword,
setIsOpenRegisterModal, setIsOpenRegisterModal,
}: IModalFormLogin) => { }: IModalFormLogin) => {
const { const { form, onSubmit } = useLogin();
form,
onSubmit,
isLoading,
showVerifyModal,
verifyForm,
onVerifySubmit,
closeVerifyModal,
isVerifying,
emailToVerify,
} = 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" isOpen={isOpen}
isOpen={isOpen && !showVerifyModal} onClose={onClose}
onClose={onClose} >
> <Modal.Header className="space-y-4">
<Modal.Header className="space-y-4"> <img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" /> <h1 className="text-primary-500 text-p1 text-center font-semibold">
<h1 className="text-primary-500 text-p1 text-center font-semibold"> Login
Login </h1>
</h1> </Modal.Header>
</Modal.Header> <Modal.Content>
<Modal.Content> <form className="space-y-4" onSubmit={onSubmit}>
<form className="space-y-4" onSubmit={onSubmit}> <ControlledInputField
<ControlledInputField control={form.control}
control={form.control} label="Email"
label="Email" placeholder="Masukkan Email"
placeholder="Masukkan Email" type="email"
type="email" name="email"
name="email" size="lg"
size="lg" className="w-full"
className="w-full" />
/> <ControlledInputField
<ControlledInputField control={form.control}
control={form.control} label="Password"
label="Password" placeholder="Masukkan Password"
placeholder="Masukkan Password" type="password"
type="password" name="password"
name="password" size="lg"
size="lg" className="w-full"
className="w-full" />
/> <h1 className="text-end text-primary-500 text-xl font-medium">
<h1 className="text-end text-primary-500 text-xl font-medium"> <Button variant="text" onClick={onForgotPassword}>
<Button variant="text" type="button" onClick={onForgotPassword}> Lupa Password?
Lupa Password?
</Button>
</h1>
<Button
type="submit"
size="md"
className="w-full"
disabled={isLoading}
>
{isLoading ? 'Loading...' : 'Login'}
</Button> </Button>
<div className="flex justify-center gap-2 pt-2.5 font-medium"> </h1>
<p className="text-neutral-500">Belum punya akun?</p> <Button type="submit" size="md" className="w-full">
<Button Login
variant="text" </Button>
className="text-primary-500 hover:text-primary-600 m-0 p-0" <div className="flex justify-center gap-2 pt-2.5 font-medium">
onClick={() => setIsOpenRegisterModal(true)} <p className="text-neutral-500">Belum punya akun?</p>
> <Button
Daftar variant="text"
</Button> className="text-primary-500 hover:text-primary-600 m-0 p-0"
</div> onClick={() => setIsOpenRegisterModal(true)}
</form> >
</Modal.Content> Daftar
</Modal> </Button>
</div>
<ModalFormVerifyEmail </form>
isOpen={showVerifyModal} </Modal.Content>
onClose={closeVerifyModal} </Modal>
verifyForm={verifyForm}
onVerifySubmit={onVerifySubmit}
isVerifying={isVerifying}
email={emailToVerify}
/>
</>
); );
}; };
@@ -1,8 +1,11 @@
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Modal } from '@imphnen-frontend-service/ui/molecules'; import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { useQueryState } from '@imphnen-frontend-service/utils';
import { useRegister } from '../../_hooks/use-register'; import { useRegister } from '../../_hooks/use-register';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms'; import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import ModalFormVerifyEmail from './modal-form-verify-email'; 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;
@@ -10,100 +13,168 @@ interface IModalFormRegisterProps {
} }
const ModalFormRegister = ({ isOpen, onClose }: IModalFormRegisterProps) => { const ModalFormRegister = ({ isOpen, onClose }: IModalFormRegisterProps) => {
const { form, onSubmit, isStepOneValid } = useRegister();
const { const {
form, step: currentStep,
verifyForm, nextStep,
onSubmit, prevStep,
onVerifySubmit, resetStep,
showVerifyModal, } = useQueryState('registerStep', {
closeVerifyModal, defaultValue: 1,
isVerifying, maxValue: 2,
isRegistering, minValue: 1,
registeredEmail, });
} = useRegister();
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" isOpen={isOpen}
isOpen={isOpen && !showVerifyModal} onClose={() => {
onClose={onClose} onClose();
> resetStep();
<Modal.Header className="space-y-4"> }}
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" /> >
<h1 className="text-primary-500 text-p1 text-center font-semibold"> <Modal.Header className="space-y-4">
Register <img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
</h1> <h1 className="text-primary-500 text-p1 text-center font-semibold">
</Modal.Header> Register
<Modal.Content> </h1>
<form onSubmit={onSubmit} className="space-y-6"> <h2 className="text-neutral-500 text-lg text-center">
<ControlledInputField {currentStep === 1 ? 'Info Akun' : 'Informasi Pengiriman Hadiah'}
control={form.control} </h2>
name="fullname" </Modal.Header>
label="Nama Lengkap" <Modal.Content>
placeholder="Masukkan Nama Lengkap" <form onSubmit={onSubmit} className="space-y-6">
type="text" {currentStep === 1 && (
size="lg" <StepOne
className="w-full" form={form}
nextStep={nextStep}
isStepOneValid={isStepOneValid}
/> />
<ControlledInputField )}
control={form.control} {currentStep === 2 && <StepTwo form={form} prevStep={prevStep} />}
label="Email" </form>
placeholder="Masukkan Email Anda" </Modal.Content>
type="email" </Modal>
name="email" );
size="lg" };
className="w-full"
/>
<ControlledInputField
control={form.control}
name="password"
label="Password"
placeholder="Masukkan Password"
type="password"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="confirm_password"
label="Ulangi Password"
placeholder="Masukkan Ulang Password"
type="password"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="phone_number"
label="Nomor Telepon"
placeholder="Masukkan Nomor Telepon Aktif"
type="text"
size="lg"
className="w-full"
/>
<Button
size="md"
className="w-full"
type="submit"
disabled={isRegistering || !form.formState.isValid}
>
{isRegistering ? 'Mendaftar...' : 'Register'}
</Button>
</form>
</Modal.Content>
</Modal>
<ModalFormVerifyEmail interface IStepOneProps {
isOpen={showVerifyModal} form: UseFormReturn<TRegisterRequest, any, TRegisterRequest>;
onClose={closeVerifyModal} nextStep: () => void;
verifyForm={verifyForm} isStepOneValid: boolean;
onVerifySubmit={onVerifySubmit} }
isVerifying={isVerifying}
email={registeredEmail} const StepOne = ({ form, nextStep, isStepOneValid }: IStepOneProps) => {
return (
<>
<ControlledInputField
control={form.control}
name="fullname"
label="Nama Lengkap"
placeholder="Masukkan Nama Lengkap"
type="text"
size="lg"
className="w-full"
/> />
<ControlledInputField
control={form.control}
label="Email"
placeholder="Masukkan Email Anda"
type="email"
name="email"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="password"
label="Password"
placeholder="Masukkan Password"
type="password"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="confirm_password"
label="Ulangi Password"
placeholder="Masukkan Ulang Password"
type="password"
size="lg"
className="w-full"
/>
<Button
size="md"
className="w-full"
onClick={nextStep}
disabled={!isStepOneValid}
>
Selanjutnya
</Button>
</> </>
); );
}; };
interface IStepTwoProps {
form: UseFormReturn<TRegisterRequest, any, TRegisterRequest>;
prevStep: () => void;
}
const StepTwo = ({ form, prevStep }: IStepTwoProps) => (
<>
<ControlledInputField
control={form.control}
name="phone_number"
label="Nomor Telepon"
placeholder="Masukkan Nomor Telepon Aktif"
type="text"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="referral_code"
label="Kode Referral"
placeholder="Masukkan Kode Referral"
type="text"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="referred_by"
label="Referral By"
placeholder="Masukkan Referral By"
type="text"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="student_type"
label="Tipe Pelajar"
placeholder="Masukkan Tipe Pelajar"
type="text"
size="lg"
className="w-full"
/>
<div className="flex gap-6">
<Button size="md" variant="text" className="w-[40%]" onClick={prevStep}>
Kembali
</Button>
<Button size="md" className="w-full" type="submit">
Gacha Sekarang
</Button>
</div>
</>
);
export default ModalFormRegister; export default ModalFormRegister;
@@ -1,63 +0,0 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { UseFormReturn } from 'react-hook-form';
interface IModalFormVerifyEmailProps {
isOpen: boolean;
onClose: () => void;
verifyForm: UseFormReturn<{ otp: string }, any>;
onVerifySubmit: (e: React.FormEvent<HTMLFormElement>) => void;
isVerifying: boolean;
email?: string;
}
const ModalFormVerifyEmail = ({
isOpen,
onClose,
verifyForm,
onVerifySubmit,
isVerifying,
email,
}: IModalFormVerifyEmailProps) => {
return (
<Modal
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
isOpen={isOpen}
onClose={onClose}
>
<Modal.Header className="space-y-4">
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
<h1 className="text-primary-500 text-p1 text-center font-semibold">
Verifikasi Email
</h1>
<h2 className="text-neutral-500 text-lg text-center">
Masukkan kode OTP yang telah dikirim ke {email ? email : 'email Anda'}
</h2>
</Modal.Header>
<Modal.Content>
<form onSubmit={onVerifySubmit} className="space-y-6">
<ControlledInputField
control={verifyForm.control}
name="otp"
label="Kode OTP"
placeholder="Masukkan Kode OTP"
type="text"
size="lg"
className="w-full"
/>
<Button
size="md"
className="w-full"
type="submit"
disabled={isVerifying}
>
{isVerifying ? 'Memverifikasi...' : 'Verifikasi'}
</Button>
</form>
</Modal.Content>
</Modal>
);
};
export default ModalFormVerifyEmail;
+3 -55
View File
@@ -5,74 +5,22 @@ import {
usePostLogin, usePostLogin,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useAuthStore } from '@imphnen-frontend-service/utils';
import { toast } from 'sonner';
import { useNavigate } from 'react-router';
import { useVerifyEmail } from './use-verify-email';
export const useLogin = () => { export const useLogin = () => {
const postLogin = usePostLogin();
const form = useForm<TLoginRequest>({ const form = useForm<TLoginRequest>({
resolver: zodResolver(authLoginSchema), resolver: zodResolver(authLoginSchema),
mode: 'all', mode: 'all',
}); });
const navigate = useNavigate();
const { mutate, isPending } = usePostLogin();
const { setLoading, setSession, clearSession } = useAuthStore();
const {
openVerifyModal,
showVerifyModal,
verifyForm,
onVerifySubmit,
closeVerifyModal,
isVerifying,
emailToVerify,
} = useVerifyEmail();
const onSubmit = form.handleSubmit((data) => { const onSubmit = form.handleSubmit((data) => {
setLoading(true); postLogin.mutate(data, {
mutate(data, { onSuccess: () => console.log('Success Login'),
onSuccess: (response) => {
toast.success('Login sukses');
if (response.data.token && response.data.user) {
setSession(response.data);
} else {
const { token, ...userData } = response.data;
const loginData = {
token,
userData,
};
setSession(loginData);
}
navigate(0);
},
onError: (error) => {
const errorMessage = error?.response?.data?.message;
if (errorMessage === 'Account not active, please verify your email') {
toast.error('Akun belum aktif, silakan verifikasi email Anda');
openVerifyModal(data.email);
} else {
toast.error(errorMessage ?? 'Terjadi Kesalahan yang tidak diketahui');
clearSession();
}
},
}); });
}); });
return { return {
form, form,
onSubmit, onSubmit,
isLoading: isPending,
showVerifyModal,
verifyForm,
onVerifySubmit,
closeVerifyModal,
isVerifying,
emailToVerify,
}; };
}; };
+25 -23
View File
@@ -1,25 +1,17 @@
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { import {
authRegisterSchema, authRegisterSchema,
stepOneRegisterSchema,
TRegisterRequest, TRegisterRequest,
usePostRegister, usePostRegister,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useVerifyEmail } from './use-verify-email'; import { useEffect, useState } from 'react';
export const useRegister = () => { export const useRegister = () => {
const postRegister = usePostRegister(); const postRegister = usePostRegister();
const {
verifyForm,
onVerifySubmit,
showVerifyModal,
openVerifyModal,
closeVerifyModal,
isVerifying,
emailToVerify,
} = useVerifyEmail();
const form = useForm<TRegisterRequest>({ const form = useForm<TRegisterRequest>({
resolver: zodResolver(authRegisterSchema), resolver: zodResolver(authRegisterSchema),
mode: 'all', mode: 'all',
@@ -29,28 +21,38 @@ export const useRegister = () => {
password: '', password: '',
confirm_password: '', confirm_password: '',
phone_number: '', 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) => { const onSubmit = form.handleSubmit((data) => {
postRegister.mutate(data, { postRegister.mutate(data, {
onSuccess: () => { onSuccess: (data) => toast.success(data.message),
toast.success('Registrasi sukses. Silakan verifikasi email Anda.'); onError: (error) => toast.error(error.message),
openVerifyModal(data.email);
},
onError: (error) => toast.error(error.message || 'Registrasi gagal'),
}); });
}); });
return { return {
form, form,
verifyForm,
onSubmit, onSubmit,
onVerifySubmit, isStepOneValid: stepValid,
showVerifyModal,
closeVerifyModal,
isVerifying,
isRegistering: postRegister.isPending,
registeredEmail: emailToVerify,
}; };
}; };
@@ -1,62 +0,0 @@
import { useForm } from 'react-hook-form';
import {
TVerifyEmailRequest,
usePostVerifyEmail,
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
export const useVerifyEmail = () => {
const [showVerifyModal, setShowVerifyModal] = useState(false);
const [emailToVerify, setEmailToVerify] = useState('');
const navigate = useNavigate();
const postVerifyEmail = usePostVerifyEmail();
const verifyForm = useForm<{ otp: string }>({
mode: 'all',
defaultValues: {
otp: '',
},
});
const onVerifySubmit = verifyForm.handleSubmit((data) => {
const otpNumber = data.otp;
const verifyData: TVerifyEmailRequest = {
email: emailToVerify,
otp: otpNumber,
};
postVerifyEmail.mutate(verifyData, {
onSuccess: () => {
toast.success('Verifikasi email sukses');
navigate(0);
},
onError: (error) =>
toast.error(error.message || 'Verifikasi email gagal'),
});
});
const openVerifyModal = (email: string) => {
setEmailToVerify(email);
setShowVerifyModal(true);
};
const closeVerifyModal = () => {
setShowVerifyModal(false);
verifyForm.reset();
};
return {
verifyForm,
onVerifySubmit,
showVerifyModal,
openVerifyModal,
closeVerifyModal,
isVerifying: postVerifyEmail.isPending,
emailToVerify,
};
};
+3 -5
View File
@@ -66,15 +66,11 @@ export const Components: FC = (): ReactElement => {
<div className="order-1 lg:order-none flex flex-col col-span-4 justify-center items-center mb-8 relative"> <div className="order-1 lg:order-none flex flex-col col-span-4 justify-center items-center mb-8 relative">
<div className="relative h-[150px] md:h-[230px]"> <div className="relative h-[150px] md:h-[230px]">
<img <img
width={400}
height={400}
src="/merch/1.png" src="/merch/1.png"
alt="Merch 1" alt="Merch 1"
className="relative top-[3px] md:top-[6px] left-[4px] md:left-[7px] z-10 w-[165px] md:w-[266px]" className="relative top-[3px] md:top-[6px] left-[4px] md:left-[7px] z-10 w-[165px] md:w-[266px]"
/> />
<img <img
width={400}
height={400}
className="absolute top-0 left-0 min-w-[174px] md:min-w-[280px] z-0" className="absolute top-0 left-0 min-w-[174px] md:min-w-[280px] z-0"
src="/merch/Vector-1.svg" src="/merch/Vector-1.svg"
alt="" alt=""
@@ -92,7 +88,7 @@ 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} width={400}
@@ -109,6 +105,8 @@ export const Components: FC = (): ReactElement => {
alt="Merch 3" 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="Merch 1" alt="Merch 1"
+1 -1
View File
@@ -35,7 +35,7 @@ if (!rootElement) throw new Error('Failed to find the root element');
createRoot(rootElement).render( createRoot(rootElement).render(
<StrictMode> <StrictMode>
<QueryProvider> <QueryProvider>
<Toaster position="top-right"/> <Toaster />
<RouterProvider router={router} /> <RouterProvider router={router} />
</QueryProvider> </QueryProvider>
</StrictMode> </StrictMode>
+7 -11
View File
@@ -1,15 +1,11 @@
// import { SessionToken, SessionUser } from '@imphnen-frontend-service/utils'; import { LoaderFunctionArgs } from 'react-router-dom';
import { LoaderFunctionArgs } from 'react-router';
// const mappingPublicRoutes = ['/'];
// const mappingRoutePermissions = [
// {
// path: '/',
// permissions: [],
// },
// ];
export const middleware = async ({ request }: LoaderFunctionArgs) => { export const middleware = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const pathname = url.pathname;
if (pathname) return null;
return null; return null;
}; };
-4
View File
@@ -1,4 +0,0 @@
NEXT_PUBLIC_API_URL=
TURNSTILE_SECRET_KEY=
NEXT_PUBLIC_TURNSTILE_SITEKEY=
+8 -3
View File
@@ -1,10 +1,15 @@
const { composePlugins, withNx } = require('@nx/next'); const { composePlugins, withNx } = require('@nx/next');
const path = require('path'); const { withPayload } = require('@payloadcms/next/withPayload');
/** @type {import('@nx/next/plugins/with-nx').WithNxOptions} */ /** @type {import('@nx/next/plugins/with-nx').WithNxOptions} */
const nextConfig = { const nextConfig = {
nx: { svgr: false }, nx: { svgr: false },
output: 'standalone', sassOptions: {
quietDeps: true,
logger: {
warn: () => {},
},
},
}; };
module.exports = composePlugins(withNx)(nextConfig); module.exports = composePlugins(withNx, withPayload)(nextConfig);
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

@@ -1,41 +0,0 @@
export async function fetchPostverifyTurnstile(
token: string,
remoteIp?: string
): Promise<boolean> {
const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
const params = new URLSearchParams({
secret: String(process.env.TURNSTILE_SECRET_KEY),
response: token,
});
if (remoteIp) {
params.append('remoteip', remoteIp);
}
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
});
if (!res.ok) {
console.error('Turnstile verify HTTP error', res.status);
return false;
}
const data = (await res.json()) as TurnstileVerifyResponse;
if (!data.success) {
console.warn('Turnstile failure', data['error-codes']);
return false;
}
return true;
}
interface TurnstileVerifyResponse {
success: boolean;
challenge_ts: string;
hostname: string;
'error-codes'?: string[];
}
@@ -1,33 +0,0 @@
'use server';
import { fetcher } from '@/lib/fetcher';
import { getRemoteIp } from '@/lib/headers';
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
import {
forgotPasswordValidationSchema,
ForgotPasswordValidationType,
} from '../_validation/forgot-password-validation';
export async function ForgotPasswordAction(
request: ForgotPasswordValidationType
) {
const validRequest = forgotPasswordValidationSchema.parse(request);
const remoteIp = await getRemoteIp();
const isCapchaValid = await fetchPostverifyTurnstile(
validRequest.token,
remoteIp
);
if (!isCapchaValid) throw new Error('Failed to verify captcha');
const { data, error } = await fetcher.POST('/v1/auth/forgot', {
body: {
email: validRequest.email,
},
});
if (error) throw new Error(error.message);
return data;
}
@@ -1,98 +0,0 @@
'use client';
import {
Button,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@components';
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
import { useRef, useState } from 'react';
import { LuLoader } from 'react-icons/lu';
import { useFormForgotPassword } from '../_hooks/use-form-forgot-password';
import { usePostForgotPassowrd } from '../_hooks/use-post-forgot-password';
import { ForgotPasswordValidationType } from '../_validation/forgot-password-validation';
export function ForgotPasswordForm() {
const ref = useRef<TurnstileInstance | null>(null);
const [step, setStep] = useState<number>(1);
const [emailValue, setEmailValue] = useState<string>('');
const form = useFormForgotPassword();
const { mutate, isPending, error } = usePostForgotPassowrd(form);
const handleFirstStep = (values: ForgotPasswordValidationType) => {
setEmailValue(values.email);
setStep(2);
};
const handleSecondStep = () => {
mutate({ email: emailValue, token: form.getValues('token') });
};
return (
<Form {...form}>
<form
onSubmit={
step === 1
? form.handleSubmit(handleFirstStep)
: (e) => {
e.preventDefault();
handleSecondStep();
}
}
className="w-full space-y-4"
>
{error && (
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
{(error as Error).message}
</div>
)}
{step === 1 && (
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="emailmu@mail.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{step === 2 && (
<Turnstile
ref={ref}
siteKey={String(process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY)}
onSuccess={(token) => form.setValue('token', token)}
options={{ theme: 'light', size: 'flexible', language: 'id' }}
/>
)}
<Button
type="submit"
disabled={isPending || (step === 2 && !form.watch('token'))}
className="w-full hover:bg-[#5fbaef] bg-[#22a5f1] font-bold"
>
{isPending ? (
<LuLoader className="h-5 w-5 animate-spin" />
) : step === 1 ? (
'Selanjutnya'
) : (
'Reset password'
)}
</Button>
</form>
</Form>
);
}
@@ -1,16 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import {
forgotPasswordValidationSchema,
type ForgotPasswordValidationType,
} from '../_validation/forgot-password-validation';
export function useFormForgotPassword() {
return useForm<ForgotPasswordValidationType>({
resolver: zodResolver(forgotPasswordValidationSchema),
defaultValues: {
email: '',
token: '',
},
});
}
@@ -1,20 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';
import { ForgotPasswordAction } from '../_actions/forgot-password-action';
import { useFormForgotPassword } from './use-form-forgot-password';
export function usePostForgotPassowrd(
form: ReturnType<typeof useFormForgotPassword>
) {
return useMutation({
mutationFn: ForgotPasswordAction,
onSuccess: ({ message }) => {
form.reset();
toast.success(message);
},
onError: ({ message }) => {
form.reset();
toast.error(message);
},
});
}
@@ -1,9 +0,0 @@
import { z } from 'zod';
export const forgotPasswordValidationSchema = z.object({
email: z.string().email({ message: 'Format email tidak valid' }),
token: z.string(),
});
export type ForgotPasswordValidationType = z.infer<
typeof forgotPasswordValidationSchema
>;
@@ -1,23 +0,0 @@
import { LogoSimple } from '@/app/_components/logo';
import { Metadata } from 'next';
import { ForgotPasswordForm } from './_components/forgot-password-form';
export const metadata: Metadata = {
title: 'IMPHNEN - Signin',
};
export default function Page() {
return (
<>
<div className="flex flex-col items-center gap-4">
<LogoSimple />
<p className="text-muted-foreground text-center text-sm sm:text-base">
Reset password akunmu
</p>
<ForgotPasswordForm />
</div>
</>
);
}
@@ -1,28 +0,0 @@
'use server';
import { fetcher } from '@/lib/fetcher';
import {
resetPasswordValidationSchema,
ResetPasswordValidationSchema,
} from '../_validation/reset-password-validation';
export async function resetPasswordAction(
request: ResetPasswordValidationSchema
) {
const validRequest = resetPasswordValidationSchema.parse(request);
if (validRequest.confirm_password !== validRequest.confirm_password) {
throw new Error('Password missmatch');
}
const { data, error } = await fetcher.POST('/v1/auth/new-password', {
body: {
password: validRequest.password,
token: validRequest.token,
},
});
if (error) throw new Error(error.message);
return data;
}
@@ -1,73 +0,0 @@
'use client';
import {
Button,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@components';
import { LuLoaderCircle } from 'react-icons/lu';
import { usePostResetPassword } from '../_hooks/use-post-reset-password';
import { useResetPasswordForm } from '../_hooks/use-reset-password-form';
import { ResetPasswordValidationSchema } from '../_validation/reset-password-validation';
export function ResetPasswordForm() {
const form = useResetPasswordForm();
const { mutate, error, isPending } = usePostResetPassword(form);
const onSubmit = (values: ResetPasswordValidationSchema) => {
mutate(values);
};
return (
<Form {...form}>
{error && (
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm w-full">
{(error as Error).message}
</div>
)}
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 w-full">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirm_password"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm New Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? (
<LuLoaderCircle className="size-5 animate-spin" />
) : (
'Ubah password'
)}
</Button>
</form>
</Form>
);
}
@@ -1,24 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { resetPasswordAction } from '../_actions/reset-password-actions';
import { useResetPasswordForm } from './use-reset-password-form';
export function usePostResetPassword(
form: ReturnType<typeof useResetPasswordForm>
) {
const router = useRouter();
return useMutation({
mutationFn: resetPasswordAction,
onSuccess: ({ message }) => {
form.reset();
router.replace('/signin');
toast.success(message);
},
onError: ({ message }) => {
form.reset();
toast.error(message);
},
});
}
@@ -1,22 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useSearchParams } from 'next/navigation';
import { useForm } from 'react-hook-form';
import {
resetPasswordValidationSchema,
ResetPasswordValidationSchema,
} from '../_validation/reset-password-validation';
export function useResetPasswordForm() {
const searchParams = useSearchParams();
const tokenFromQuery = searchParams.get('token') ?? '';
return useForm<ResetPasswordValidationSchema>({
resolver: zodResolver(resetPasswordValidationSchema),
defaultValues: {
password: '',
confirm_password: '',
token: tokenFromQuery,
},
});
}
@@ -1,31 +0,0 @@
import { z } from 'zod';
export const resetPasswordValidationSchema = z
.object({
password: z
.string({
required_error: 'Password tidak boleh kosong',
invalid_type_error: 'Password harus berupa string',
})
.min(8, 'Password harus minimal 8 karakter')
.max(50, 'Password tidak boleh lebih dari 50 karakter')
.regex(
/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*\W).+$/,
'Password harus mengandung setidaknya satu huruf kapital, satu huruf kecil, satu angka, dan satu karakter spesial'
),
confirm_password: z
.string({
required_error: 'Konfirmasi password tidak boleh kosong',
})
.min(8, 'Konfirmasi password harus minimal 8 karakter')
.max(50, 'Konfirmasi password tidak boleh lebih dari 50 karakter'),
token: z.string(),
})
.refine((data) => data.password === data.confirm_password, {
message: 'Password dan Konfirmasi Password harus sama',
path: ['confirm_password'],
});
export type ResetPasswordValidationSchema = z.infer<
typeof resetPasswordValidationSchema
>;
@@ -1,30 +0,0 @@
import { LogoSimple } from '@/app/_components/logo';
import { redirect } from 'next/navigation';
import { use } from 'react';
import { ResetPasswordForm } from './_components/reset-password-form';
export const dynamic = 'force-dynamic';
export default function Page({
searchParams,
}: {
searchParams: Promise<{ token?: string }>;
}) {
const { token } = use(searchParams);
if (!token) redirect('/signin');
return (
<>
<div className="flex flex-col items-center gap-4">
<LogoSimple />
<p className="text-muted-foreground text-center text-sm sm:text-base">
Ubah passwordmu
</p>
<ResetPasswordForm />
</div>
</>
);
}
@@ -1,25 +0,0 @@
'use server';
import { getRemoteIp } from '@/lib/headers';
import { z } from 'zod';
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
import { fetchPostSignin } from '../_http/fetch-post-signup';
import { signupValidationSchema } from '../_validation/signup-validation';
export async function SignupAction(
request: z.infer<typeof signupValidationSchema>
) {
const validRequest = signupValidationSchema.parse(request);
const remoteIp = await getRemoteIp();
const isCapchaValid = await fetchPostverifyTurnstile(
validRequest.token,
remoteIp
);
if (!isCapchaValid) throw new Error('Failed to verify captcha');
const data = await fetchPostSignin(validRequest);
return data;
}
@@ -1,36 +0,0 @@
import { LogoSimple } from '@/app/_components/logo';
import { Metadata } from 'next';
import Link from 'next/link';
import { SignupForm } from './_components/signup-form';
export const metadata: Metadata = {
title: 'IMPHNEN - Signup',
};
export default function Page() {
return (
<>
<div className="flex flex-col items-center gap-4">
<LogoSimple />
<p className="text-muted-foreground text-center text-sm sm:text-base">
Buat akunmu sekarang
</p>
</div>
<SignupForm />
<div className="w-full space-y-4">
<p className="text-muted-foreground px-4 text-center text-xs leading-5 text-balance sm:text-sm">
Sudah punya akun?{' '}
<Link
href="/signin"
className="font-bold hover:underline hover:underline-offset-4 transition-colors text-primary-500"
>
Masuk
</Link>
</p>
</div>
</>
);
}
@@ -1,31 +0,0 @@
'use server';
import { fetcher } from '@/lib/fetcher';
import { getRemoteIp } from '@/lib/headers';
import { fetchPostverifyTurnstile } from '../../_http/fetch-post-verify-turnstile';
import {
resendOTPValidationSchema,
type ResendOTPValidationType,
} from '../_validation/resend-otp-validation';
export async function resendOTPAction(request: ResendOTPValidationType) {
const validRequest = resendOTPValidationSchema.parse(request);
const remoteIp = await getRemoteIp();
const isCapchaValid = await fetchPostverifyTurnstile(
validRequest.token,
remoteIp
);
if (!isCapchaValid) throw new Error('Failed to verify captcha');
const { data, error } = await fetcher.POST('/v1/auth/send-otp', {
body: {
email: validRequest.email,
},
});
if (error) throw new Error(error.message);
return data;
}
@@ -1,74 +0,0 @@
'use client';
import {
Button,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@components';
import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile';
import { useRef } from 'react';
import { useFormResendOTP } from '../_hooks/use-form-resend-otp';
import { usePostResendOTP } from '../_hooks/use-post-resend-otp';
import { ResendOTPValidationType } from '../_validation/resend-otp-validation';
export function ResendOTPForm() {
const ref = useRef<TurnstileInstance | null>(null);
const form = useFormResendOTP();
const { mutate, isPending, error } = usePostResendOTP(form);
const onSubmit = (values: ResendOTPValidationType) => {
mutate(values);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{error && (
<div className="p-2 text-xs bg-red-50 border border-red-200 text-red-800 rounded-sm">
{(error as Error).message}
</div>
)}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="emailmu@mail.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Turnstile
ref={ref}
siteKey={String(process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY)}
onSuccess={(token) => form.setValue('token', token)}
options={{
theme: 'light',
size: 'flexible',
language: 'id',
}}
/>
<Button
type="submit"
className="w-full"
disabled={isPending || !form.watch('token')}
>
Kirim Ulang Kode OTP
</Button>
</form>
</Form>
);
}

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