Compare commits

..
Author SHA1 Message Date
ArraysID a84e2fc31a fix: remove mistakenly added env from test-build workflow 2025-05-11 05:53:31 +07:00
ArraysID 6e1c561387 Merge branch 'feat/landing-cms' of https://github.com/IMPHNEN/imphnen-frontend-service into feat/landing-cms 2025-05-11 05:36:24 +07:00
ArraysID b39e918b3c fix: remove unused environment variables from test build workflow 2025-05-11 05:32:55 +07:00
ArraysID 0a217341a5 chore: add environment placeholder variables for CMS configuration in test build workflow 2025-05-11 05:32:55 +07:00
ArraysID f1f72402b2 fix: add placeholder to .env.example 2025-05-11 05:32:55 +07:00
ArraysID 3f1b2a2bd7 fix: supress scss deps warning 2025-05-11 05:32:55 +07:00
ArraysID 57b27c6784 feat: wrap logo image in a link to navigate to the homepage 2025-05-11 05:32:55 +07:00
ArraysID f191ffc4b1 feat: update button components to use onClick for external links and improve styling 2025-05-11 05:32:25 +07:00
ArraysID 31e4ffae92 feat: update CallToActionSection label and wrap Discord button in link 2025-05-11 05:29:34 +07:00
ArraysID c9f7ce24ed feat: implement CallToAction section with dynamic data fetching and UI integration 2025-05-11 05:29:01 +07:00
ArraysID d21adebf73 feat: simplify joinTitle rendering in Testimonials section 2025-05-11 05:28:20 +07:00
ArraysID 744d39ea75 feat: implement Testimonials section with dynamic data fetching and UI integration 2025-05-11 05:28:20 +07:00
ArraysID da11ba5894 refactor: reorganize layout structure by moving Header and Footer components to RootLayout 2025-05-11 05:27:19 +07:00
ArraysID 6d29c81dad feat: enhance LearningResourcesSection with primary and secondary button links 2025-05-11 05:26:07 +07:00
ArraysID 4a7135dd17 feat: add Learning Resources section with dynamic data fetching and UI integration 2025-05-11 05:24:31 +07:00
ArraysID 33d750b221 feat: add heroImage field to HeroSection and implement image formatting utility 2025-05-11 05:24:31 +07:00
ArraysID 1f9aa2e6b3 feat: update FeaturesSection to use Iconify icons and adjust icon properties 2025-05-11 05:24:31 +07:00
ArraysID 7722c98c84 feat: replace Link component with Button for improved button styling in Community component 2025-05-11 05:24:31 +07:00
ArraysID 0914361635 feat: update Community component to use Iconify for icons and adjust CommunitiesSection configuration 2025-05-11 05:24:31 +07:00
ArraysID 74aabd1eba feat: add support for s3 bucket for media 2025-05-11 05:24:31 +07:00
ArraysID d1fc7a8fd5 refactor: move global section functions to services directory 2025-05-11 05:24:31 +07:00
ArraysID 009cabfc1c feat: add CommunitiesSection configuration and integrate with Community component 2025-05-11 05:24:31 +07:00
ArraysID a7f7521761 fix: update revalidation interval to 10 seconds 2025-05-11 05:24:31 +07:00
ArraysID 54b7a2f0eb refactor: optimize data fetching by using Promise.all 2025-05-11 05:24:31 +07:00
ArraysID ef042f2f40 feat: add revalidate constant to control page revalidation interval 2025-05-11 05:24:31 +07:00
ArraysID fd35cb9525 feat: add FeaturesSection global configuration and integrate with Features component 2025-05-11 05:24:31 +07:00
ArraysID 18d11fd1c3 feat: implement HeroSection global configuration and integrate with Hero component 2025-05-11 05:24:31 +07:00
ArraysID c82d9bcc70 fix: include withPayload in Next.js plugins for proper configuration 2025-05-11 05:24:31 +07:00
ArraysID a4dd793bb7 refactor: update config import paths to use local payload.config 2025-05-11 05:24:31 +07:00
ArraysID bba3c89b23 fix: update TypeScript paths for payload configuration 2025-05-11 05:24:31 +07:00
ArraysID 8b6aa21f82 feat: add payload configuration and update TypeScript target 2025-05-11 05:24:31 +07:00
ArraysID 0fffe2d403 feat: integrate payload cms to landing page 2025-05-11 05:24:09 +07:00
ArraysID 919fac811d fix: remove unused environment variables from test build workflow 2025-05-10 17:07:51 +07:00
ArraysID 4316dcf8b3 chore: add environment placeholder variables for CMS configuration in test build workflow 2025-05-10 17:04:33 +07:00
ArraysID b9b71c4c25 fix: add placeholder to .env.example 2025-05-10 16:50:26 +07:00
ArraysID 121faf89c3 fix: supress scss deps warning 2025-05-10 16:26:32 +07:00
ArraysID 07913efb16 Merge branch 'develop' into feat/landing-cms 2025-05-10 16:01:05 +07:00
ArraysID 4b2733511c feat: wrap logo image in a link to navigate to the homepage 2025-05-10 15:50:42 +07:00
ArraysID 9f3d00a0db feat: update button components to use onClick for external links and improve styling 2025-05-10 15:48:35 +07:00
ArraysID 7ac571c019 feat: update CallToActionSection label and wrap Discord button in link 2025-05-10 15:34:18 +07:00
ArraysID a8c958400d feat: implement CallToAction section with dynamic data fetching and UI integration 2025-05-10 15:19:54 +07:00
ArraysID 760ab67ae0 feat: simplify joinTitle rendering in Testimonials section 2025-05-10 14:56:07 +07:00
ArraysID c4cf9e4e6c feat: implement Testimonials section with dynamic data fetching and UI integration 2025-05-10 14:52:35 +07:00
ArraysID a800b1e293 refactor: reorganize layout structure by moving Header and Footer components to RootLayout 2025-05-10 13:20:43 +07:00
ArraysID da0e599b9a feat: enhance LearningResourcesSection with primary and secondary button links 2025-05-10 12:58:34 +07:00
ArraysID a02a259f4e feat: add Learning Resources section with dynamic data fetching and UI integration 2025-05-10 12:48:07 +07:00
ArraysID ff99564b05 feat: add heroImage field to HeroSection and implement image formatting utility 2025-05-10 08:54:45 +07:00
ArraysID 13ab089afc feat: update FeaturesSection to use Iconify icons and adjust icon properties 2025-05-10 07:45:29 +07:00
ArraysID 378e258520 feat: replace Link component with Button for improved button styling in Community component 2025-05-10 07:30:06 +07:00
ArraysID 88468081b6 feat: update Community component to use Iconify for icons and adjust CommunitiesSection configuration 2025-05-10 07:26:38 +07:00
ArraysID aba462e462 feat: add support for s3 bucket for media 2025-05-10 06:55:11 +07:00
ArraysID 55fd1fa443 refactor: move global section functions to services directory 2025-05-10 05:38:01 +07:00
ArraysID 07a1eb8dbc feat: add CommunitiesSection configuration and integrate with Community component 2025-05-10 05:35:24 +07:00
ArraysID aad04b6f94 fix: update revalidation interval to 10 seconds 2025-05-10 04:33:06 +07:00
ArraysID d4a91abb63 refactor: optimize data fetching by using Promise.all 2025-05-10 04:29:46 +07:00
ArraysID 7fd5528c0d feat: add revalidate constant to control page revalidation interval 2025-05-10 04:29:28 +07:00
ArraysID 39a57b4fed feat: add FeaturesSection global configuration and integrate with Features component 2025-05-10 04:08:37 +07:00
ArraysID 7b490702b9 feat: implement HeroSection global configuration and integrate with Hero component 2025-05-10 03:32:19 +07:00
ArraysID f31aad8570 fix: include withPayload in Next.js plugins for proper configuration 2025-05-08 16:07:51 +07:00
ArraysID acb7f4aef9 fix: update quotation marks for testimonials to use HTML entities 2025-05-08 15:42:24 +07:00
ArraysID c69614531b refactor: update config import paths to use local payload.config 2025-05-08 15:39:21 +07:00
ArraysID c82426282c fix: update TypeScript paths for payload configuration 2025-05-08 15:24:44 +07:00
ArraysID 45295953aa feat: add payload configuration and update TypeScript target 2025-05-08 15:16:59 +07:00
ArraysID 0561555276 feat: integrate payload cms to landing page 2025-05-08 15:12:32 +07:00
344 changed files with 9847 additions and 16090 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
-35
View File
@@ -1,35 +0,0 @@
name: Deploy Backoffice
on:
push:
branches:
- develop
paths:
- 'apps/backoffice/**' # 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-backoffice ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-backoffice
else
cd ~/imphnen-frontend-service-backoffice && git pull
fi
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml down || true
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d
docker image prune -af
-35
View File
@@ -1,35 +0,0 @@
name: Deploy Dimentorin
on:
push:
branches:
- develop
paths:
- 'apps/dimentorin/**' # 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-dimentorin ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-dimentorin
else
cd ~/imphnen-frontend-service-dimentorin && git pull
fi
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml down || true
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d
docker image prune -af
-35
View File
@@ -1,35 +0,0 @@
name: Deploy Gacha
on:
push:
branches:
- develop
paths:
- 'apps/gacha/**' # 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-gacha ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-gacha
else
cd ~/imphnen-frontend-service-gacha && git pull
fi
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-gacha/apps/gacha/.env
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml down || true
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d
docker image prune -af
-35
View File
@@ -1,35 +0,0 @@
name: Deploy Landing
on:
push:
branches:
- develop
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: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

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: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 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,158 +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';
import { useNavigate } from 'react-router-dom';
export const CTASection: FC = () => {
const navigate = useNavigate();
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"
onClick={() => navigate('/mentoring/ai-agent')}
>
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"
onClick={() => navigate('/mentoring')}
>
Temukan Mentor
</Button>
</motion.div>
</motion.div>
</section>
)
}
@@ -1,116 +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 bebas memilih mentor yang sesuai dengan kebutuhan, topik, dan level skill kamu. Setiap mentor punya profil, expertise, dan jadwal yang bisa kamu lihat sebelum booking.' },
{ title: 'Apakah mentoring ini cocok untuk pemula?', description: 'Yap! Baik newbie maupun seasoned dev bisa menemukan mentor yang pas. Tidak tahu harus mulai dari mana? Tanya AI Agent dulu untuk arahan belajar, lalu booking mentor untuk pendampingan yang lebih dalam.' },
{ title: 'Apakah ini seperti sekolah coding biasa?', description: 'Nope! Ini lebih fleksibel dari kursus pada umumnya. Rasa seperti punya mentor pribadi yang menyesuaikan pace belajarmu, plus AI Agent yang siap membantu kapan pun kamu macet.' },
{ title: 'Bagaimana sistem pembayarannya?', description: 'Kamu bayar per sesi mentoring yang sudah di-booking. Pembayaran diproses via Midtrans (Virtual Account / QRIS), dan sesi otomatis terkonfirmasi begitu pembayaran lunas.' },
{ title: 'Apakah ada materi yang bisa dipelajari gratis?', description: 'Ada! Halaman Materi berisi artikel dari para mentor yang bisa kamu baca kapan saja, dan AI Agent AI bisa menjawab pertanyaanmu berdasar materi tersebut tanpa biaya tambahan.' },
]
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 Asked Questions
</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,76 +0,0 @@
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { motion } from 'framer-motion'
import { useNavigate } from 'react-router-dom';
export function HeroSection() {
const navigate = useNavigate();
return (
<section className="relative w-full z-0 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"
onClick={() => navigate('/mentoring/ai-agent')}
>
Belajar Dengan AI Sekarang
</Button>
</motion.div>
</div>
</section>
)
}
@@ -1,140 +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 testimonials = [
{
name: 'Riko, Junior Web Developer',
quote:
'Rasanya seperti punya AI waifu yang ngajarin ngoding! Mentoringnya juga super insightful. Thanks, Dimentorin!',
rating: '4.8',
},
{
name: 'Salsa, Data Analyst',
quote:
'AI Agent-nya ngebantu banget pas stuck di konsep SQL yang rumit. Jawabannya selalu lengkap sama sumber materinya. Recommended!',
rating: '5.0',
},
{
name: 'Dimas, Mahasiswa Sistem Informasi',
quote:
'Awalnya takut tanya dosen, sekarang tinggal chat AI agent atau booking mentor. Progress belajarku naik drastis dalam 2 bulan!',
rating: '4.9',
},
{
name: 'Nadia, UI/UX Designer',
quote:
'Sensei-nya sabar banget jelasin dari dasar. Materi Rust & Tokio di sini juga disusun rapi, jadi gampang diikuti step by step.',
rating: '4.7',
},
]
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 fellow 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={testimonials}>
{(testimonial, 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">
{testimonial.name}
</h3>
<p className="mb-6 text-[10px] text-neutral-500 font-medium md:text-sm lg:text-base">
{testimonial.quote}
</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">{testimonial.rating}</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-[23px] lg:font-bold">{offer.title}</p>
<p className="text-xs lg:text-[19px] 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;
-52
View File
@@ -1,52 +0,0 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Link } from 'react-router-dom';
export default function NotFound() {
return (
<main className="relative min-h-[70vh] flex items-center justify-center overflow-hidden bg-primary-50 px-6">
{/* Decorative circles */}
<div className="pointer-events-none absolute -top-24 -right-24 size-72 rounded-full bg-primary-200 md:size-96" />
<div className="pointer-events-none absolute -bottom-28 -left-24 size-72 rounded-full bg-primary-100 md:size-96" />
<div className="relative z-10 mx-auto max-w-2xl text-center">
<p className="text-[46px] font-bold leading-none text-primary-500 md:text-[96px]">
404
</p>
<h1 className="mt-4 text-[23px] font-semibold text-neutral-800 md:text-[29px]">
Quest Tidak Ditemukan!
</h1>
<p className="mx-auto mt-3 max-w-md text-[15px] text-neutral-500 md:text-[19px]">
Sepertinya halaman ini pergi ke isekai tanpa kamu... atau alamatnya
salah ketik. Jangan khawatir, pahlawan sejati selalu tahu jalan
pulang! 🗡
</p>
<div className="mt-8 flex flex-col items-center justify-center gap-4 sm:flex-row">
<Link to="/">
<Button
type="button"
size="lg"
className="w-full sm:w-auto px-8"
>
Kembali ke Home
</Button>
</Link>
<Link to="/mentoring">
<Button
type="button"
size="lg"
variant="bordered"
className="w-full sm:w-auto px-8"
>
Cari Mentor
</Button>
</Link>
</div>
<p className="mt-8 text-[12px] text-neutral-400">
Level up &gt; 404 Not Found &gt; Berhasil kembali ke jalur quest
</p>
</div>
</main>
);
}
@@ -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,108 +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, useMemo, useState } from "react";
import { Link, NavLink, useLocation } from "react-router-dom";
const MENUS: { label: string; href: string }[] = [
{ label: "Home", href: "/" },
{ label: 'Mentoring', href: '/mentoring' },
{ label: 'Materi', href: '/mentoring/materi' },
{ label: 'AI Agent', href: '/mentoring/ai-agent' },
{ label: 'Dashboard', href: '/mentoring/dashboard' },
{ label: 'Sesi Saya', href: '/mentoring/my-sessions' },
{ 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)
})
const isActive = useMemo(() => {
return (path: string) => {
if (path === '/' && location.pathname !== '/') return false
return location.pathname.includes(path)
}
}, [location.pathname]);
return (
<div className={cn("w-full px-8 pt-8 top-0 z-50 md:px-[60px] md:pt-[60px] lg:px-20 sticky", !show && "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",
isActive(menu.href) && "text-primary-500 hover:text-primary-500"
)}
>
<NavLink to={menu.href}>{menu.label}</NavLink>
</Button>
)}
</For>
<Link to="/auth/login">
<Button type="button" className="px-12 py-1 lg:hidden">Login</Button>
</Link>
</nav>
<div>
<Link to="/auth/login">
<Button type="button" className="px-5 py-2 hidden lg:block">Login</Button>
</Link>
<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,105 +0,0 @@
import { Icon } from "@iconify/react";
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { cn, For, Show } from "@imphnen-frontend-service/utils";
import { motion } from "framer-motion";
import { useParams } from "react-router-dom";
import { useGetArticleBySlug } from "@imphnen-frontend-service/service";
export default function DetailArticle() {
const { slug } = useParams()
const { data: article, isLoading, isError } = useGetArticleBySlug(slug ?? "")
const paragraphs = article?.content.split("\n").filter((p) => p.trim()) ?? []
const date = article?.created_at
? new Date(article.created_at).toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" })
: ""
return (
<main>
<motion.section
className="w-full p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, ease: "easeInOut" }}
>
<Show
condition={!isLoading && !isError && !!article}
fallback={
<div className="w-full max-w-[900px] mx-auto text-center text-neutral-500 py-20">
{isError ? "Artikel tidak ditemukan." : "Memuat artikel..."}
</div>
}
>
<div className="w-full max-w-[900px] mx-auto mb-8 md:mb-[60px]">
<div className="mb-10 flex items-center justify-between">
<Button
type="button"
variant="text"
className="flex gap-x-2 text-[10px] px-2.5 py-2 md:text-[15px]"
onClick={() => window.history.back()}
>
<Icon icon="material-symbols:arrow-back-rounded" />
<span>Back</span>
</Button>
<Button
type="button"
variant="text"
className="flex gap-x-2 text-[10px] px-2.5 py-2 md:text-[15px]"
>
<span>Read Next</span>
<Icon icon="material-symbols:arrow-forward-rounded" />
</Button>
</div>
<div className="text-center leading-tight">
<h1
className="text-[23px] text-neutral-800 font-semibold mb-6 md:text-[37px] md:mb-10 xl:text-[46px]"
>
{article?.title}
</h1>
<p
className="text-[15px] font-semibold text-neutral-800 mb-2 md:text-[19px] md:mb-1 xl:text-[23px] xl:mb-2"
>
{article?.author_name || "IMPHNEN Editorial"}
</p>
<p className="text-xs text-neutral-600 mb-6 md:text-[15px] xl:text-[19px]">
{date}
</p>
<div
className="text-[10px] text-primary-600 font-medium bg-primary-200 px-3 py-1 rounded-3xl w-max mx-auto md:text-xs xl:text-[15px]"
>
{article?.category}
</div>
</div>
</div>
<div className="w-full max-w-[900px] mx-auto">
<div className="mb-8 w-full h-auto aspect-16/10 overflow-hidden md:aspect-31/16 md:mb-[60px] xl:aspect-45/26">
<img
src={article?.cover_url ?? "/image/59de8a682dfbe42455613ea07477ff3e30532c1f.webp"}
alt="Cover Article"
className="size-full object-cover"
/>
</div>
<div
className={cn(
"text-balance text-[15px] font-medium text-neutral-600 space-y-8",
"md:space-y-12 md:text-[19px] xl:text-[23px] xl:max-w-[780px] xl:mx-auto",
)}
>
<p className="leading-tight">{article?.excerpt}</p>
<For data={paragraphs}>
{(paragraph) => (
<p key={paragraph.slice(0, 40)} className="leading-tight">
{paragraph}
</p>
)}
</For>
</div>
</div>
</Show>
</motion.section>
</main>
)
}
@@ -1,90 +0,0 @@
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { cn, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react"
import { Link } from "react-router-dom"
import { TArticleListItem } from "@imphnen-frontend-service/service"
export type ArticleCardProps = {
variant?: "default" | "recent" | "featured"
article?: TArticleListItem
}
function formatDate(iso?: string) {
if (!iso) return ""
return new Date(iso).toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" })
}
export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default', article }) => {
const title = article?.title ?? "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
const excerpt = article?.excerpt ?? "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
const author = article?.author_name ?? "IMPHNEN Editorial"
const date = formatDate(article?.created_at)
const slug = article?.slug ?? ""
const cover = article?.cover_url
return (
<div
className={cn(
variant === "recent" && "md:grid md:grid-cols-2 md:gap-7 xl:flex xl:items-center",
)}
>
<div
className={cn(
"w-full h-auto aspect-16/10 overflow-hidden mb-8 md:h-60 md:w-full xl:aspect-auto",
variant === "featured" && "md:h-[300px]",
variant === "recent" && "md:h-auto md:w-full md:aspect-15/7 md:mb-0 xl:aspect-square xl:w-[162px] xl:h-auto",
)}
>
<img
src={cover ?? "/image/59de8a682dfbe42455613ea07477ff3e30532c1f.webp"}
alt={title}
className={cn("w-full object-cover", variant === "recent" && "h-full")}
/>
</div>
<div className={cn("leading-tight", variant === "recent" && "xl:flex-1")}>
<div className="w-max bg-primary-200 text-primary-600 px-3 py-1 text-xs font-medium rounded-3xl mb-4 xl:mb-[15px]">
{article?.category ?? "Artikel"}
</div>
<Link
to={slug ? `/articles/${slug}` : "/articles"}
className={cn(
"text-[15px] text-neutral-800 font-semibold mb-4 line-clamp-2 md:text-[19px] xl:mb-[23px]",
variant === "featured" && "text-[19px] md:text-[23px]",
variant === "recent" && "mb-7 xl:text-[29px]"
)}
>
{title}
</Link>
<Show condition={variant !== "recent"}>
<p
className={cn(
"text-xs text-neutral-600 mb-6 md:text-[15px] md:mb-8 xl:text-[19px]",
variant === "featured" && "text-[15px] md:text-[19px] md:mb-6",
)}
>
{excerpt}
</p>
</Show>
<div
className={cn(
"flex justify-between items-center text-xs text-neutral-600 md:text-[15px] xl:text-[19px]",
variant === "default" && "mb-4",
variant === "featured" && "text-[15px] md:text-[19px] xl:text-base",
)}
>
<p>{author}</p>
<p>{date}</p>
</div>
<Show condition={variant === "default"}>
<Link to={slug ? `/articles/${slug}` : "/articles"}>
<Button type="button" size="sm" className="px-2.5 py-2">
Baca Artikel
</Button>
</Link>
</Show>
</div>
</div>
)
}
@@ -1,140 +0,0 @@
import { ReactElement, useRef, useState } from "react";
import { ArticleCard } from "./_components/card/article";
import { cn, For, Show } from "@imphnen-frontend-service/utils";
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { motion, useInView, Variants } from "framer-motion";
import { useGetArticleCategories, useGetArticles } from "@imphnen-frontend-service/service";
export default function Components(): ReactElement {
const [activeTab, setActiveTab] = useState<string>("Semua")
const { data: articles = [], isLoading } = useGetArticles({ per_page: 20 })
const { data: categories = [] } = useGetArticleCategories()
const tabs = ["Semua", ...categories]
const featured = articles[0]
const recent = articles.slice(1, 4)
const filtered = activeTab === "Semua"
? articles
: articles.filter((a) => a.category === activeTab)
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 (
<main ref={ref}>
<motion.section
className="w-full p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20"
variants={containerVariants}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
>
<div className="max-w-7xl mx-auto mb-8 md:mb-20">
<motion.h1
className="text-[23px] font-semibold text-neutral-800 mb-9 md:text-[29px] xl:text-[46px] xl:font-bold"
variants={childVariants}
>
Featuring Articles
</motion.h1>
<Show
condition={!isLoading}
fallback={<p className="text-neutral-500">Memuat artikel...</p>}
>
<div className="grid gap-12 xl:grid-cols-2">
<motion.div variants={childVariants}>
<ArticleCard variant="featured" article={featured} />
</motion.div>
<div>
<motion.h1
className="text-[19px] text-neutral-800 font-semibold mb-10 md:text-[29px] xl:text-[37px] xl:mb-12"
variants={childVariants}
>
Recent Articles
</motion.h1>
<div className="grid gap-10">
<For data={recent}>
{(article, index) => (
<motion.div key={article.id ?? index} variants={childVariants}>
<ArticleCard variant="recent" article={article} />
</motion.div>
)}
</For>
</div>
</div>
</div>
</Show>
</div>
<div className="max-w-7xl mx-auto">
<motion.h1
className="text-[19px] text-neutral-800 font-semibold mb-4 md:text-[23px] xl:text-[29px]"
variants={childVariants}
>
Category
</motion.h1>
<div className="scrollbar-hide mb-8 w-full overflow-auto mx-auto">
<motion.div className="min-w-max w-auto flex gap-x-3" variants={childVariants}>
<For data={tabs}>
{(category) => (
<Button
key={category}
type="button"
variant="text"
onClick={() => setActiveTab(category)}
className={cn(
"relative text-[10px] text-neutral-400 font-medium px-3 py-2 rounded-3xl md:text-xs md:font-semibold xl:text-[15px]",
"before:w-0 before:absolute before:h-0.5 before:mx-auto before:inset-x-0 before:bg-primary-500 hover:before:w-full before:bottom-0 before:left-0 before:transition-all before:duration-300",
activeTab === category && "text-primary-500 before:w-full"
)}
>
{category}
</Button>
)}
</For>
</motion.div>
</div>
<Show
condition={filtered.length > 0}
fallback={<p className="text-neutral-500 text-center py-10">Belum ada artikel di kategori ini.</p>}
>
<div className="grid gap-8 xl:grid-cols-3">
<For data={filtered}>
{(article, index) => (
<motion.div key={article.id ?? index} variants={childVariants}>
<ArticleCard article={article} />
</motion.div>
)}
</For>
</div>
</Show>
</div>
</motion.section>
</main>
)
}
@@ -1,220 +0,0 @@
import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { motion } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import { ArrowRightOutlined, FireOutlined, TrophyOutlined, ThunderboltOutlined } from '@ant-design/icons';
const STATS = [
{ value: '1000+', label: 'Kohai Menunggu' },
{ value: '500+', label: 'Sensei Aktif' },
];
const GUILD_CARDS = [
{
icon: <ThunderboltOutlined />,
title: 'Power-Up Para Kohai',
description:
'Setiap tips dan trik dari sensei bakal langsung boost skill mereka! Kayak main RPG tapi real life—level up yang beneran meaningful!',
},
{
icon: <FireOutlined />,
title: 'Skill Jadi Gold Mine',
description:
'Knowledge yang udah kamu grind bertahun-tahun sekarang bisa jadi sumber cuan! Farming EXP sambil farming income, double win!',
},
{
icon: <TrophyOutlined />,
title: 'Legendary Sensei Status',
description:
'Jadi mentor yang namanya diingat sampai kohai-mu jadi senior! Your legacy bakal hidup terus di setiap success story mereka.',
},
];
export const Components: FC = (): ReactElement => {
const navigate = useNavigate();
return (
<main>
{/* HERO */}
<section className="relative overflow-hidden bg-primary-50/60">
<div className="max-w-7xl mx-auto px-8 py-16 md:px-[60px] md:py-20 lg:px-20 lg:py-24 flex flex-col items-center text-center">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-3xl"
>
<h1 className="text-[29px] font-bold text-primary-500 leading-tight md:text-[37px] lg:text-[46px]">
Jadi Sensei di Dunia IT?
</h1>
<p className="mt-4 text-[15px] font-medium text-neutral-600 md:text-[19px]">
Waktunya naik level jadi mentor legendaris!
</p>
<div className="mt-10 flex justify-center gap-6 md:gap-12">
{STATS.map((s) => (
<div key={s.label} className="text-center">
<p className="text-[23px] font-bold text-primary-500 md:text-[29px]">
{s.value}
</p>
<p className="text-xs font-medium text-neutral-500 md:text-[15px]">
{s.label}
</p>
</div>
))}
</div>
<div className="mt-10">
<Button
type="button"
size="lg"
onClick={() => navigate('/auth/register-mentor')}
>
Mulai Jadi Sensei <ArrowRightOutlined />
</Button>
</div>
</motion.div>
</div>
</section>
{/* VALUE PROPOSITION */}
<section className="max-w-7xl mx-auto px-8 py-14 md:px-[60px] md:py-16 lg:px-20">
<div className="text-center mb-10">
<h2 className="text-[23px] font-bold text-neutral-800 md:text-[29px]">
Kenapa Harus Join Guild Sensei Kami? 🔥
</h2>
</div>
<div className="grid gap-6 md:grid-cols-3">
{GUILD_CARDS.map((card, i) => (
<motion.div
key={card.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.1 }}
className="bg-white border border-neutral-100 rounded-xl shadow-sm p-8 text-center"
>
<div className="mx-auto mb-5 flex size-16 items-center justify-center rounded-full bg-primary-100 text-3xl text-primary-500">
{card.icon}
</div>
<h3 className="text-[19px] font-semibold text-neutral-800 mb-3">
{card.title}
</h3>
<p className="text-sm text-neutral-500 leading-relaxed">
{card.description}
</p>
</motion.div>
))}
</div>
</section>
{/* TESTIMONIAL */}
<section className="bg-white py-14 md:py-16">
<div className="max-w-7xl mx-auto px-8 md:px-[60px] lg:px-20">
<div className="text-center mb-10">
<p className="text-xs font-semibold uppercase tracking-wider text-primary-500 mb-2">
Testimonial
</p>
<h2 className="text-[23px] font-bold text-neutral-800 md:text-[29px]">
What the Legendary Sensei Says
</h2>
</div>
<div className="grid gap-6 md:grid-cols-3">
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-primary-50/60 border border-primary-100 rounded-xl p-8"
>
<div className="flex items-center gap-4 mb-4">
<div className="size-12 rounded-full bg-primary-500 text-white flex items-center justify-center font-bold">
R
</div>
<div>
<p className="font-semibold text-neutral-800 text-[15px]">
Riko, Junior Web Developer
</p>
<p className="text-primary-500 text-xs">4.8/5.0 </p>
</div>
</div>
<p className="text-sm text-neutral-600 leading-relaxed">
Rasanya seperti punya AI waifu yang ngajarin ngoding!
Mentoringnya juga super insightful. Thanks, Dimentorin!
</p>
</div>
))}
</div>
</div>
</section>
{/* FAQ */}
<section className="bg-primary-50/60 py-14 md:py-16">
<div className="max-w-7xl mx-auto px-8 md:px-[60px] lg:px-20">
<div className="text-center mb-10">
<h2 className="text-[23px] font-bold text-neutral-800 md:text-[29px]">
Frequently Asked Questions
</h2>
<p className="mt-2 text-[15px] font-medium text-neutral-500">
Kata Para Sensei Legendaris
</p>
</div>
<div className="max-w-3xl mx-auto space-y-4">
{[
{
q: 'Berapa income yang bisa didapat?',
a: 'Kamu akan mendapatkan income 100rb/sesi/45 menit.',
},
{
q: 'Bagaimana sistem pembayarannya?',
a: 'Tentu! Kamu bisa memilih mentor yang sesuai dengan kebutuhan dan level skill kamu.',
},
{
q: 'Apakah bisa flexible dalam mengambil sesi?',
a: 'Tentu! Kamu bisa memilih mentor yang sesuai dengan kebutuhan dan level skill kamu.',
},
].map((faq, i) => (
<details
key={i}
className="group bg-white border border-neutral-100 rounded-lg p-5 open:shadow-sm"
>
<summary className="flex items-center justify-between cursor-pointer list-none font-semibold text-neutral-800 text-[15px]">
{faq.q}
<span className="text-primary-500 group-open:rotate-45 transition-transform">
+
</span>
</summary>
<p className="mt-3 text-sm text-neutral-500 leading-relaxed">
{faq.a}
</p>
</details>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="bg-primary-600 relative overflow-hidden py-16 md:py-20">
<div className="max-w-4xl mx-auto px-8 text-center relative z-10">
<h2 className="text-[23px] font-bold text-white md:text-[29px]">
Ready to become the Legendary Sensei?
</h2>
<p className="mt-4 text-[15px] text-primary-100">
Join ribuan expert lainnya yang sudah membuktikan impact mereka.
Your expertise is needed!
</p>
<div className="mt-8">
<Button
type="button"
size="lg"
variant="secondary"
onClick={() => navigate('/auth/register-mentor')}
>
Daftar Sekarang 🚀
</Button>
</div>
</div>
</section>
</main>
);
};
export default Components;
@@ -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">
<Outlet />
</main>
<Footer />
</div>
);
}
@@ -1,216 +0,0 @@
import { CloseOutlined } from "@ant-design/icons"
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { AnimatePresence, motion } from "framer-motion"
import { FC, useCallback, useEffect, useState } from "react"
import { TopicStep } from "./steps/topic"
import { cn, Show } from "@imphnen-frontend-service/utils"
import { ScheduleStep } from "./steps/schedule"
import { ProfileStep } from "./steps/profile"
import { QrisPaymentStep } from "./steps/qris-payement"
import { VAPaymentStep } from "./steps/va-payment"
import { SuccessStep } from "./steps/success"
import { PaymentStep } from "./steps/payment"
import {
TBookSessionRequest,
TPayment,
usePostBookSession,
usePostCreatePayment,
} from "@imphnen-frontend-service/service"
const STEPS = ['topic', 'schedule', 'profile', 'payment', 'qr-payment', 'va-payment', 'success'] as const
type Step = typeof STEPS[number]
type Props = {
open: boolean
setOpen: (open: boolean) => void
mentorId?: string
mentor?: {
fullname?: string | null
current_role?: string
current_company?: string
mentoring_rate?: number
}
}
export const AppointmentModal: FC<Props> = ({ open, setOpen, mentorId, mentor }) => {
const [step, setStep] = useState<Step>('topic')
const [selectedTopics, setSelectedTopics] = useState<number[]>([])
const [booking, setBooking] = useState<TBookSessionRequest | null>(null)
const [payment, setPayment] = useState<TPayment | null>(null)
const [paymentMethod, setPaymentMethod] = useState<'va' | 'qris'>('va')
const [error, setError] = useState("")
const [sessionId, setSessionId] = useState("")
const bookMutation = usePostBookSession(mentorId ?? "")
const paymentMutation = usePostCreatePayment(sessionId)
const handleStep = async (action: 'next' | 'prev') => {
setError("")
if (step === 'payment' && action === 'next') {
if (!mentorId || !booking) {
setError("Data booking tidak lengkap.")
return
}
try {
// 1. Book the session -> get session id
const booked = await bookMutation.mutateAsync({ ...booking, topic: booking.topic || `Mentoring #${selectedTopics[0] ?? 1}` })
const sid = (booked as unknown as { data?: { id?: string } })?.data?.id ?? ""
if (!sid) {
setError("Gagal membuat sesi mentoring. Coba lagi.")
return
}
setSessionId(sid)
// 2. Create payment for the session with the chosen method
const pm = await paymentMutation.mutateAsync({ method: paymentMethod })
setPayment(pm)
// 3. Route to the method-specific payment screen
setStep(paymentMethod === 'qris' ? 'qr-payment' : 'va-payment')
} catch {
setError("Gagal membuat sesi mentoring. Coba lagi.")
}
return
}
if (action === 'next' && step === 'success') {
setOpen(false)
} else if (action === 'next' && (step === 'qr-payment' || step === 'va-payment')) {
setStep('success')
} else if (action === 'next') {
setStep(STEPS[STEPS.indexOf(step) + 1])
} else if (action === 'prev' && step !== 'topic') {
setStep(STEPS[STEPS.indexOf(step) - 1])
}
}
const handleEscapeKey = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape" && open) setOpen(false)
},
[open, setOpen]
)
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden"
window.addEventListener("keydown", handleEscapeKey)
} else {
document.body.style.overflow = ""
}
return () => {
document.body.style.overflow = ""
window.removeEventListener("keydown", handleEscapeKey)
}
}, [handleEscapeKey, open])
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, top: "50%" }}
animate={{ opacity: 1, top: 0 }}
exit={{ opacity: 0, top: "50%" }}
className="fixed inset-0 z-100 flex items-center justify-center"
>
<div className="fixed inset-0 bg-primary-900/30" onClick={() => setOpen(false)} />
<div
className={cn(
"relative bg-primary-50 px-5 py-6 w-full max-w-[280px] mx-4 rounded-lg md:max-w-[627px] md:py-10 md:px-[60px] md:rounded-xl xl:max-w-[668px]",
step === 'success' && 'md:max-w-[400px] md:p-10 xl:max-w-[400px]',
)}
>
<Show condition={step !== 'success'}>
<Button
type="button"
size="sm"
variant="text"
className="absolute top-3 right-3 bg-primary-200 p-1 shadow md:bg-white md:p-2 md:top-8 md:right-10"
>
<CloseOutlined className="md:text-lg" />
</Button>
</Show>
<div>
{step !== 'success' && (
<img
src="/logos/simple.svg"
alt="IMPHNEN Logo"
className="mb-6 h-8 w-auto mx-auto md:h-12 xl:h-[60px]"
/>
)}
<div className="scrollbar-hide max-h-[50dvh] overflow-y-auto">
{step !== 'success' && (
<div className="relative mb-7 bg-primary-500 p-4 overflow-hidden rounded-md md:px-7 md:py-5 md:flex md:justify-between md:gap-x-6">
<p className="text-xs font-semibold text-primary-50 md:text-[19px] md:w-[290px] xl:text-[23px]">
<Show
condition={step !== 'qr-payment' && step !== 'va-payment'}
fallback="Yosha~! Saatnya Membayar :)"
>
Yosha~! Saatnya Level Up dengan Sesi Mentoring bersama Senpai!
</Show>
</p>
<img
src="/image/mascot-character.webp"
alt="IMPHNEN Mascot"
className="hidden w-[166px] h-auto absolute right-0 -bottom-3 md:block xl:w-[207px] xl:-bottom-5"
/>
</div>
)}
<AnimatePresence>
{step === 'topic' && <TopicStep selectedTopics={selectedTopics} setSelectedTopics={setSelectedTopics} mentor={mentor} />}
{step === 'schedule' && <ScheduleStep onChange={(data) => setBooking(data)} />}
{step === 'profile' && <ProfileStep />}
{step === 'payment' && <PaymentStep selectedTopics={selectedTopics} mentor={mentor} booking={booking} onMethodChange={setPaymentMethod} />}
{step === 'qr-payment' && <QrisPaymentStep payment={payment} />}
{step === 'va-payment' && <VAPaymentStep payment={payment} />}
{step === 'success' && <SuccessStep />}
</AnimatePresence>
{error && (
<p className="text-danger-600 text-xs mt-4 font-medium">{error}</p>
)}
</div>
<Show condition={step !== 'success'}>
<hr className="my-6 border-primary-300" />
</Show>
<div className="flex justify-between">
<Button
type="button"
size="sm"
variant="text"
className={cn((step === 'topic' || step === 'success') && 'hidden')}
onClick={() => handleStep('prev')}
>
Kembali
</Button>
<Button
type="button"
size="sm"
variant="primary"
className={cn((step === 'topic' || step === 'success') && 'w-full')}
disabled={selectedTopics.length === 0 && step === 'topic'}
onClick={() => handleStep('next')}
>
<Show
condition={step !== 'success'}
fallback="Halman Booking"
>
<Show condition={step !== 'payment'} fallback={bookMutation.isPending ? "Memproses..." : "Bayar Sekarang"}>
<Show condition={step !== 'profile'} fallback="Pembayaran">
Selanjutnya
</Show>
</Show>
</Show>
</Button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
@@ -1,153 +0,0 @@
import { For, Show } from "@imphnen-frontend-service/utils"
import { FC, useState } from "react"
import { motion } from "framer-motion"
import { TOPICS } from "../../../sections/topics"
import { TBookSessionRequest } from "@imphnen-frontend-service/service"
import { MentorAvatar } from "../../../../../_components/mentor-avatar"
type Props = {
selectedTopics: number[]
mentor?: {
fullname?: string | null;
current_role?: string;
current_company?: string;
mentoring_rate?: number;
} | null
booking?: TBookSessionRequest | null
onMethodChange: (method: 'va' | 'qris') => void
}
const BANK_OPTIONS = ['BCA', 'BRI', 'BNI', 'MANDIRI']
export const PaymentStep: FC<Props> = ({ selectedTopics, mentor, booking, onMethodChange }) => {
const rate = mentor?.mentoring_rate ?? 50000
const serviceFee = 2000
const [method, setMethod] = useState<'va' | 'qris'>('va')
const handleMethod = (next: 'va' | 'qris') => {
setMethod(next)
onMethodChange(next)
}
return (
<motion.div
className="bg-white px-6 py-5 rounded-md grid gap-6 md:grid-cols-2 md:gap-8 xl:grid-cols-9"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<div className="space-y-6 xl:col-span-4">
<div>
<p className="w-max mx-auto text-[10px] text-neutral-400 font-medium mb-2 md:text-xs md:ms-0">
Senpai Kamu
</p>
<div>
<div className="aspect-square mb-2 size-10 mx-auto rounded-full overflow-hidden md:size-20 md:ms-0">
<MentorAvatar
name={mentor?.fullname || "Mentor"}
size="sm"
className="w-full h-full rounded-full"
textClassName="text-sm md:text-xl"
/>
</div>
<div className="w-44 mx-auto text-center md:ms-0 md:text-left md:mb-2 md:text-[15px] md:w-auto">
<h3 className="text-xs font-semibold mb-1 xl:text-[15px]">
{mentor?.fullname || "Mentor"}
</h3>
<p className="text-[10px] text-neutral-600 md:text-xs">
{mentor?.current_role || "Mentor"} at {mentor?.current_company || "IMPHNEN"}
</p>
{booking?.scheduled_at && (
<p className="text-[10px] text-neutral-500 md:text-xs mt-1">
{new Date(booking.scheduled_at).toLocaleString("id-ID", { dateStyle: "full", timeStyle: "short" })}
</p>
)}
</div>
</div>
</div>
<div>
<p className="text-[10px] text-neutral-400 font-medium mb-2 md:text-xs">
Topics :
</p>
<div className="flex flex-wrap gap-2">
<For data={TOPICS}>
{(topic) => (
<Show key={topic.id} condition={selectedTopics.includes(topic.id)}>
<div
className="px-2.5 py-2 text-neutral-800 bg-white border border-primary-100 rounded-md shadow text-[10px] font-medium"
>
<span>{topic.icon} </span>
<span>{topic.name}</span>
</div>
</Show>
)}
</For>
</div>
</div>
</div>
<div className="space-y-6 xl:col-span-5">
<div>
<p className="text-[10px] text-neutral-400 font-medium mb-2 md:text-xs">
Metode Pembayaran
</p>
<div className="mb-3">
<p className="text-[8px] text-neutral-400 mb-2 md:text-[10px]">
Virtual Account
</p>
<div className="grid grid-cols-4 gap-2">
<For data={BANK_OPTIONS}>
{(bank, index) => (
<div
key={index}
className="p-1 bg-white rounded-xs border border-primary-50 flex items-center gap-x-1 cursor-pointer"
onClick={() => handleMethod('va')}
>
<input type="radio" name="payment" id={`va-${bank}`} checked={method === 'va'} readOnly className="size-2" />
<label htmlFor={`va-${bank}`} className="block">
<img src="/image/payment/bca.webp" alt={bank} className="object-scale-down" />
</label>
</div>
)}
</For>
</div>
</div>
<div>
<p className="text-[8px] text-neutral-400 mb-2 md:text-[10px]">
QRIS
</p>
<div className="grid grid-cols-4 gap-2">
<div
className="p-1 bg-white rounded-xs border border-primary-50 flex items-center gap-x-1 cursor-pointer"
onClick={() => handleMethod('qris')}
>
<input type="radio" name="payment" id="QRIS" checked={method === 'qris'} readOnly className="size-2" />
<label htmlFor="QRIS" className="block">
<img src="/image/payment/qris.webp" alt="QRIS" className="object-scale-down" />
</label>
</div>
</div>
</div>
</div>
<div>
<p className="text-[10px] text-neutral-400 font-medium mb-2 md:text-xs">
Detail Pembayaran
</p>
<div className="grid grid-cols-2 space-y-2.5 text-[10px] text-neutral-400 font-medium md:text-xs md:space-y-3">
<p className="font-normal">Subtotal :</p>
<p className="text-right">Rp. {rate.toLocaleString("id-ID")}</p>
<p className="font-normal">Service Fee :</p>
<p className="text-right">Rp. {serviceFee.toLocaleString("id-ID")}</p>
<hr className="border-neutral-200 col-span-full mt-1" />
<p className="text-neutral-700">Total :</p>
<p className="text-right text-neutral-700">Rp. {(rate + serviceFee).toLocaleString("id-ID")}</p>
</div>
</div>
</div>
</motion.div>
)
}
@@ -1,38 +0,0 @@
import { Input } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils"
import { motion } from "framer-motion"
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
export const ProfileStep = () => {
return (
<motion.div
className="bg-white px-6 py-5 rounded-md md:px-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<h3 className="text-xs font-semibold mb-1.5 md:text-[15px] md:mb-2 xl:text-[19px]">
Langkah 3 dari 3
</h3>
<p className="text-[10px] text-neutral-600 mb-5 md:text-xs xl:text-[15px]">
Lengkapi profil kamu agar senpai dapat mengenal kamu lebih baik.
</p>
<div className="space-y-2.5 md:space-y-5">
<div>
<label className={labelClass}>Nomor WhatsApp</label>
<Input className="min-w-full w-full" placeholder="080000000" />
</div>
<div>
<label className={labelClass}>CV (Opsional)</label>
<Input type="file" className="min-w-full w-full" />
</div>
<div>
<label className={labelClass}>Portoflio (Opsional)</label>
<Input className="min-w-full w-full" placeholder="Link Url" />
</div>
</div>
</motion.div>
)
}
@@ -1,80 +0,0 @@
import { For, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react"
import { motion } from "framer-motion"
import { TPayment } from "@imphnen-frontend-service/service"
const PAYMENT_STEP = [
'Buka aplikasi e-wallet atau m-banking kamu',
'Cari fitur bayar menggunakan QRIS',
'Scan kode QR di samping',
'Konfimasi pembayaran, dan proses selesai.'
]
type Props = {
payment?: TPayment | null
}
export const QrisPaymentStep: FC<Props> = ({ payment }) => {
return (
<motion.div
className="bg-white px-6 py-5 rounded-md md:flex md:justify-between md:gap-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<div className="mb-7 md:mb-0">
<h3 className="text-xs font-semibold mb-4 text-neutral-600 text-center md:text-start">
Silahkan ikuti instruksi pembayaran di bawah ini
</h3>
<p className="text-[10px] text-neutral-400 mb-1.5">
Cara melakukan pembayaran
</p>
<ul className="list-decimal pl-2.5 mb-4 md:mb-7">
<For data={PAYMENT_STEP}>
{(step, index) => (
<li key={index} className="text-[10px] text-neutral-400 leading-tight font-medium">
{step}
</li>
)}
</For>
</ul>
<div className="text-center md:text-start">
<p className="text-[10px] text-neutral-400 mb-1.5 font-medium md:text-xs">
Biaya yang harus dibayarkan
</p>
<p className="text-xs font-semibold text-neutral-700 md:text-[15px]">
Rp. {(payment?.total ?? 52000).toLocaleString("id-ID")}
</p>
<Show condition={!!payment?.externalRef && payment.provider !== 'midtrans'}>
<p className="text-[10px] text-neutral-400 mt-1 font-medium">
Ref: {payment?.externalRef}
</p>
</Show>
</div>
</div>
<div>
<p className="text-[10px] text-neutral-400 mb-2 font-medium text-center md:text-xs">
QR Code Pembayaran
</p>
<div className="bg-primary-50 p-2.5 rounded-lg size-[120px] mx-auto md:size-[142px] md:me-0">
<Show
condition={!!payment?.externalRef && payment.provider === 'midtrans'}
fallback={<img src="/image/sample-qrcode.webp" alt="QR Code" className="w-full" />}
>
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=${encodeURIComponent(payment!.externalRef!)}`}
alt="QR Code Midtrans"
className="w-full"
/>
</Show>
</div>
<p className="text-[8px] text-neutral-500 text-center mt-2 md:text-[10px]">
Berlaku hingga {payment ? new Date(payment.expiresAt).toLocaleString("id-ID") : "-"}
</p>
</div>
</motion.div>
)
}
@@ -1,80 +0,0 @@
import { Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils"
import { motion } from "framer-motion"
import { useEffect, useState } from "react"
import { TBookSessionRequest } from "@imphnen-frontend-service/service"
const placeholder = `Hi [Nama Mentor], Saya [Nama Kamu] & saya berharap dapat memiliki sesi mentoring dengan Anda.
Saat ini, saya tertarik untuk mengejar __. Tujuan saya untuk sesi ini adalah __.
Saya ingin tahu secara khusus tentang ___.
1.Pertanyaan Anda
2. ...
3. ...`
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
type Props = {
onChange: (data: TBookSessionRequest) => void
}
export const ScheduleStep = ({ onChange }: Props) => {
const [date, setDate] = useState("")
const [time, setTime] = useState("")
const [sessionType, setSessionType] = useState("online")
const [description, setDescription] = useState("")
useEffect(() => {
if (!date || !time) return
onChange({
topic: "Mentoring Session",
scheduled_at: `${date}T${time}:00`,
description,
duration_minutes: 60,
session_type: sessionType,
})
}, [date, time, sessionType, description])
return (
<motion.div
className="bg-white px-6 py-5 rounded-md md:px-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<h3 className="text-xs font-semibold mb-1.5 md:text-[15px] md:mb-2 xl:text-[19px]">
Langkah 2 dari 3
</h3>
<p className="text-[10px] text-neutral-600 mb-5 md:text-xs xl:text-[15px]">
Pilih jadwal sesuai preferensi kamu & siapkan pertanyaanmu
</p>
<div className="grid gap-2.5 md:grid-cols-2 md:gap-5">
<div>
<label className={labelClass}>Tanggal</label>
<Input type="date" className="min-w-full w-full" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
<div>
<label className={labelClass}>Waktu</label>
<Input type="time" className="min-w-full w-full" value={time} onChange={(e) => setTime(e.target.value)} />
</div>
<div className="relative md:col-span-full">
<label className={labelClass}>Lokasi</label>
<Select className="min-w-full w-full" value={sessionType} onChange={(e) => setSessionType(e.target.value)}>
<option value="online">Online</option>
<option value="offline">Offline</option>
</Select>
</div>
<div className="md:col-span-full">
<label className={labelClass}>Pertanyaan Untuk Senpai</label>
<Textarea
className="min-w-full w-full h-40"
placeholder={placeholder}
value={description}
onChange={(e) => setDescription(e.target.value)} />
</div>
</div>
</motion.div>
)
}
@@ -1,33 +0,0 @@
import { CheckOutlined } from "@ant-design/icons"
import { cn } from "@imphnen-frontend-service/utils"
import { motion } from "framer-motion"
export const SuccessStep = () => {
return (
<motion.div
className="pb-6 rounded-md md:pb-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<h2 className="text-[15px] font-semibold mb-3 text-primary-500 text-center md:text-[19px] md:mb-7 xl:text-[23px] xl:mb-8">
Booking Senpai Berhasil
</h2>
<div
className={cn(
"mb-3 flex justify-center items-center mx-auto size-[60px] bg-success-200 rounded-full",
"md:size-[100px] md:mb-7 xl:size-[120px] xl:mb-8"
)}
>
<div className="bg-success-500 rounded-full size-10 flex justify-center items-center text-white md:size-16 xl:size-20">
<CheckOutlined className="text-xl md:text-3xl xl:text-5xl" />
</div>
</div>
<p className="text-[10px] text-neutral-400 text-center md:text-xs md:max-w-[220px] md:mx-auto xl:text-[15px]">
Untuk detail dan instruksi berikutnya dapat dilihat di dashboard pada halaman booking
</p>
</motion.div>
)
}
@@ -1,93 +0,0 @@
import { CheckOutlined } from "@ant-design/icons"
import { cn, For, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react"
import { motion } from "framer-motion"
import { TOPICS } from "../../../sections/topics"
import { MentorAvatar } from "../../../../../_components/mentor-avatar"
type Props = {
selectedTopics: number[]
setSelectedTopics: (topics: number[]) => void
mentor?: {
fullname?: string | null
current_role?: string
current_company?: string
}
}
export const TopicStep: FC<Props> = ({ selectedTopics, setSelectedTopics, mentor }) => {
const handleSelectTopic = (topicId: number) => {
if (selectedTopics.includes(topicId)) {
setSelectedTopics(selectedTopics.filter((id) => id !== topicId))
} else {
setSelectedTopics([...selectedTopics, topicId])
}
}
return (
<>
<motion.div
className="mb-6 flex items-center gap-x-4 bg-white rounded-md overflow-hidden shadow/5 md:gap-x-10"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<div className="w-16 aspect-4/5 overflow-hidden md:w-[134px] xl:w-[140px]">
<MentorAvatar
name={mentor?.fullname || "Mentor"}
size="md"
className="w-full h-full rounded-none"
textClassName="text-lg md:text-3xl"
/>
</div>
<div className="flex-1 pe-4">
<h3 className="text-xs font-semibold mb-1 line-clamp-2 md:text-[19px] md:mb-2 xl:text-[23px]">
{mentor?.fullname || "Mentor"}
</h3>
<p className="text-[10px] line-clamp-2 text-neutral-600 md:text-[15px] xl:text-[19px]">
{mentor?.current_role || "Mentor"} at {mentor?.current_company || "IMPHNEN"}
</p>
</div>
</motion.div>
<motion.div
className="bg-white p-5 rounded-md shadow/5 md:px-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<h3 className="text-xs font-semibold mb-1.5 md:text-[15px] md:mb-2 xl:text-[19px]">
Langkah 1 dari 3
</h3>
<p className="text-[10px] text-neutral-600 mb-5 md:text-xs xl:text-[15px]">
Pilih topik yang ingin kamu diskusikan dengan Senpai kamu~
</p>
<div className="grid gap-3 md:grid-cols-2 md:content-center">
<For data={TOPICS}>
{(topic) => (
<div
key={topic.id}
className={cn(
"relative px-2.5 py-2 text-neutral-800 bg-white border border-primary-100 rounded-md shadow text-[10px] font-medium cursor-pointer",
"md:text-xs xl:text-xs xl:font-semibold",
selectedTopics.includes(topic.id) && "bg-primary-50 border-primary-200"
)}
onClick={() => handleSelectTopic(topic.id)}
>
<span>{topic.icon} </span>
<span>{topic.name}</span>
<Show condition={selectedTopics.includes(topic.id)}>
<div className="absolute top-2 right-2 bg-primary-500 size-[14px] rounded-sm flex justify-center items-center text-white">
<CheckOutlined />
</div>
</Show>
</div>
)}
</For>
</div>
</motion.div>
</>
)
}
@@ -1,75 +0,0 @@
import { For, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react"
import { motion } from "framer-motion"
import { TPayment } from "@imphnen-frontend-service/service"
const PAYMENT_STEP = [
'Buka aplikasi e-wallet atau m-banking kamu',
'Pergi ke fitur transfer via Virtual Account',
'Pilih bank dan kode VA sesuai dengan yang ada pada halaman ini',
'Konfimasi pembayaran, dan proses selesai.'
]
type Props = {
payment?: TPayment | null
}
export const VAPaymentStep: FC<Props> = ({ payment }) => {
return (
<motion.div
className="bg-white px-6 py-5 rounded-md md:flex md:items-center md:gap-11"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<div className="mb-6 md:mb-0">
<h3 className="text-xs font-semibold mb-4 text-neutral-600 text-center md:text-start">
Silahkan ikuti instruksi pembayaran di bawah ini
</h3>
<p className="text-[10px] text-neutral-400 mb-1.5">
Cara melakukan pembayaran
</p>
<ul className="list-decimal pl-2.5 mb-4 md:mb-7">
<For data={PAYMENT_STEP}>
{(step, index) => (
<li key={index} className="text-[10px] text-neutral-400 leading-tight font-medium">
{step}
</li>
)}
</For>
</ul>
<div className="text-center md:text-start">
<p className="text-[10px] text-neutral-400 mb-1.5 font-medium md:text-xs">
Biaya yang harus dibayarkan
</p>
<p className="text-xs font-semibold text-neutral-700 md:text-[15px]">
Rp. {(payment?.total ?? 52000).toLocaleString("id-ID")}
</p>
</div>
</div>
<div className="text-center md:text-start">
<div className="w-max mx-auto mb-3 md:ms-0 md:mb-4">
<img src="/image/payment/bca.webp" alt="VA BCA" className="h-5 w-auto" />
</div>
<p className="text-[10px] text-neutral-400 mb-1 font-medium md:text-xs">
Kode Virtual Account
</p>
<p className="text-xs font-semibold text-neutral-700 mb-1.5 md:text-[15px]">
{payment?.externalRef || "8091239861969812"}
</p>
<p className="text-[8px] text-neutral-400 md:text-[10px]">
A/N IMPHNEN
</p>
<Show condition={!!payment?.expiresAt}>
<p className="text-[8px] text-neutral-500 mt-2 md:text-[10px]">
Berlaku hingga {new Date(payment!.expiresAt).toLocaleString("id-ID")}
</p>
</Show>
</div>
</motion.div>
)
}
@@ -1,33 +0,0 @@
import { cn, For } from "@imphnen-frontend-service/utils"
const EDUCATION = [
{ name: 'Universitas Widyabakti', major: 'Intern Front End', duration: '2 Months', range: 'Oct 2024 - Present' },
{ name: 'SMKN 99 Banjaran', major: 'Rekayasa Perangkat Lunak', duration: '6 Months', range: 'May 2024 - Oct 2024' },
]
export const EducationSection = () => {
return (
<div className="px-7 py-8 rounded-md shadow-md">
<h2 className="text-xs font-semibold mb-5 md:text-[15px] xl:text-[19px]">Education</h2>
<div className="space-y-4 divide-y">
<For data={EDUCATION}>
{(item, index) => (
<div key={index} className={cn("flex items-center gap-x-4", index !== EDUCATION.length - 1 && "pb-4")}>
<div className="rounded-full size-6 bg-neutral-200 md:size-7 xl:size-8"></div>
<div className='flex-1 text-[10px] font-medium'>
<p className="text-neutral-800 md:text-xs xl:text-[15px]">{item.name}</p>
<p>
<span className="text-neutral-600 xl:text-xs">{item.major}</span>
<span className="text-neutral-300"> · </span>
<span className="text-neutral-400 font-normal">{item.duration}</span>
<span className="text-neutral-300"> · </span>
<span className="text-neutral-400 font-normal xl:font-medium">{item.range}</span>
</p>
</div>
</div>
)}
</For>
</div>
</div>
)
}
@@ -1,34 +0,0 @@
import { cn, For } from "@imphnen-frontend-service/utils"
import { FC } from "react"
const EXPERIENCE = [
{ name: 'Sunday.com', position: 'Intern Front End', duration: '2 Months', range: 'Oct 2024 - Present' },
{ name: 'CodeX Digital', position: 'Intern Front End', duration: '6 Months', range: 'May 2024 - Oct 2024' },
]
export const ExperienceSection: FC = () => {
return (
<div className="px-7 py-8 rounded-md shadow-md">
<h2 className="text-xs font-semibold mb-5 md:text-[15px] xl:text-[19px]">Experience</h2>
<div className="space-y-4 divide-y">
<For data={EXPERIENCE}>
{(item, index) => (
<div key={index} className={cn("flex items-center gap-x-4", index !== EXPERIENCE.length - 1 && "pb-4")}>
<div className="rounded-full size-6 bg-neutral-200 md:size-7 xl:size-8"></div>
<div className='flex-1 text-[10px] font-medium'>
<p className="text-neutral-800 md:text-xs xl:text-[15px]">{item.name}</p>
<p>
<span className="text-neutral-600 xl:text-xs">{item.position}</span>
<span className="text-neutral-300"> · </span>
<span className="text-neutral-400 font-normal">{item.duration}</span>
<span className="text-neutral-300"> · </span>
<span className="text-neutral-400 font-normal xl:font-medium">{item.range}</span>
</p>
</div>
</div>
)}
</For>
</div>
</div>
)
}
@@ -1,111 +0,0 @@
import { StarFilled } from "@ant-design/icons"
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { cn, For } from "@imphnen-frontend-service/utils"
import { TMentorDetail } from "@imphnen-frontend-service/service"
import { MentorAvatar } from "../../../_components/mentor-avatar"
type Props = {
onBook: () => void
mentor?: TMentorDetail
}
export const ProfileSection: React.FC<Props> = ({ onBook, mentor }) => {
const name = mentor?.fullname || mentor?.legal_name || "Mentor"
const role = mentor?.current_role || "Mentor"
const company = mentor?.current_company || "IMPHNEN"
const expertise = mentor?.expertise ?? []
const softSkills = mentor?.languages ?? []
return (
<div className="bg-white px-4 py-5 space-y-6 md:px-8 md:pt-6 md:pb-0 xl:space-y-0 xl:py-[30px] xl:flex xl:gap-x-9 xl:justify-between">
<div
className={cn(
"flex flex-col items-center justify-center",
"md:flex-row md:justify-start md:gap-x-8 md:items-start",
"xl:items-center xl:gap-x-10",
)}
>
<div
className={cn(
"mb-5 h-20 w-auto aspect-4/5 rounded-sm overflow-hidden",
"md:aspect-33/40 md:h-40 md:rounded-md md:border md:border-primary-50 md:mb-0",
"xl:aspect-5/6 md:h-60 md:rounded-lg"
)}
>
<MentorAvatar
name={name}
size="lg"
className="w-full h-full rounded-none"
textClassName="text-xl md:text-3xl lg:text-5xl"
/>
</div>
<div className="md:max-w-[242px] xl:max-w-[306px]">
<h1
className={cn(
"mb-1 text-[15px] font-semibold text-center leading-snug",
"md:text-[19px] md:mb-2 md:text-start xl:text-[23px]",
)}
>
{name}
</h1>
<p className="text-xs mb-4 text-neutral-600 md:mb-5 md:text-[15px] xl:text-[19px] xl:mb-5">
{role} at {company}
</p>
<div className="flex items-center gap-x-2.5 w-full max-w-max border border-primary-50 p-1.5 rounded-md mx-auto md:ms-0">
<div
className="bg-gradient-to-tr from-primary-500 to-primary-200 rounded-sm text-white size-5 flex justify-center items-center xl:size-[29.4px]"
>
<StarFilled className="text-xs xl:text-sm" />
</div>
<div>
<p className="text-[10px] font-medium mb-1 text-neutral-800 xl:text-xs">Excelent Sensei</p>
<p className="text-[8px] text-neutral-600 xl:text-[10px]">4.8/5.0</p>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-y-3 xl:gap-y-4 xl:max-w-[402px]">
<div>
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Expertise</h2>
<div className="p-4 bg-primary-50 border border-primary-100 rounded-md flex flex-wrap gap-3">
<For data={expertise}>
{(item) => (
<div key={item} className="bg-primary-300 text-primary-600 px-3 py-2 rounded-md text-[10px] md:font-medium xl:text-xs xl:font-semibold">
{item}
</div>
)}
</For>
</div>
</div>
<div>
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Soft Skills</h2>
<div className="p-4 bg-primary-50 border border-primary-100 rounded-md flex flex-wrap gap-3">
<For data={softSkills}>
{(item) => (
<div key={item} className="bg-primary-300 text-primary-600 px-3 py-2 rounded-md text-[10px] md:font-medium xl:text-xs xl:font-semibold">
{item}
</div>
)}
</For>
</div>
</div>
</div>
<div className="hidden md:flex xl:hidden justify-between">
<div>
<p className="text-primary-500 text-[15px] font-semibold">
{new Date().toLocaleDateString('id-ID', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' })}
</p>
<p className="text-neutral-500 text-xs font-medium">
{new Date().toLocaleDateString('id-ID', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
</p>
</div>
<Button type="button" size="sm" onClick={onBook}>
Book Your Senpai!
</Button>
</div>
</div>
)
}
@@ -1,84 +0,0 @@
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { For, Show } from "@imphnen-frontend-service/utils"
import { useGetMentorAvailability } from "@imphnen-frontend-service/service"
import { FC } from "react"
type Props = {
onBook: () => void
mentorId?: string
}
const DAY_NAMES = ["Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"]
function formatDate(dateStr: string) {
const d = new Date(dateStr + "T00:00:00")
return `${DAY_NAMES[d.getDay()]}, ${d.getDate()} ${d.toLocaleString("id-ID", { month: "long" })} ${d.getFullYear()}`
}
function formatTime(timeStr: string) {
const [h] = timeStr.split(":").map(Number)
return `${String(h).padStart(2, "0")}:00 WIB`
}
export const SenpaiScheduleSection: FC<Props> = ({ onBook, mentorId }) => {
const { data: availability, isLoading } = useGetMentorAvailability(mentorId ?? "")
const slots = availability?.slots ?? []
const byDate = new Map<string, typeof slots>()
for (const s of slots) {
if (!byDate.has(s.date)) byDate.set(s.date, [])
byDate.get(s.date)!.push(s)
}
const days = [...byDate.entries()]
return (
<div>
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Senpai Schedule</h2>
<div className="p-4 bg-primary-50 border border-primary-100 rounded-md space-y-5">
<Show
condition={!isLoading && days.length > 0}
fallback={
<p className="text-xs text-neutral-500">
{isLoading ? "Memuat jadwal senpai..." : "Belum ada jadwal tersedia."}
</p>
}
>
<For data={days}>
{([date, daySlots]) => (
<div key={date}>
<div className="flex justify-between items-center mb-4">
<p className="text-[15px] font-medium">{formatDate(date)}</p>
<p className="rounded-3xl py-1 px-2.5 text-xs font-medium bg-primary-200 text-primary-500">
{daySlots.length} slot tersisa
</p>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-2">
<For data={daySlots}>
{(schedule) => (
<div
key={`${schedule.date}-${schedule.time}`}
className={`px-3 py-2 text-xs rounded-md font-semibold ${
schedule.available
? "bg-primary-300 text-primary-600"
: "bg-neutral-200 text-neutral-400 line-through"
}`}
>
{formatTime(schedule.time)}
</div>
)}
</For>
</div>
</div>
)}
</For>
</Show>
<hr className="border-primary-200" />
<Button type="button" size="sm" className="w-full" onClick={onBook}>
Book Your Senpai!
</Button>
</div>
</div>
)
}
@@ -1,43 +0,0 @@
import { StarFilled } from "@ant-design/icons";
import { For } from "@imphnen-frontend-service/utils";
import { useGetMentorStats } from "@imphnen-frontend-service/service";
import { FC } from "react";
type Props = {
mentorId?: string;
};
const formatCount = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 100000 ? 0 : 1)}k` : `${n}`);
export const StatisticsSection: FC<Props> = ({ mentorId }) => {
const { data: stats } = useGetMentorStats(mentorId ?? "")
const SENPAI_STATISTIC: { name: string; count: string }[] = [
{ name: 'Total Sessions', count: formatCount(stats?.total_sessions ?? 0) },
{ name: 'Mentee Impact', count: formatCount(stats?.unique_mentees ?? 0) },
{ name: 'Avg Rating', count: stats && stats.avg_rating > 0 ? `${stats.avg_rating}/5` : '-' },
]
return (
<div className="md:mb-10">
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Senpai Statistics</h2>
<div className="grid gap-4 xl:flex">
<For data={SENPAI_STATISTIC}>
{(item, index) => (
<div key={index} className="px-2.5 py-2 border border-primary-50 rounded-md shadow flex items-center gap-x-2.5">
<div
className="bg-gradient-to-tr from-primary-500 to-primary-200 rounded-sm text-white size-7 flex justify-center items-center xl:size-[29.4px]"
>
<StarFilled className="text-sm" />
</div>
<div>
<p className="text-[10px] text-neutral-600 font-medium md:text-xs xl:font-semibold">{item.count}</p>
<p className="text-[8px] text-neutral-800 md:text-[10px]">{item.name}</p>
</div>
</div>
)}
</For>
</div>
</div>
)
};
@@ -1,33 +0,0 @@
import { For } from "@imphnen-frontend-service/utils"
import { FC } from "react"
export const TOPICS = [
{ id: 1, icon: '💼', name: 'Career & Self Development' },
{ id: 2, icon: '🏢', name: 'Industry Insight' },
{ id: 3, icon: '🗂️', name: 'Project Management & IT Tools' },
{ id: 4, icon: '🖥️', name: 'Basic IT' },
{ id: 5, icon: '💻', name: 'Programming/Software Dev' },
{ id: 6, icon: '🗃️', name: 'Data & Database' },
{ id: 7, icon: <span className="font-bold text-primary-500">AI</span>, name: 'AI Tips' }
]
export const TopicsSection: FC = () => {
return (
<div>
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Topics</h2>
<div className="p-5 bg-primary-50 border border-primary-100 rounded-md flex flex-wrap gap-2.5">
<For data={TOPICS}>
{(item, index) => (
<div
key={index}
className="px-2.5 py-2 text-neutral-800 bg-white border border-primary-100 rounded-md shadow text-[10px] font-medium"
>
<span>{item.icon} </span>
<span>{item.name}</span>
</div>
)}
</For>
</div>
</div>
)
}
@@ -1,102 +0,0 @@
import { FC, useState } from 'react'
import { useParams } from 'react-router-dom'
import { ProfileSection } from './_components/sections/profile'
import { StatisticsSection } from './_components/sections/senpai-statistics'
import { TopicsSection } from './_components/sections/topics'
import { ExperienceSection } from './_components/sections/experience'
import { EducationSection } from './_components/sections/education'
import { SenpaiScheduleSection } from './_components/sections/senpai-schedule'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { AppointmentModal } from './_components/modals/appointment'
import { useGetMentorDetail } from '@imphnen-frontend-service/service'
import { Show } from '@imphnen-frontend-service/utils'
export const Components: FC = () => {
const [open, setOpen] = useState(false)
const { id } = useParams()
const { data: mentor, isLoading, isError } = useGetMentorDetail(id ?? '')
const buildResume = (): string => {
if (mentor?.bio) return mentor.bio
if (!mentor) return ''
const parts: string[] = []
const role = mentor.current_role
const company = mentor.current_company
const years = mentor.years_of_experience
const expertise = mentor.expertise
const industries = mentor.industries
const languages = mentor.languages
if (role || company) {
parts.push(`${role || 'Mentor'}${company && role ? ' at ' : company ? ' di ' : ''}${company || ''}.`)
}
if (years > 0) {
parts.push(`Memiliki pengalaman ${years} tahun di industri IT.`)
}
if (expertise.length > 0) {
parts.push(`Keahlian utama: ${expertise.join(', ')}.`)
}
if (industries.length > 0) {
parts.push(`Bidang yang digeluti: ${industries.join(', ')}.`)
}
if (languages.length > 0) {
parts.push(`Bahasa yang dikuasai: ${languages.join(', ')}.`)
}
if (parts.length === 0) {
return 'Profil senpai ini sedang dilengkapi. Segera cek kembali!'
}
return parts.join(' ')
}
const resume = buildResume()
return (
<main>
<Show
condition={!isLoading && !isError}
fallback={
<section className="w-full p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20">
<div className="max-w-7xl mx-auto text-center text-neutral-500 py-10">
{isError ? 'Mentor tidak ditemukan' : 'Memuat profil mentor...'}
</div>
</section>
}
>
<section className="w-full p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20">
<div className="max-w-7xl mx-auto space-y-8 md:bg-white xl:bg-transparent">
<ProfileSection onBook={() => setOpen(true)} mentor={mentor} />
<Button type="button" size="sm" className="w-full md:hidden" onClick={() => setOpen(true)}>
Book Your Senpai!
</Button>
<div className="bg-white px-4 py-5 md:px-8 md:pb-6 md:pt-0 xl:py-7 xl:flex xl:gap-x-10">
<div className="space-y-10 md:space-y-7 xl:flex-1">
<StatisticsSection mentorId={mentor?.id} />
<TopicsSection />
<div className="px-6 py-8 rounded-md shadow-md">
<h2 className="text-xs text-neutral-800 font-semibold mb-5 md:text-[15px] xl:text-[19px]">Senpai Resume</h2>
<p className="text-[10px] font-medium text-neutral-600 text-pretty md:text-[15px]">
{resume || 'Profil senpai ini sedang dilengkapi. Segera cek kembali!'}
</p>
</div>
<ExperienceSection />
<EducationSection />
</div>
<div className="hidden xl:block xl:w-[400px]">
<SenpaiScheduleSection onBook={() => setOpen(true)} mentorId={mentor?.id} />
</div>
</div>
</div>
</section>
<AppointmentModal open={open} setOpen={setOpen} mentorId={mentor?.id} mentor={mentor} />
</Show>
</main>
)
}
export default Components
@@ -1,70 +0,0 @@
import { cn } from '@imphnen-frontend-service/utils';
import { FC, useRef } from 'react';
import { motion, useInView, Variants } from 'framer-motion';
export const BannerSection: 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 p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20">
<motion.div
className={cn(
"relative max-w-7xl mx-auto bg-white p-6 rounded-lg overflow-hidden shadow-md",
"md:flex md:justify-between md:items-center md:px-5 md:rounded-xl lg:px-16 lg:py-10",
)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<motion.div
className="hidden absolute -left-9 -top-24 w-[17rem] md:block lg:w-[28rem] lg:-left-14 lg:-top-52"
initial={{ x: -120, opacity: 0, rotate: -6 }}
animate={{ x: 0, opacity: 1, rotate: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<img src="/image/mentoring-banner.webp" alt="" className="w-full" />
</motion.div>
<motion.div
className="text-center md:text-start md:max-w-[calc(100%-14rem)] md:ms-auto lg:max-w-[calc(100%-20rem)]"
variants={containerVariants}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
viewport={{ once: true }}
>
<motion.h1 className="mb-4 text-xl font-semibold text-primary-500 leading-tight md:text-2xl lg:text-5xl lg:font-bold lg:mb-6" variants={childVariants}>
Find your perfect sensei
</motion.h1>
<motion.p className="text-xs font-semibold text-neutral-500 md:text-[15px] md:font-medium lg:text-xl" variants={childVariants}>
Di sini, kamu nggak cuma grinding sendiriansensei dari dunia nyata siap membimbingmu, dan AI mentor bakal jadi support system terbaikmu. Saatnya jadi protagonist dalam perjalanan karier IT-mu!
</motion.p>
</motion.div>
</motion.div>
</section>
)
}
@@ -1,162 +0,0 @@
import { FC, ReactElement } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { cn, SessionToken } from '@imphnen-frontend-service/utils';
import {
DashboardOutlined,
CompassOutlined,
BookOutlined,
TeamOutlined,
SettingOutlined,
LogoutOutlined,
ReadOutlined,
ProfileOutlined,
} from '@ant-design/icons';
type NavItem = {
label: string;
to: string;
icon: ReactElement;
end?: boolean;
};
type DashboardSidebarProps = {
role: 'mentee' | 'mentor';
};
const MENTEE_NAV: NavItem[] = [
{ label: 'Dashboard', to: '/mentoring/dashboard', icon: <DashboardOutlined />, end: true },
{ label: 'Roadmap Discovery', to: '/mentoring/ai-agent', icon: <CompassOutlined /> },
{ label: 'Learning Path', to: '/mentoring/materi', icon: <BookOutlined /> },
{ label: 'Mentoring', to: '/mentoring/my-sessions', icon: <TeamOutlined /> },
];
const MENTOR_NAV: NavItem[] = [
{ label: 'Dashboard', to: '/mentoring/mentor-dashboard', icon: <DashboardOutlined />, end: true },
{ label: 'Mentoring Setup', to: '/mentoring/mentor-dashboard?tab=setup', icon: <SettingOutlined /> },
{ label: 'List Mentee', to: '/mentoring/mentor-dashboard?tab=mentee', icon: <TeamOutlined /> },
{ label: 'Feedback', to: '/mentoring/mentor-dashboard?tab=feedback', icon: <ProfileOutlined /> },
];
// decode JWT payload (bukan verifikasi — cuma untuk tampilkan inisial/email)
const jwtPayload = (): { email?: string; name?: string } | null => {
const raw = SessionToken.get()?.token?.access_token;
if (!raw) return null;
try {
const part = raw.split('.')[1];
if (!part) return null;
const json = atob(part.replace(/-/g, '+').replace(/_/g, '/'));
const pad = json.length % 4 === 0 ? json : json + '='.repeat(4 - (json.length % 4));
return JSON.parse(pad);
} catch {
return null;
}
};
export const DashboardSidebar: FC<DashboardSidebarProps> = ({ role }): ReactElement => {
const location = useLocation();
const navigate = useNavigate();
const nav = role === 'mentee' ? MENTEE_NAV : MENTOR_NAV;
const isActive = (item: NavItem) => {
if (item.end) return location.pathname === item.to;
if (item.to.includes('?')) {
const [base, q] = item.to.split('?');
const [key, val] = q.split('=');
return location.pathname === base && new URLSearchParams(location.search).get(key) === val;
}
return location.pathname.startsWith(item.to);
};
const handleLogout = () => {
SessionToken.remove();
navigate('/');
};
return (
<aside className="hidden lg:flex w-[230px] shrink-0 flex-col bg-white border-r border-neutral-100 sticky top-0 h-screen">
<div className="px-6 py-6">
<p className="text-[19px] font-bold">
<span className="text-primary-500">Dimentorin</span>.dev
</p>
</div>
<nav className="flex-1 px-3 space-y-1">
{nav.map((item) => (
<Link
key={item.label}
to={item.to}
className={cn(
'flex items-center gap-3 rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
isActive(item)
? 'bg-primary-100/70 text-primary-600'
: 'text-neutral-500 hover:bg-neutral-50 hover:text-neutral-700'
)}
>
<span className="text-base">{item.icon}</span>
{item.label}
</Link>
))}
</nav>
<div className="p-3 border-t border-neutral-100">
<button
type="button"
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-lg px-4 py-2.5 text-sm font-medium text-neutral-500 hover:bg-danger-50 hover:text-danger-500 transition-colors"
>
<LogoutOutlined /> Log Out
</button>
</div>
</aside>
);
};
export const DashboardHeader: FC<{ title?: string; role?: 'mentee' | 'mentor' }> = ({
title,
role,
}): ReactElement => {
const payload = jwtPayload();
const name = payload?.name || payload?.email?.split('@')[0] || 'Pengguna';
const initial = name[0]?.toUpperCase() ?? 'U';
const roleLabel = role === 'mentor' ? 'Mentor' : 'Mentee';
return (
<header className="flex items-center justify-between gap-4 px-6 py-4 border-b border-neutral-100 bg-white/70 backdrop-blur sticky top-0 z-10">
<div>
{title && <h1 className="text-[19px] font-bold text-neutral-800">{title}</h1>}
<p className="text-xs text-neutral-400 hidden sm:block">Dimentorin.dev</p>
</div>
<div className="flex items-center gap-3">
<button type="button" aria-label="Notifications" className="size-9 rounded-full bg-neutral-50 hover:bg-neutral-100 flex items-center justify-center text-neutral-500">
<ReadOutlined />
</button>
<button type="button" aria-label="Settings" className="size-9 rounded-full bg-neutral-50 hover:bg-neutral-100 flex items-center justify-center text-neutral-500">
<SettingOutlined />
</button>
<div className="flex items-center gap-2">
<div className="size-9 rounded-full bg-primary-500 text-white flex items-center justify-center text-sm font-semibold">
{initial}
</div>
<div className="hidden sm:block">
<p className="text-sm font-semibold text-neutral-800 leading-tight">{name}</p>
<p className="text-xs text-neutral-400 capitalize">{roleLabel}</p>
</div>
</div>
</div>
</header>
);
};
export const DashboardLayout: FC<{
role: 'mentee' | 'mentor';
title?: string;
children: ReactElement | ReactElement[];
}> = ({ role, title, children }): ReactElement => (
<div className="flex min-h-screen bg-primary-50/40">
<DashboardSidebar role={role} />
<div className="flex-1 min-w-0 flex flex-col">
<DashboardHeader title={title} role={role} />
<main className="flex-1 px-6 py-6 lg:px-8">{children}</main>
</div>
</div>
);
@@ -1,256 +0,0 @@
import { FC, ReactElement, useEffect, useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { cn, Show } from '@imphnen-frontend-service/utils';
import { usePostSessionFeedback } from '@imphnen-frontend-service/service';
import { StarFilled, StarOutlined } from '@ant-design/icons';
import { toast } from 'sonner';
const RATING_LABELS: Record<number, string> = {
1: 'Sangat Tidak Puas',
2: 'Tidak Puas',
3: 'Cukup Puas',
4: 'Puas',
5: 'Sangat Puas',
};
type FeedbackModalProps = {
open: boolean;
sessionId: string;
mentorName?: string | null;
onClose: () => void;
onSuccess: () => void;
};
const RATING_QUESTIONS: { question: string; options: string[] }[] = [
{
question: 'Seberapa membantu jawaban mentor untuk kebutuhanmu?',
options: [
'Tidak Membantu',
'Kurang Membantu',
'Cukup Membantu',
'Membantu',
'Sangat Membantu',
],
},
{
question: 'Apakah kamu akan merekomendasikan platform ini ke rekanmu?',
options: ['Ya', 'Tidak', 'Mungkin'],
},
];
export const FeedbackModal: FC<FeedbackModalProps> = ({
open,
sessionId,
mentorName,
onClose,
onSuccess,
}): ReactElement => {
const [step, setStep] = useState<'rating' | 'detail'>('rating');
const [rating, setRating] = useState(0);
const [hoverRating, setHoverRating] = useState(0);
const [helpfulness, setHelpfulness] = useState('');
const [recommend, setRecommend] = useState('');
const [liked, setLiked] = useState('');
const [improve, setImprove] = useState('');
const [testimonial, setTestimonial] = useState('');
const mutation = usePostSessionFeedback(sessionId);
useEffect(() => {
if (open) {
setStep('rating');
setRating(0);
setHelpfulness('');
setRecommend('');
setLiked('');
setImprove('');
setTestimonial('');
}
}, [open]);
const handleSubmit = async () => {
const parts: string[] = [];
if (liked.trim()) parts.push(`Suka: ${liked.trim()}`);
if (improve.trim()) parts.push(`Tingkatkan: ${improve.trim()}`);
if (testimonial.trim()) parts.push(`Testimoni: ${testimonial.trim()}`);
if (helpfulness) parts.push(`Kebermanfaatan: ${helpfulness}`);
if (recommend) parts.push(`Rekomendasi: ${recommend}`);
const feedbackText = parts.join('. ') || 'Tidak ada komentar tambahan.';
try {
await mutation.mutateAsync({ rating, feedback: feedbackText });
toast.success('Feedback berhasil dikirim ✅ Terima kasih atas waktumu!');
onSuccess();
onClose();
} catch {
toast.error('Gagal mengirim feedback. Coba lagi ya, Senpai!');
}
};
const canNext =
rating > 0 && helpfulness !== '' && recommend !== '';
const canSubmit =
canNext || liked.trim() !== '' || improve.trim() !== '' || testimonial.trim() !== '';
return (
<Modal isOpen={open} onClose={onClose}>
<div className="p-6 w-full max-w-lg">
<div className="flex items-center justify-between mb-1">
<h2 className="text-[19px] font-bold text-neutral-800">
Feedback Mentor
</h2>
<span className="text-xs font-medium text-neutral-400">
{step === 'rating' ? '1 dari 2' : '2 dari 2'}
</span>
</div>
<p className="text-xs text-neutral-400 mb-4">
Sesi dengan {mentorName ?? 'mentormu'}
</p>
<Show
condition={step === 'rating'}
fallback={
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Apa yang kamu suka dari sesi ini?
</label>
<textarea
value={liked}
onChange={(e) => setLiked(e.target.value)}
rows={2}
className="w-full rounded-lg border border-neutral-200 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
placeholder="Ceritakan hal yang paling berkesan dari sesi ini..."
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Apa yang bisa ditingkatkan oleh mentor?
</label>
<textarea
value={improve}
onChange={(e) => setImprove(e.target.value)}
rows={2}
className="w-full rounded-lg border border-neutral-200 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
placeholder="Saran membangun untuk mentor..."
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Testimoni atau ucapan terima kasih untuk mentor
</label>
<textarea
value={testimonial}
onChange={(e) => setTestimonial(e.target.value)}
rows={2}
className="w-full rounded-lg border border-neutral-200 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
placeholder="Tulis testimoni singkat..."
/>
</div>
</div>
}
>
<div className="space-y-5">
<div className="text-center">
<p className="text-sm font-medium text-neutral-700 mb-3">
Seberapa puas kamu dengan sesi mentoring ini?
</p>
<div className="flex justify-center gap-1.5">
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
type="button"
aria-label={`Rating ${value}`}
onMouseEnter={() => setHoverRating(value)}
onMouseLeave={() => setHoverRating(0)}
onClick={() => setRating(value)}
className={cn(
'text-2xl transition-colors',
(hoverRating || rating) >= value
? 'text-warning-400'
: 'text-neutral-200'
)}
>
{(hoverRating || rating) >= value ? <StarFilled /> : <StarOutlined />}
</button>
))}
</div>
<p className="mt-2 text-xs text-neutral-500">
{rating > 0 ? RATING_LABELS[rating] : '1 = Sangat Tidak Puas, 5 = Sangat Puas'}
</p>
</div>
{RATING_QUESTIONS.map((q) => (
<div key={q.question}>
<p className="text-sm font-medium text-neutral-700 mb-2">{q.question}</p>
<div className="flex flex-wrap gap-2">
{q.options.map((opt) => {
const isSelected =
(q.question === RATING_QUESTIONS[0].question
? helpfulness
: recommend) === opt;
const setter =
q.question === RATING_QUESTIONS[0].question
? setHelpfulness
: setRecommend;
return (
<button
key={opt}
type="button"
onClick={() => setter(opt)}
className={cn(
'rounded-full border px-4 py-1.5 text-xs font-medium transition-colors',
isSelected
? 'border-primary-500 bg-primary-100/60 text-primary-600'
: 'border-neutral-200 text-neutral-500 hover:border-primary-300'
)}
>
{opt}
</button>
);
})}
</div>
</div>
))}
</div>
</Show>
<div className="mt-6 flex justify-between gap-3">
<Button
type="button"
size="sm"
variant="bordered"
onClick={step === 'rating' ? onClose : () => setStep('rating')}
>
{step === 'rating' ? 'Batal' : 'Kembali'}
</Button>
<Show
condition={step === 'rating'}
fallback={
<Button
type="button"
size="sm"
disabled={mutation.isPending || !canSubmit}
onClick={handleSubmit}
>
{mutation.isPending ? 'Mengirim...' : 'Kirim'}
</Button>
}
>
<Button
type="button"
size="sm"
disabled={!canNext}
onClick={() => setStep('detail')}
>
Lanjut
</Button>
</Show>
</div>
</div>
</Modal>
);
};
export default FeedbackModal;
@@ -1,69 +0,0 @@
import { FC } from 'react';
import { cn } from '@imphnen-frontend-service/utils';
type TMentorAvatarProps = {
name: string;
size?: 'sm' | 'md' | 'lg';
className?: string;
textClassName?: string;
};
// Warna gradient unik per nama — hash nama → pasangan warna dari palet brand
const GRADIENTS: [string, string][] = [
['#23a1eb', '#0b5b8c'],
['#5b8def', '#3a3f8f'],
['#14b8a6', '#0f766e'],
['#8b5cf6', '#5b21b6'],
['#f59e0b', '#b45309'],
['#ef4444', '#991b1b'],
['#ec4899', '#9d174d'],
['#06b6d4', '#0e7490'],
];
const SIZES: Record<'sm' | 'md' | 'lg', string> = {
sm: 'size-[60px] text-lg md:size-10 lg:size-12',
md: 'size-16 text-xl md:size-20',
lg: 'size-24 text-3xl md:size-32 lg:size-40 md:text-4xl lg:text-5xl',
};
const getInitials = (name: string): string => {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '?';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
};
const getGradient = (name: string): string => {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
}
const [from, to] = GRADIENTS[hash % GRADIENTS.length];
return `linear-gradient(135deg, ${from} 0%, ${to} 100%)`;
};
export const MentorAvatar: FC<TMentorAvatarProps> = ({
name,
size = 'md',
className,
textClassName,
}) => {
const displayName = name || 'Mentor';
return (
<div
className={cn(
'flex items-center justify-center rounded-md overflow-hidden shrink-0 select-none',
SIZES[size],
className
)}
style={{ background: getGradient(displayName) }}
aria-label={`Avatar ${displayName}`}
>
<span
className={cn('font-semibold text-white drop-shadow-sm', textClassName)}
>
{getInitials(displayName)}
</span>
</div>
);
};
@@ -1,74 +0,0 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { For } from '@imphnen-frontend-service/utils';
import { TMentorDetail } from '@imphnen-frontend-service/service';
import { FC } from 'react';
import { Link } from 'react-router-dom';
import { MentorAvatar } from './mentor-avatar';
type TMentorCardProps = {
mentor: TMentorDetail;
};
export const MentorCard: FC<TMentorCardProps> = ({ mentor }) => {
const experience =
mentor.years_of_experience > 0
? `${mentor.years_of_experience} Years Experience`
: 'New Mentor';
const role = mentor.current_role || 'Mentor';
const company = mentor.current_company || 'IMPHNEN';
const softSkills = mentor.expertise || [];
const shownSkills = softSkills.slice(0, 2);
const extraCount = Math.max(0, softSkills.length - shownSkills.length);
return (
<div className="p-2.5 rounded-md bg-white shadow flex gap-x-4 items-start md:p-4 md:flex-col md:rounded-lg md:gap-y-4">
<MentorAvatar
name={mentor.fullname ?? mentor.legal_name ?? 'Mentor'}
size="sm"
className="md:w-full md:h-auto md:aspect-square"
/>
<div>
<Button variant="text" size="sm" className="bg-primary-100 h-auto px-1.5 py-1 text-[8px] font-normal mb-2 hover:bg-primary-100 md:text-[10px] md:font-medium md:mb-4">
{experience}
</Button>
<h2 className="mb-1">
<Link to={`/mentoring/${mentor.id}`} className="text-xs font-semibold text-primary-500 md:text-[15px] md:font-semibold lg:text-[19px]">
{mentor.fullname || mentor.legal_name || 'Mentor'}
</Link>
</h2>
<p className="text-[8px] text-neutral-500 mb-3 md:text-[10px] md:font-medium md:mb-4 lg:text-xs">
{role} at {company}
</p>
{softSkills.length > 0 && (
<div>
<p className="text-[8px] text-neutral-500 mb-1 md:mb-2 lg:text-[10px]">Soft Skills :</p>
<div className="space-x-2">
<For data={shownSkills}>
{(skill) => (
<Button
key={skill}
variant="text"
size="sm"
className="bg-primary-200 h-auto px-1.5 py-1 text-[8px] font-normal hover:bg-primary-200 lg:text-[10px]"
>
{skill}
</Button>
)}
</For>
{extraCount > 0 && (
<Button
variant="text"
size="sm"
className="h-auto px-1.5 py-1 text-[8px] font-normal text-neutral-500 hover:bg-transparent lg:text-[10px]"
>
+{extraCount}
</Button>
)}
</div>
</div>
)}
</div>
</div>
)
}
@@ -1,73 +0,0 @@
import { CloudServerOutlined, CodeOutlined, DatabaseOutlined, ProjectOutlined, TabletOutlined } from '@ant-design/icons'
import { Icon } from '@iconify/react'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { For } from '@imphnen-frontend-service/utils'
import { FC, ReactElement, useRef } from 'react'
import { motion, useInView, Variants } from 'framer-motion'
const TOPICS: {
name: string
icon: React.ReactNode
}[] = [
{ name: 'UI/UX & Design', icon: <TabletOutlined /> },
{ name: 'Software/Web Dev', icon: <CodeOutlined /> },
{ name: 'Data & AI', icon: <DatabaseOutlined /> },
{ name: 'Cloud & DevOps', icon: <CloudServerOutlined /> },
{ name: 'Cybersecurity', icon: <Icon icon="mage:security-shield" /> },
{ name: 'IT & Networking', icon: <Icon icon="carbon:data-structured" /> },
{ name: 'Project Management', icon: <ProjectOutlined /> },
{ name: 'QA & Testing', icon: <Icon icon="carbon:exam-mode" /> }
]
export const Topics: FC = (): ReactElement => {
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.3,
delay: 0.4,
},
},
}
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 (
<div ref={ref}>
<motion.div
className="grid grid-cols-4 gap-6 px-3 md:px-12 md:gap-x-10 md:gap-y-14 lg:grid-cols-8 lg:px-0"
variants={containerVariants}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
>
<For data={TOPICS}>
{(topic) => (
<motion.div key={topic.name} className="flex flex-col items-center" variants={childVariants}>
<Button variant="secondary" size='sm' className="size-7 mb-3 p-0 md:mb-4 md:size-8 md:text-base lg:size-10 lg:text-lg">
{topic.icon}
</Button>
<p className="text-[8px] text-neutral-700 text-center break-words md:text-xs md:font-medium lg:text-[15px]">
{topic.name}
</p>
</motion.div>
)}
</For>
</motion.div>
</div>
)
}

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