Compare commits

..
255 changed files with 9126 additions and 10124 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
+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:
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>.
3. **Dimentorin** - Application for <a href="https://dimentorin.imphnen.dev/" target="_blank">Mentoring Service</a>.
4. **Landing Page** - Application for <a href="https://imphnen.dev/" target="_blank">Landing Page</a>.
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.
## How to install
@@ -25,15 +24,6 @@ This repository is a **monorepo** for all frontend services of IMPHNEN. The mono
## 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
Use the following commands to run in development mode:
@@ -50,10 +40,6 @@ Use the following commands to run in development mode:
```sh
npm run dimentorin:dev
```
- **Landing Page**:
```sh
npm run landing:dev
```
### Build
@@ -71,10 +57,6 @@ Use the following commands to build the applications:
```sh
npm run dimentorin:build
```
- **Landing Page**:
```sh
npm run landing:build
```
### Production
@@ -92,22 +74,19 @@ Use the following commands to run the applications in production mode:
```sh
npm run dimentorin:prod
```
- **Landing Page**:
```sh
npm run landing:prod
```
### 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**
This command starts Storybook in development mode, allowing you to view and test UI components interactively.
```sh
npm run ui:storybook
```
- **Run Unit Test**
-1
View File
@@ -1 +0,0 @@
VITE_API_URL=
@@ -2,19 +2,24 @@ import { useForm } from 'react-hook-form';
import {
authLoginSchema,
TLoginRequest,
usePostLogin,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useSession } from '@imphnen-frontend-service/utils';
import { toast } from 'sonner';
export const useLogin = () => {
const postLogin = usePostLogin();
const form = useForm<TLoginRequest>({
resolver: zodResolver(authLoginSchema),
mode: 'all',
});
const { signIn } = useSession();
const onSubmit = form.handleSubmit((data) => signIn(data));
const onSubmit = form.handleSubmit((data) => {
postLogin.mutate(data, {
onSuccess: () => toast.success("Login sukses"),
onError: (error) => toast.error(error.message),
});
});
return {
form,
@@ -18,7 +18,7 @@ import {
RowSelectionState,
} from '@tanstack/react-table';
import ModalEditAccount from './_components/modal-edit-account';
import { useQueryState } from '@imphnen-frontend-service/utils';
import { useQueryState } from '../../hook/use-query-state';
interface Account {
id: number;
@@ -166,7 +166,7 @@ export const Components: FC = (): ReactElement => {
</Button>
{showFilter && (
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
<Filter onClose={() => setShowFilter(false)} options={[]} />
<Filter onClose={() => setShowFilter(false)} />
</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 ModalEditItem from './_components/modal-edit-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 => {
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"
className="w-full"
/>
<Button
disabled={
form.formState.isSubmitting ||
form.formState.isValidating ||
!form.formState.isValid
}
type="submit"
size="md"
className="w-full"
>
<Button type="submit" size="md" className="w-full">
Login
</Button>
</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 {
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');
// };
import { LoaderFunctionArgs } from 'react-router-dom';
export const middleware = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
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 (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';
}
}
if (pathname) return null;
return null;
};
-1
View File
@@ -1 +0,0 @@
VITE_API_URL=
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

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,154 +0,0 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import { FC, useRef } from 'react';
import { motion, useInView, Variants } from 'framer-motion';
export const CTASection: FC = () => {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}
const scaleVariant: Variants = {
hidden: { scale: 0 },
visible: {
scale: 1,
transition: {
duration: 0.5,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
const contentVariant: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
return (
<section ref={ref} className="w-full mx-auto px-8 py-4 md:px-[60px] md:py-8 lg:px-20">
<motion.div
className="relative max-w-7xl mx-auto bg-white rounded-lg border-2 border-primary-200 py-20 overflow-hidden md:rounded-3xl lg:py-24"
variants={containerVariants}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
>
{/* Background gradients and shapes */}
<div className="absolute inset-0">
<motion.div
className={cn("absolute top-8 -left-14 size-28 bg-gradient-to-tl from-primary-400 to-primary-300 rounded-full",
"md:size-52 md:-top-20 md:-left-10 lg:size-72 lg:-top-16 lg:-left-14"
)}
variants={scaleVariant}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
/>
<motion.div
className={cn(
"absolute top-24 -right-14 size-[89px] bg-gradient-to-b from-primary-400 to-primary-300 rounded-full blur-[1.8px]",
"md:size-[182px] md:-right-24 md:blur-[2px] lg:size-[392px] lg:top-16 lg:-right-52 lg:blur-[4px]"
)}
variants={scaleVariant}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
/>
<motion.div
variants={scaleVariant}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
className={cn(
"absolute bottom-10 -left-[88px] size-28 bg-primary-200 rounded-full flex justify-center items-center",
"md:size-56 md:-bottom-28 md:-left-28 lg:size-[274px] lg:-bottom-24 lg:-left-24"
)}
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: isInView ? 1 : 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
className="size-[72px] bg-primary-50 rounded-full md:size-40 lg:size-[200px]"
/>
</motion.div>
<div
className={cn(
"absolute inset-x-0 bottom-0 h-1/4 bg-gradient-to-t from-primary-400 via-primary-200 opacity-90",
"md:h-[22%] md:opacity-85 lg:h-1/4"
)}
/>
</div>
<motion.img
src="/logos/logo.svg"
alt="IMPHNEN Logo"
className="relative w-28 mx-auto mb-8 md:w-[186px]"
variants={contentVariant}
/>
<div className="relative mb-8 max-w-52 mx-auto md:max-w-[446px] lg:max-w-[688px]">
<motion.h1
className={cn(
"mb-3 text-xl text-center font-semibold leading-tight text-primary-500",
"md:text-4xl md:mb-2 lg:text-5xl lg:font-bold"
)}
variants={contentVariant}
>
Start Your IT Journey Now!
</motion.h1>
<motion.p
className={cn(
"mb-6 text-[15px] font-medium text-center leading-tight text-primary-400",
"md:text-2xl md:mb-8 lg:text-[29px]"
)}
variants={contentVariant}
>
Jangan biarkan progress mu hanya jadi side story!
</motion.p>
<motion.p
className={cn(
"text-xs font-semibold text-center leading-tight text-primary-400",
"md:text-[19px] lg:text-[23px] lg:max-w-[628px] lg:mx-auto"
)}
variants={contentVariant}
>
Dapatkan mentor terbaik dan gunakan AI untuk belajar lebih cepat. Waktunya jadi protagonist dalam perjalanan IT-mu!
</motion.p>
</div>
<motion.div
className="relative max-w-52 mx-auto space-y-3 md:max-w-full md:flex md:gap-x-6 md:justify-center md:space-y-0 md:items-center"
variants={contentVariant}
>
<Button
type="button"
className="w-full h-auto py-2 text-[10px] font-medium md:w-max md:text-sm md:px-2.5 md:font-semibold lg:text-base"
>
Mulai Belajar Dengan AI
</Button>
<Button
type="button"
variant="secondary"
className="w-full h-auto py-2 text-[10px] font-medium md:w-max md:text-sm md:px-2.5 md:font-semibold lg:text-base"
>
Temukan Mentor
</Button>
</motion.div>
</motion.div>
</section>
)
}
@@ -1,114 +0,0 @@
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { Accordion, AccordionOptions } from '@imphnen-frontend-service/ui/molecules'
import { FC, useRef } from 'react'
import { motion, useInView, Variants } from 'framer-motion'
import { cn, For } from '@imphnen-frontend-service/utils'
const FAQ_DATA: AccordionOptions[] = [
{ title: 'Apakah saya bisa memilih mentor sendiri?', description: 'Tentu! Kamu bisa memilih mentor yang sesuai dengan kebutuhan dan level skill kamu.' },
{ title: 'Apakah mentoring ini cocok untuk pemula?', description: 'Yap! Baik newbie maupun seasoned dev bisa menemukan mentor yang pas untuk mempercepat progres belajarnya.' },
{ title: 'Apakah ini seperti sekolah coding biasa?', description: 'Nope! Ini lebih dari sekadar kursus-kursus AI guide dan mentor berpengalaman, kamu mendapatkan pengalaman belajar yang jauh lebih personal dan efektif.' },
]
export const FAQSection: FC = () => {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}
const childVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
return (
<section ref={ref} className="relative mx-auto py-4 px-8 overflow-x-clip md:py-8 md:px-[60px] lg:px-20 md:pb-8">
{/* Background shapes */}
<div className="absolute inset-0">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: isInView ? 1 : 0 }}
transition={{ duration: 0.5 }}
className={cn(
"absolute -top-10 -right-14 size-60 bg-primary-200 rounded-full justify-center items-center hidden",
"md:flex lg:size-[344px] lg:top-48 lg:-right-[88px]"
)}
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: isInView ? 1 : 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
className="size-[168px] bg-primary-50 rounded-full lg:size-64"
/>
</motion.div>
</div>
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
className="relative mb-6 flex justify-center md: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"
>
Frequently Ask Question
</Button>
</motion.div>
<div
className={cn(
"relative grid max-w-7xl mx-auto gap-y-7 divide-y-2 divide-primary-200",
"md:grid-cols-11 md:gap-x-10 md:divide-y-0 md:divide-x-2 lg:grid-cols-7 lg:gap-x-[72px]"
)}
>
<motion.h1
className={cn(
"pb-7 text-2xl font-semibold text-primary-500 text-center leading-none",
"md:pe-10 md:pb-0 md:text-start md:col-span-5 lg:text-4xl lg:col-span-3 lg:pe-[72px]"
)}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
Console log for new developers
</motion.h1>
<motion.div
className="flex flex-col gap-4 md:col-span-6 lg:col-span-4"
variants={containerVariants}
animate={isInView ? "visible" : "hidden"}
>
<For data={FAQ_DATA}>
{(props) => (
<motion.div key={props.title} variants={childVariants}>
<Accordion {...props} />
</motion.div>
)}
</For>
</motion.div>
</div>
</section>
)
}
@@ -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,113 +0,0 @@
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { For } from '@imphnen-frontend-service/utils'
import { FC, useRef } from 'react'
import { motion, useInView, Variants } from 'framer-motion'
import { StarFilled } from '@ant-design/icons'
export const TestimonialSection: FC = () => {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}
const childVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
return (
<section ref={ref} className="mx-auto py-4 md:py-8 xl:px-20">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
className="flex justify-center"
>
<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"
>
Testimonial
</Button>
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
className="my-6 text-lg font-semibold mx-auto text-center text-primary-500 md:text-2xl lg:my-10 lg:text-4xl"
>
Words from our dellow devs
</motion.h1>
<div className="scrollbar-hide w-full mx-auto max-w-7xl px-8 overflow-auto md:px-[60px] xl:px-0">
<motion.div
className="mb-2 flex gap-x-5 min-w-max w-auto md:gap-x-6 xl:min-w-full xl:max-w-7xl xl:grid xl:grid-cols-2"
variants={containerVariants}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
>
<For data={Array.from({ length: 2 })}>
{(_, index) => (
<motion.div
key={index}
variants={childVariants}
className="flex flex-col gap-6 p-6 w-52 bg-white rounded-xl shadow-md md:w-xl md:flex-row md:gap-8 xl:w-full"
>
<div className="w-full aspect-[8/7] rounded-lg overflow-hidden md:w-40 md:aspect-[7/6] lg:aspect-square">
<img
src="/image/testimonial.webp"
alt="Testimonial"
className="w-full object-cover"
/>
</div>
<div className="flex-1">
<h3 className="mb-3 font-semibold text-primary-500 md:text-lg lg:text-2xl">
Riko, Junior Web Developer
</h3>
<p className="mb-6 text-[10px] text-neutral-500 font-medium md:text-sm lg:text-base">
Rasanya seperti punya AI waifu yang ngajarin ngoding! Mentoringnya juga super insightful. Thanks, Dimentorin!
</p>
<motion.div className="w-max" whileHover={{ scale: 1.1, rotate: -3 }}>
<Button
type="button"
size="sm"
variant="bordered"
className="bg-primary-50 flex items-center gap-x-2 h-auto px-2 py-1 hover:bg-primary-50"
>
<StarFilled className="md:text-lg" />
<span>
<span className="text-sm font-medium md:text-lg md:font-semibold lg:text-xl">4.8</span>
<span className="text-[10px] text-primary-300 md:text-sm md:font-medium lg:text-base">/5.0</span>
</span>
</Button>
</motion.div>
</div>
</motion.div>
)}
</For>
</motion.div>
</div>
</section>
)
}
@@ -1,84 +0,0 @@
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { For } from "@imphnen-frontend-service/utils";
import { FC, useRef } from "react";
import { motion, useInView, Variants } from 'framer-motion'
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 = () => {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}
const childVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
return (
<section ref={ref} 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 }}
viewport={{ once: true }}
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>
<motion.div
className="grid gap-6 max-w-7xl mx-auto md:grid-cols-3"
variants={containerVariants}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
>
<For data={OFFERS}>
{(offer) => (
<motion.div
key={offer.title}
className="relative aspect-[16/10] overflow-hidden rounded-lg md:aspect-[24/35] lg:aspect-[6/7]"
variants={childVariants}
>
<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>
</motion.div>
</section>
)
}
@@ -1,20 +0,0 @@
import { FC, ReactElement } from 'react';
import { HeroSection } from './_components/hero-section';
import { WhatWeOfferSection } from './_components/what-we-offer-section';
import { TestimonialSection } from './_components/testimonial-section';
import { FAQSection } from './_components/faq-section';
import { CTASection } from './_components/cta-section';
export const Components: FC = (): ReactElement => {
return (
<>
<HeroSection />
<WhatWeOfferSection />
<TestimonialSection />
<FAQSection />
<CTASection />
</>
);
};
export default Components;
@@ -1,157 +0,0 @@
import { DiscordOutlined, GithubOutlined, InstagramOutlined } from '@ant-design/icons';
import { cn, For } from '@imphnen-frontend-service/utils';
import React, { FC, useRef } from 'react';
import { Link } from 'react-router-dom';
import { motion, useInView, Variants } from 'framer-motion';
import { SteamIcon } from '../../_components/icons';
const PAGES: { label: string; href: string }[] = [
{ label: "Home", href: "/" },
{ label: 'Mentoring', href: '/mentoring' },
{ label: 'Resources', href: '/resources' },
{ label: 'Articles', href: '/articles' },
]
const COMMUNITY: { label: string; href: string; icon: React.ReactNode }[] = [
{ label: 'Discord', href: 'https://discord.gg/imphnen', icon: <DiscordOutlined /> },
{ label: 'Steam', href: 'https://steamcommunity.com/groups/IMPHNEN', icon: <SteamIcon /> },
{ label: 'Github', href: 'https://github.com/IMPHNEN', icon: <GithubOutlined /> },
{ label: 'Instagram', href: 'https://www.instagram.com/imphnen.dev', icon: <InstagramOutlined /> },
]
export const Footer: FC = () => {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
}
const childVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
const mascotVariants: Variants = {
hidden: { opacity: 0, y: 20, rotate: -12 },
visible: {
opacity: 1,
y: 0,
rotate: 0,
transition: {
duration: 0.5,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
const mascotTextVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.4,
delay: 0.4,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
return (
<footer ref={ref} className="w-full mx-auto px-8 py-4 md:px-[60px] md:pt-8 md:pb-16 lg:px-20 lg:pb-20">
<motion.div
className={cn(
"relative max-w-7xl mx-auto px-10 py-8 bg-primary-500 text-primary-50 grid gap-10 rounded-lg",
"lg:grid-cols-3 lg:rounded-xl"
)}
variants={containerVariants}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
>
{/* Mascot */}
<motion.div
className="hidden absolute bottom-0 right-0 size-40 md:block lg:size-72"
variants={mascotVariants}
>
<motion.img
src="/image/mascot-character.webp"
alt="IMPHNEN Mascot"
className="w-full h-auto absolute inset-x-0 bottom-0"
/>
<motion.img
src="/image/mascot-text.webp"
alt="IMPHNEN Mascot"
className="w-4/6 absolute top-0 left-0"
variants={mascotTextVariants}
/>
</motion.div>
<div className="relative">
<div className="text-center lg:text-start lg:mb-16">
<motion.img
src="/logos/logo.svg"
alt="IMPHNEN Logo"
className="w-[84px] mb-6 mx-auto md:w-32 lg:w-40 lg:ms-0"
variants={childVariants}
/>
<motion.h4 className="mb-2 font-semibold text-[15px] md:mb-2.5 md:text-xl" variants={childVariants}>
Dimentorin by IMPHNEN
</motion.h4>
<motion.p className="font-medium text-xs md:text-[15px] md:max-w-[232px] md:mx-auto lg:ms-0" variants={childVariants}>
Karena Belajar IT Itu Harusnya Se-Seru Anime Favoritmu!
</motion.p>
</div>
<motion.p className="relative hidden text-xs font-medium text-[15px] lg:block" variants={childVariants}>
&copy; IMPHNEN {new Date().getFullYear()} All Rights Reserved
</motion.p>
</div>
<motion.div className="relative grid gap-10 md:flex md:justify-center lg:gap-x-20" variants={childVariants}>
<div className="text-center text-xs md:text-[15px] lg:text-start">
<h4 className="mb-5 font-medium md:mb-7 lg:text-xl">Pages</h4>
<ul className="font-semibold space-y-5">
<For data={PAGES}>
{({ label, href }) => (
<li key={label}>
<Link to={href}>{label}</Link>
</li>
)}
</For>
</ul>
</div>
<div className="text-center text-xs md:text-[15px] lg:text-start">
<h4 className="mb-5 font-medium md:mb-7 lg:text-xl">Join Our Community</h4>
<ul className="font-semibold space-y-5">
<For data={COMMUNITY}>
{({ label, href, icon }) => (
<li key={label}>
<Link to={href} target="_blank" className="flex items-center justify-center gap-x-3 lg:justify-start">
<span className="text-primary-900">{icon}</span>
<span>{label}</span>
</Link>
</li>
)}
</For>
</ul>
</div>
</motion.div>
<motion.p className="relative text-center text-xs font-medium md:text-[15px] lg:hidden" variants={childVariants}>
&copy; IMPHNEN {new Date().getFullYear()} All Rights Reserved
</motion.p>
</motion.div>
</footer>
)
}
@@ -1,93 +0,0 @@
import { CloseOutlined, MenuOutlined } from "@ant-design/icons";
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { cn, For, Show } from "@imphnen-frontend-service/utils";
import { motion, useMotionValueEvent, useScroll, Variants } from "framer-motion";
import { FC, useState } from "react";
import { Link, useLocation } from "react-router-dom";
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 { scrollY } = useScroll()
const [expandMenu, setExpandMenu] = useState(false);
const [show, setShow] = useState(true)
const headerVariants: Variants = {
hidden: { opacity: 0, y: -100 },
show: { opacity: 1, y: 0 },
};
useMotionValueEvent(scrollY, "change", (latest) => {
const prev = scrollY.getPrevious() || 0
if (latest > prev && latest > 100) setShow(false)
else setShow(true)
})
return (
<div className="w-full px-8 pt-8 top-0 z-50 md:px-[60px] md:pt-[60px] lg:px-20 sticky overflow-hidden">
<motion.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"
variants={headerVariants}
animate={show ? "show" : "hidden"}
transition={{ duration: 0.5, ease: "easeInOut" }}
>
<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>
</motion.header>
</div>
);
}
@@ -1,15 +0,0 @@
import { Outlet } from "react-router-dom";
import { Header } from "./_components/header";
import { Footer } from "./_components/footer";
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>
<Footer />
</div>
);
}
@@ -1 +0,0 @@
export * from './steam';
@@ -1,9 +0,0 @@
import { FC } from "react"
export const SteamIcon: FC<React.SVGProps<SVGSVGElement>> = ({ ...props }) => {
return (
<svg width="15" height="15" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M9 0.875C4.52 0.875 0.875 4.52 0.875 9C0.875 13.48 4.52 17.125 9 17.125C13.48 17.125 17.125 13.48 17.125 9C17.125 4.52 13.48 0.875 9 0.875ZM9 2.125C12.7906 2.125 15.875 5.20937 15.875 9C15.875 12.7906 12.7906 15.875 9 15.875C5.80687 15.875 3.12438 13.6838 2.35438 10.7288L4.64563 11.71C4.69576 12.167 4.91262 12.5896 5.25474 12.8968C5.59686 13.2039 6.04021 13.3742 6.5 13.375C6.99728 13.375 7.47419 13.1775 7.82583 12.8258C8.17746 12.4742 8.375 11.9973 8.375 11.5C8.375 11.48 8.36937 11.4606 8.36875 11.4412L10.98 9.615C11.6235 9.5895 12.2321 9.31606 12.6786 8.85193C13.125 8.38781 13.3745 7.76898 13.375 7.125C13.375 6.46196 13.1116 5.82607 12.6428 5.35723C12.1739 4.88839 11.538 4.625 10.875 4.625C10.2312 4.62579 9.61273 4.8755 9.14886 5.32188C8.685 5.76827 8.41174 6.37674 8.38625 7.02L6.55875 9.63125C6.53875 9.63062 6.52 9.625 6.5 9.625C6.08563 9.625 5.70625 9.76375 5.395 9.99125L2.14562 8.59813C2.35625 4.995 5.345 2.125 9 2.125ZM10.875 5.25C11.3723 5.25 11.8492 5.44754 12.2008 5.79917C12.5525 6.15081 12.75 6.62772 12.75 7.125C12.75 7.62228 12.5525 8.09919 12.2008 8.45083C11.8492 8.80246 11.3723 9 10.875 9C10.3777 9 9.90081 8.80246 9.54917 8.45083C9.19754 8.09919 9 7.62228 9 7.125C9 6.62772 9.19754 6.15081 9.54917 5.79917C9.90081 5.44754 10.3777 5.25 10.875 5.25ZM10.875 5.875C10.5435 5.875 10.2255 6.0067 9.99112 6.24112C9.7567 6.47554 9.625 6.79348 9.625 7.125C9.625 7.45652 9.7567 7.77446 9.99112 8.00888C10.2255 8.2433 10.5435 8.375 10.875 8.375C11.2065 8.375 11.5245 8.2433 11.7589 8.00888C11.9933 7.77446 12.125 7.45652 12.125 7.125C12.125 6.79348 11.9933 6.47554 11.7589 6.24112C11.5245 6.0067 11.2065 5.875 10.875 5.875ZM6.5 10.25C6.72467 10.2495 6.94532 10.3095 7.13875 10.4238C7.33217 10.5381 7.49122 10.7024 7.59915 10.8995C7.70709 11.0965 7.75992 11.319 7.75208 11.5436C7.74425 11.7681 7.67605 11.9864 7.55466 12.1754C7.43326 12.3645 7.26315 12.5173 7.06224 12.6178C6.86132 12.7184 6.63702 12.7629 6.41293 12.7468C6.18884 12.7306 5.97325 12.6543 5.78884 12.526C5.60443 12.3977 5.45801 12.222 5.365 12.0175L5.85437 12.2275C6.08293 12.325 6.34082 12.328 6.57159 12.2359C6.80235 12.1437 6.98719 11.9638 7.08563 11.7356C7.18341 11.507 7.18639 11.249 7.09391 11.0182C7.00144 10.7874 6.82107 10.6028 6.5925 10.505L6.1375 10.31C6.25313 10.2744 6.37313 10.25 6.5 10.25Z" fill="currentColor"/>
</svg>
)
}
+9 -5
View File
@@ -2,23 +2,27 @@ import { useForm } from 'react-hook-form';
import {
authLoginSchema,
TLoginRequest,
usePostLogin,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useSession } from '@imphnen-frontend-service/utils';
import { toast } from 'sonner';
export const useLogin = () => {
const postLogin = usePostLogin();
const form = useForm<TLoginRequest>({
resolver: zodResolver(authLoginSchema),
mode: 'all',
});
const { signIn, isLoading } = useSession();
const onSubmit = form.handleSubmit((data) => signIn(data));
const onSubmit = form.handleSubmit((data) => {
postLogin.mutate(data, {
onSuccess: () => toast.success("Login sukses"),
onError: (error) => toast.error(error.message),
});
});
return {
form,
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';
export const Components: FC = (): ReactElement => {
const { form, onSubmit, isLoading } = useLogin();
const { form, onSubmit } = useLogin();
return (
<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 ?
</a>
</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>
<div className="flex my-3 gap-3 justify-center">
@@ -74,13 +74,6 @@ export const Components: FC = (): ReactElement => {
<ArrowLeftOutlined />
Kembali Ke Homepage
</Button>
<Button
className='xl:hidden w-full gap-3'
variant='secondary'
>
<ArrowLeftOutlined />
Kembali Ke Homepage
</Button>
</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 { ControlledInputField, RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { useRegisterHook } from '../../_hooks/use-register';
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { InputField } from '@imphnen-frontend-service/ui/molecules';
export const Components: FC = (): ReactElement => {
const { form, onSubmit, isLoading } = useRegisterHook();
return (
<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 />
<div className="xl:border-2 xl:border-primary-500/50 xl:w-[726px] rounded-lg py-[32px] px-[48px] flex justify-center">
<div className="xl:w-[714px]">
<Button
className='xl:hidden gap-3'
variant='secondary'
>
<ArrowLeftOutlined />
Login
</Button>
<div className="border-2 border-primary-500/50 w-[696px] rounded-lg py-[32px] px-[48px] flex justify-center">
<div className="w-[704px]">
<h3 className="mt-5 text-3xl font-semibold text-primary-500">
Register
</h3>
<h5 className="text-primary-500 font-medium">
Yosha~! Saatnya Bergabung dengan Dimentorin
</h5>
<form onSubmit={onSubmit}>
<div className='my-7'>
<ControlledInputField
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"
<div className="grid grid-flow-row-dense my-7 grid-cols-2 gap-2">
<InputField
label="First Name"
size="lg"
type="password"
placeholder="Buat password sekeren jurus ultimate-mu!"
control={form.control}
name={'password'}
placeholder="Nama Depan"
/>
<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>
<ControlledInputField
label="Repeat Password"
className="w-full"
<InputField
label="Last Name"
size="lg"
type="password"
placeholder="Ulangi password-mu, Senpai~!"
control={form.control}
name={"confirm_password"}
className="w-full"
placeholder="Nama Belakang"
/>
<Button className="xl:w-full mt-2" disabled={(!form.formState.isValid || isLoading)}>Linked Start !!!</Button>
</form>
<InputField
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>
@@ -1,21 +1,15 @@
import { FC, ReactElement, useEffect } from 'react';
import { FC, ReactElement } from 'react';
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { ArrowRightOutlined } from '@ant-design/icons';
export const Components: FC = (): ReactElement => {
useEffect(() => {
setTimeout(() => {
document.location.href = "/auth/login"
}, 10000)
}, [])
return (
<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 />
<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="xl:w-[704px] flex flex-col justify-center items-center">
<div className="border-2 border-primary-500/50 w-[696px] rounded-lg py-[32px] px-[48px] flex justify-center bg-primary-50">
<div className="w-[704px] flex flex-col justify-center items-center">
<h2 className="text-4xl text-primary-500 font-semibold">
Register Successfull
</h2>
@@ -47,12 +41,10 @@ export const Components: FC = (): ReactElement => {
<h4 className="mt-10 text-primary-500 font-medium text-xl">
Kamu akan memasuki isekai dalam 10 dtk
</h4>
<a href="/auth/login">
<Button className="gap-3 mt-10" size="lg">
Masuk Isekai
<ArrowRightOutlined />
</Button>
</a>
<Button className="gap-3 mt-10" size="lg">
Masuk Isekai
<ArrowRightOutlined />
</Button>
</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;
-6
View File
@@ -119,9 +119,3 @@
font-weight: 400;
}
}
@layer utilities {
.scrollbar-hide::-webkit-scrollbar {
@apply hidden;
}
}
+3 -105
View File
@@ -1,113 +1,11 @@
import {
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');
// };
import { LoaderFunctionArgs } from 'react-router-dom';
export const middleware = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
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 (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';
}
}
if (pathname) 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 { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { useLogin } from '../../_hooks/use-login';
import ModalFormVerifyEmail from './modal-form-verify-email';
interface IModalFormLogin {
isOpen: boolean;
@@ -17,87 +16,61 @@ const ModalFormLogin = ({
onForgotPassword,
setIsOpenRegisterModal,
}: IModalFormLogin) => {
const {
form,
onSubmit,
isLoading,
showVerifyModal,
verifyForm,
onVerifySubmit,
closeVerifyModal,
isVerifying,
emailToVerify,
} = useLogin();
const { form, onSubmit } = useLogin();
return (
<>
<Modal
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
isOpen={isOpen && !showVerifyModal}
onClose={onClose}
>
<Modal.Header className="space-y-4">
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
<h1 className="text-primary-500 text-p1 text-center font-semibold">
Login
</h1>
</Modal.Header>
<Modal.Content>
<form className="space-y-4" onSubmit={onSubmit}>
<ControlledInputField
control={form.control}
label="Email"
placeholder="Masukkan Email"
type="email"
name="email"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Password"
placeholder="Masukkan Password"
type="password"
name="password"
size="lg"
className="w-full"
/>
<h1 className="text-end text-primary-500 text-xl font-medium">
<Button variant="text" type="button" onClick={onForgotPassword}>
Lupa Password?
</Button>
</h1>
<Button
type="submit"
size="md"
className="w-full"
disabled={isLoading}
>
{isLoading ? 'Loading...' : 'Login'}
<Modal
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
isOpen={isOpen}
onClose={onClose}
>
<Modal.Header className="space-y-4">
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
<h1 className="text-primary-500 text-p1 text-center font-semibold">
Login
</h1>
</Modal.Header>
<Modal.Content>
<form className="space-y-4" onSubmit={onSubmit}>
<ControlledInputField
control={form.control}
label="Email"
placeholder="Masukkan Email"
type="email"
name="email"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Password"
placeholder="Masukkan Password"
type="password"
name="password"
size="lg"
className="w-full"
/>
<h1 className="text-end text-primary-500 text-xl font-medium">
<Button variant="text" onClick={onForgotPassword}>
Lupa Password?
</Button>
<div className="flex justify-center gap-2 pt-2.5 font-medium">
<p className="text-neutral-500">Belum punya akun?</p>
<Button
variant="text"
className="text-primary-500 hover:text-primary-600 m-0 p-0"
onClick={() => setIsOpenRegisterModal(true)}
>
Daftar
</Button>
</div>
</form>
</Modal.Content>
</Modal>
<ModalFormVerifyEmail
isOpen={showVerifyModal}
onClose={closeVerifyModal}
verifyForm={verifyForm}
onVerifySubmit={onVerifySubmit}
isVerifying={isVerifying}
email={emailToVerify}
/>
</>
</h1>
<Button type="submit" size="md" className="w-full">
Login
</Button>
<div className="flex justify-center gap-2 pt-2.5 font-medium">
<p className="text-neutral-500">Belum punya akun?</p>
<Button
variant="text"
className="text-primary-500 hover:text-primary-600 m-0 p-0"
onClick={() => setIsOpenRegisterModal(true)}
>
Daftar
</Button>
</div>
</form>
</Modal.Content>
</Modal>
);
};
@@ -1,8 +1,11 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { useQueryState } from '@imphnen-frontend-service/utils';
import { useRegister } from '../../_hooks/use-register';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import 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 {
isOpen: boolean;
@@ -10,100 +13,168 @@ interface IModalFormRegisterProps {
}
const ModalFormRegister = ({ isOpen, onClose }: IModalFormRegisterProps) => {
const { form, onSubmit, isStepOneValid } = useRegister();
const {
form,
verifyForm,
onSubmit,
onVerifySubmit,
showVerifyModal,
closeVerifyModal,
isVerifying,
isRegistering,
registeredEmail,
} = useRegister();
step: currentStep,
nextStep,
prevStep,
resetStep,
} = useQueryState('registerStep', {
defaultValue: 1,
maxValue: 2,
minValue: 1,
});
useEffect(() => {
if (!isStepOneValid && currentStep === 2) {
prevStep();
}
}, [isStepOneValid, currentStep, prevStep]);
return (
<>
<Modal
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
isOpen={isOpen && !showVerifyModal}
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">
Register
</h1>
</Modal.Header>
<Modal.Content>
<form onSubmit={onSubmit} className="space-y-6">
<ControlledInputField
control={form.control}
name="fullname"
label="Nama Lengkap"
placeholder="Masukkan Nama Lengkap"
type="text"
size="lg"
className="w-full"
<Modal
className="py-[45px] min-w-[400px] lg:min-w-[455px] px-7"
isOpen={isOpen}
onClose={() => {
onClose();
resetStep();
}}
>
<Modal.Header className="space-y-4">
<img src="/logos/logo.svg" alt="" className="h-[70px] w-auto" />
<h1 className="text-primary-500 text-p1 text-center font-semibold">
Register
</h1>
<h2 className="text-neutral-500 text-lg text-center">
{currentStep === 1 ? 'Info Akun' : 'Informasi Pengiriman Hadiah'}
</h2>
</Modal.Header>
<Modal.Content>
<form onSubmit={onSubmit} className="space-y-6">
{currentStep === 1 && (
<StepOne
form={form}
nextStep={nextStep}
isStepOneValid={isStepOneValid}
/>
<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"
/>
<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>
)}
{currentStep === 2 && <StepTwo form={form} prevStep={prevStep} />}
</form>
</Modal.Content>
</Modal>
);
};
<ModalFormVerifyEmail
isOpen={showVerifyModal}
onClose={closeVerifyModal}
verifyForm={verifyForm}
onVerifySubmit={onVerifySubmit}
isVerifying={isVerifying}
email={registeredEmail}
interface IStepOneProps {
form: UseFormReturn<TRegisterRequest, any, TRegisterRequest>;
nextStep: () => void;
isStepOneValid: boolean;
}
const StepOne = ({ form, nextStep, isStepOneValid }: IStepOneProps) => {
return (
<>
<ControlledInputField
control={form.control}
name="fullname"
label="Nama Lengkap"
placeholder="Masukkan Nama Lengkap"
type="text"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Email"
placeholder="Masukkan Email Anda"
type="email"
name="email"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="password"
label="Password"
placeholder="Masukkan Password"
type="password"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="confirm_password"
label="Ulangi Password"
placeholder="Masukkan Ulang Password"
type="password"
size="lg"
className="w-full"
/>
<Button
size="md"
className="w-full"
onClick={nextStep}
disabled={!isStepOneValid}
>
Selanjutnya
</Button>
</>
);
};
interface IStepTwoProps {
form: UseFormReturn<TRegisterRequest, any, TRegisterRequest>;
prevStep: () => void;
}
const StepTwo = ({ form, prevStep }: IStepTwoProps) => (
<>
<ControlledInputField
control={form.control}
name="phone_number"
label="Nomor Telepon"
placeholder="Masukkan Nomor Telepon Aktif"
type="text"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="referral_code"
label="Kode Referral"
placeholder="Masukkan Kode Referral"
type="text"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="referred_by"
label="Referral By"
placeholder="Masukkan Referral By"
type="text"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
name="student_type"
label="Tipe Pelajar"
placeholder="Masukkan Tipe Pelajar"
type="text"
size="lg"
className="w-full"
/>
<div className="flex gap-6">
<Button size="md" variant="text" className="w-[40%]" onClick={prevStep}>
Kembali
</Button>
<Button size="md" className="w-full" type="submit">
Gacha Sekarang
</Button>
</div>
</>
);
export default ModalFormRegister;
@@ -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,
} from '@imphnen-frontend-service/service';
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 = () => {
const postLogin = usePostLogin();
const form = useForm<TLoginRequest>({
resolver: zodResolver(authLoginSchema),
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) => {
setLoading(true);
mutate(data, {
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();
}
},
postLogin.mutate(data, {
onSuccess: () => console.log('Success Login'),
});
});
return {
form,
onSubmit,
isLoading: isPending,
showVerifyModal,
verifyForm,
onVerifySubmit,
closeVerifyModal,
isVerifying,
emailToVerify,
};
};
+25 -23
View File
@@ -1,25 +1,17 @@
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 { useVerifyEmail } from './use-verify-email';
import { useEffect, useState } from 'react';
export const useRegister = () => {
const postRegister = usePostRegister();
const {
verifyForm,
onVerifySubmit,
showVerifyModal,
openVerifyModal,
closeVerifyModal,
isVerifying,
emailToVerify,
} = useVerifyEmail();
const form = useForm<TRegisterRequest>({
resolver: zodResolver(authRegisterSchema),
mode: 'all',
@@ -29,28 +21,38 @@ export const useRegister = () => {
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: () => {
toast.success('Registrasi sukses. Silakan verifikasi email Anda.');
openVerifyModal(data.email);
},
onError: (error) => toast.error(error.message || 'Registrasi gagal'),
onSuccess: (data) => toast.success(data.message),
onError: (error) => toast.error(error.message),
});
});
return {
form,
verifyForm,
onSubmit,
onVerifySubmit,
showVerifyModal,
closeVerifyModal,
isVerifying,
isRegistering: postRegister.isPending,
registeredEmail: emailToVerify,
isStepOneValid: stepValid,
};
};
@@ -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="relative h-[150px] md:h-[230px]">
<img
width={400}
height={400}
src="/merch/1.png"
alt="Merch 1"
className="relative top-[3px] md:top-[6px] left-[4px] md:left-[7px] z-10 w-[165px] md:w-[266px]"
/>
<img
width={400}
height={400}
className="absolute top-0 left-0 min-w-[174px] md:min-w-[280px] z-0"
src="/merch/Vector-1.svg"
alt=""
@@ -92,7 +88,7 @@ export const Components: FC = (): ReactElement => {
~ 175k ~
</div>
</div>
<div className="order-2 flex flex-col col-span-4 justify-center items-center mb-8 relative">
<div className="order-2 flex flex-col col-span-4 justify-center items-center mb-8 relative overflow-hidden">
<div className="relative h-[130px] md:h-[230px]">
<img
width={400}
@@ -109,6 +105,8 @@ export const Components: FC = (): ReactElement => {
alt="Merch 3"
/>
<img
width={400}
height={400}
className="absolute hidden lg:block -bottom-[100px] -left-[55px]"
src="/landing-arrow-2.svg"
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(
<StrictMode>
<QueryProvider>
<Toaster position="top-right"/>
<Toaster />
<RouterProvider router={router} />
</QueryProvider>
</StrictMode>
+7 -11
View File
@@ -1,15 +1,11 @@
// import { SessionToken, SessionUser } from '@imphnen-frontend-service/utils';
import { LoaderFunctionArgs } from 'react-router';
// const mappingPublicRoutes = ['/'];
// const mappingRoutePermissions = [
// {
// path: '/',
// permissions: [],
// },
// ];
import { LoaderFunctionArgs } from 'react-router-dom';
export const middleware = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const pathname = url.pathname;
if (pathname) 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 path = require('path');
const { withPayload } = require('@payloadcms/next/withPayload');
/** @type {import('@nx/next/plugins/with-nx').WithNxOptions} */
const nextConfig = {
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: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

@@ -1,86 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { useEffect, useMemo, useState } from 'react';
import {
SiCplusplus,
SiCss3,
SiGo,
SiHtml5,
SiJavascript,
SiPhp,
SiPython,
SiRuby,
SiRust,
SiSwift,
SiTypescript,
} from 'react-icons/si';
export function AnimatedBackground() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
const iconColorMap = useMemo(
() => [
{ Icon: SiJavascript, color: '#F7DF1E' },
{ Icon: SiTypescript, color: '#3178C6' },
{ Icon: SiPython, color: '#3776AB' },
{ Icon: SiCplusplus, color: '#00599C' },
{ Icon: SiRuby, color: '#CC342D' },
{ Icon: SiSwift, color: '#F05138' },
{ Icon: SiRust, color: '#000000' },
{ Icon: SiGo, color: '#00ADD8' },
{ Icon: SiPhp, color: '#777BB4' },
{ Icon: SiHtml5, color: '#E34F26' },
{ Icon: SiCss3, color: '#1572B6' },
],
[]
);
const getRandom = (min: number, max: number) =>
Math.random() * (max - min) + min;
const floatingIcons = useMemo(() => {
if (!isClient) return [];
return Array.from({ length: 40 }).map((_, i) => {
const { Icon, color } = iconColorMap[i % iconColorMap.length];
return (
<motion.div
key={i}
className="pointer-events-none absolute"
style={{
top: `${getRandom(0, 100)}%`,
left: `${getRandom(0, 100)}%`,
fontSize: `${getRandom(16, 32)}px`,
color: color,
opacity: getRandom(0.1, 0.2),
rotate: getRandom(-180, 180),
}}
animate={{
y: [0, getRandom(-100, 100), 0],
x: [0, getRandom(-50, 50), 0],
rotate: getRandom(-180, 180),
}}
transition={{
duration: getRandom(15, 25),
repeat: Infinity,
repeatType: 'loop',
ease: 'easeInOut',
}}
>
<Icon className="h-full w-full" />
</motion.div>
);
});
}, [isClient, iconColorMap]);
if (!isClient) return null;
return (
<div className="fixed inset-0 z-0 overflow-hidden">{floatingIcons}</div>
);
}
@@ -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>
</>
);
}
-25
View File
@@ -1,25 +0,0 @@
import { baiJamjureeFont } from '@/lib/fonts';
import { Card } from '@components';
import { cn } from '@utils';
import { ReactNode } from 'react';
import { AnimatedBackground } from './_components/animated-background';
export default function Layout({ children }: { children: ReactNode }) {
return (
<div
className={cn(
baiJamjureeFont.className,
'bg-background/20 relative flex min-h-[100dvh] items-center justify-center p-4'
)}
>
<AnimatedBackground />
<div className="z-10 w-full">
<Card className="bg-background/90 mx-auto w-full max-w-[360px] p-6 shadow-xl backdrop-blur-lg sm:max-w-md sm:p-8">
<div className="flex flex-col items-center space-y-6">
<div className="w-full space-y-4">{children}</div>
</div>
</Card>
</div>
</div>
);
}

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