Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
738b0b5728 | ||
|
|
edc8fff13a | ||
|
|
9a0fee65a5 | ||
|
|
376e646c10 | ||
|
|
f8cd57a1cf | ||
|
|
5618a8e99b | ||
|
|
598bd22b5a | ||
|
|
246adb57a1 | ||
|
|
75839271ad |
@@ -1,4 +0,0 @@
|
||||
VITE_API_URL=
|
||||
|
||||
CMS_SECRET=
|
||||
CMS_POSTGRES_URL=
|
||||
@@ -24,5 +24,14 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Set env vars
|
||||
run: |
|
||||
echo "CMS_SECRET=${{ secrets.CMS_SECRET }}" >> $GITHUB_ENV
|
||||
echo "CMS_STORAGE_REGION=${{ secrets.CMS_STORAGE_REGION }}" >> $GITHUB_ENV
|
||||
echo "CMS_STORAGE_ENDPOINT=${{ secrets.CMS_STORAGE_ENDPOINT }}" >> $GITHUB_ENV
|
||||
echo "CMS_STORAGE_ACCESS_KEY_ID=${{ secrets.CMS_STORAGE_ACCESS_KEY_ID }}" >> $GITHUB_ENV
|
||||
echo "CMS_STORAGE_SECRET_ACCESS_KEY=${{ secrets.CMS_STORAGE_SECRET_ACCESS_KEY }}" >> $GITHUB_ENV
|
||||
echo "CMS_POSTGRES_URL=${{ secrets.CMS_POSTGRES_URL }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Nx Build
|
||||
run: npx nx run-many --target=build --all
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
|
||||
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 internal management.
|
||||
3. **Dimentorin** - Application for mentoring services.
|
||||
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>.
|
||||
|
||||
## How to install
|
||||
|
||||
@@ -24,6 +25,15 @@ 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:
|
||||
@@ -40,6 +50,10 @@ Use the following commands to run in development mode:
|
||||
```sh
|
||||
npm run dimentorin:dev
|
||||
```
|
||||
- **Landing Page**:
|
||||
```sh
|
||||
npm run landing:dev
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
@@ -57,6 +71,10 @@ Use the following commands to build the applications:
|
||||
```sh
|
||||
npm run dimentorin:build
|
||||
```
|
||||
- **Landing Page**:
|
||||
```sh
|
||||
npm run landing:build
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
@@ -74,19 +92,22 @@ 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**
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
VITE_API_URL=
|
||||
+2
-2
@@ -18,7 +18,7 @@ import {
|
||||
RowSelectionState,
|
||||
} from '@tanstack/react-table';
|
||||
import ModalEditAccount from './_components/modal-edit-account';
|
||||
import { useQueryState } from '../../hook/use-query-state';
|
||||
import { useQueryState } from '@imphnen-frontend-service/utils';
|
||||
|
||||
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)} />
|
||||
<Filter onClose={() => setShowFilter(false)} options={[]} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
+1
-1
@@ -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 '../../hook/use-query-state';
|
||||
import { useQueryState } from '@imphnen-frontend-service/utils';
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
const [showModalAddItem, setShowModalAddItem] = useState(false);
|
||||
+4
-9
@@ -2,24 +2,19 @@ import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
authLoginSchema,
|
||||
TLoginRequest,
|
||||
usePostLogin,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from 'sonner';
|
||||
import { useSession } from '@imphnen-frontend-service/utils';
|
||||
|
||||
export const useLogin = () => {
|
||||
const postLogin = usePostLogin();
|
||||
const form = useForm<TLoginRequest>({
|
||||
resolver: zodResolver(authLoginSchema),
|
||||
mode: 'all',
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => {
|
||||
postLogin.mutate(data, {
|
||||
onSuccess: () => toast.success("Login sukses"),
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
});
|
||||
const { signIn } = useSession();
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => signIn(data));
|
||||
|
||||
return {
|
||||
form,
|
||||
+10
-1
@@ -32,7 +32,16 @@ export const Components: FC = (): ReactElement => {
|
||||
size="lg"
|
||||
className="w-full"
|
||||
/>
|
||||
<Button type="submit" size="md" className="w-full">
|
||||
<Button
|
||||
disabled={
|
||||
form.formState.isSubmitting ||
|
||||
form.formState.isValidating ||
|
||||
!form.formState.isValid
|
||||
}
|
||||
type="submit"
|
||||
size="md"
|
||||
className="w-full"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</form>
|
||||
@@ -1,18 +0,0 @@
|
||||
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;
|
||||
@@ -1,18 +0,0 @@
|
||||
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;
|
||||
@@ -1,18 +0,0 @@
|
||||
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;
|
||||
@@ -1,18 +0,0 @@
|
||||
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;
|
||||
@@ -1,18 +0,0 @@
|
||||
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;
|
||||
@@ -1,18 +0,0 @@
|
||||
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;
|
||||
@@ -1,11 +1,108 @@
|
||||
import { LoaderFunctionArgs } from 'react-router-dom';
|
||||
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');
|
||||
// };
|
||||
|
||||
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 (pathname) return null;
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
VITE_API_URL=
|
||||
@@ -2,24 +2,19 @@ import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
authLoginSchema,
|
||||
TLoginRequest,
|
||||
usePostLogin,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from 'sonner';
|
||||
import { useSession } from '@imphnen-frontend-service/utils';
|
||||
|
||||
export const useLogin = () => {
|
||||
const postLogin = usePostLogin();
|
||||
const form = useForm<TLoginRequest>({
|
||||
resolver: zodResolver(authLoginSchema),
|
||||
mode: 'all',
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => {
|
||||
postLogin.mutate(data, {
|
||||
onSuccess: () => toast.success("Login sukses"),
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
});
|
||||
const { signIn } = useSession();
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => signIn(data));
|
||||
|
||||
return {
|
||||
form,
|
||||
|
||||
@@ -1,11 +1,109 @@
|
||||
import { LoaderFunctionArgs } from 'react-router-dom';
|
||||
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');
|
||||
// };
|
||||
|
||||
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 (pathname) return null;
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
VITE_API_URL=
|
||||
@@ -2,22 +2,19 @@ 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';
|
||||
|
||||
export const useLogin = () => {
|
||||
const postLogin = usePostLogin();
|
||||
const form = useForm<TLoginRequest>({
|
||||
resolver: zodResolver(authLoginSchema),
|
||||
mode: 'all',
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => {
|
||||
postLogin.mutate(data, {
|
||||
onSuccess: () => console.log('Success Login'),
|
||||
});
|
||||
});
|
||||
const { signIn } = useSession();
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => signIn(data));
|
||||
|
||||
return {
|
||||
form,
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
authRegisterSchema,
|
||||
stepOneRegisterSchema,
|
||||
TRegisterRequest,
|
||||
usePostRegister,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from 'sonner';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const useRegister = () => {
|
||||
const postRegister = usePostRegister();
|
||||
@@ -21,31 +18,12 @@ export const useRegister = () => {
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
phone_number: '',
|
||||
referral_code: '',
|
||||
referred_by: '',
|
||||
student_type: '',
|
||||
},
|
||||
});
|
||||
|
||||
const [stepValid, setStepValid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = form.watch(() => {
|
||||
const { fullname, email, password, confirm_password } = form.getValues();
|
||||
const valid = stepOneRegisterSchema.safeParse({
|
||||
fullname,
|
||||
email,
|
||||
password,
|
||||
confirm_password,
|
||||
}).success;
|
||||
setStepValid(valid);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form]);
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => {
|
||||
postRegister.mutate(data, {
|
||||
onSuccess: (data) => toast.success(data.message),
|
||||
onSuccess: () => toast.success('Registrasi sukses'),
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
});
|
||||
@@ -53,6 +31,5 @@ export const useRegister = () => {
|
||||
return {
|
||||
form,
|
||||
onSubmit,
|
||||
isStepOneValid: stepValid,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,11 +1,109 @@
|
||||
import { LoaderFunctionArgs } from 'react-router-dom';
|
||||
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');
|
||||
// };
|
||||
|
||||
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 (pathname) return null;
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_URL=
|
||||
@@ -1,20 +1,8 @@
|
||||
//@ts-check
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { composePlugins, withNx } = require('@nx/next');
|
||||
const { withPayload } = require('@payloadcms/next/withPayload');
|
||||
|
||||
/**
|
||||
* @type {import('@nx/next/plugins/with-nx').WithNxOptions}
|
||||
**/
|
||||
/** @type {import('@nx/next/plugins/with-nx').WithNxOptions} */
|
||||
const nextConfig = {
|
||||
nx: {
|
||||
// Set this to true if you would like to to use SVGR
|
||||
// See: https://github.com/gregberge/svgr
|
||||
svgr: false,
|
||||
},
|
||||
nx: { svgr: false },
|
||||
};
|
||||
|
||||
const plugins = [withNx, withPayload];
|
||||
|
||||
module.exports = composePlugins(...plugins)(nextConfig);
|
||||
module.exports = composePlugins(withNx)(nextConfig);
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 87 KiB |
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@imphnen-frontend-service/shadcn-ui/atoms';
|
||||
import { Button } from '@components';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import { useRef } from 'react';
|
||||
|
||||
@@ -41,7 +41,10 @@ export function CallToAction() {
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-primary to-blue-400 hover:from-primary/90 hover:to-blue-400/90"
|
||||
className="bg-gradient-to-r from-primary to-blue-400 hover:from-primary/90 hover:to-blue-400/90 font-bold text-black hover:text-white cursor-pointer"
|
||||
onClick={() =>
|
||||
window.open('https://discord.gg/imphnen', '_blank')
|
||||
}
|
||||
>
|
||||
Gabung Discord
|
||||
</Button>
|
||||
@@ -49,6 +52,12 @@ export function CallToAction() {
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="border-primary hover:bg-primary/10"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
'https://facebook.com/groups/programmerhandal',
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
>
|
||||
Join Facebook Group
|
||||
</Button>
|
||||
|
||||
@@ -1,54 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Button,
|
||||
FacebookIcon,
|
||||
InstagramIcon,
|
||||
MessageCircleIcon,
|
||||
} from '@imphnen-frontend-service/shadcn-ui/atoms';
|
||||
import { Button } from '@components';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import { useRef } from 'react';
|
||||
|
||||
export function Community() {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
||||
|
||||
const communities = [
|
||||
const items = [
|
||||
{
|
||||
icon: (
|
||||
<FacebookIcon className="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
),
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-900',
|
||||
iconName: 'tabler:brand-facebook',
|
||||
title: 'Facebook Group',
|
||||
description:
|
||||
'Bergabunglah dengan grup Facebook kami untuk diskusi santai dan berbagi artikel menarik.',
|
||||
buttonText: 'Gabung Sekarang',
|
||||
buttonLink: '#',
|
||||
buttonLink: 'https://facebook.com/groups/programmerhandal',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<InstagramIcon className="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
),
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-900',
|
||||
iconName: 'tabler:brand-instagram',
|
||||
title: 'Instagram',
|
||||
description:
|
||||
'Ikuti kami di Instagram untuk tips programming, konten inspiratif, dan info event terbaru.',
|
||||
buttonText: 'Follow Kami',
|
||||
buttonLink: '#',
|
||||
buttonLink: 'https://www.instagram.com/imphnen.dev',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<MessageCircleIcon className="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
),
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-900',
|
||||
iconName: 'tabler:brand-discord-filled',
|
||||
title: 'Discord Server',
|
||||
description:
|
||||
'Diskusikan langsung dengan sesama programmer dan dapatkan bantuan langsung dari para ahli.',
|
||||
buttonText: 'Join Server',
|
||||
buttonLink: '#',
|
||||
buttonLink: 'https://discord.com/invite/imphnen',
|
||||
},
|
||||
];
|
||||
|
||||
const stats = [
|
||||
{ value: '100K+', label: 'Member Aktif' },
|
||||
{ value: '50+', label: 'Event Bulanan' },
|
||||
{ value: '100+', label: 'Mentor Profesional' },
|
||||
{ value: '5K+', label: 'Diskusi Mingguan' },
|
||||
];
|
||||
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
@@ -73,105 +67,74 @@ export function Community() {
|
||||
id="komunitas"
|
||||
className="w-full py-20 md:py-32 bg-muted relative overflow-hidden"
|
||||
>
|
||||
{/* Background Elements */}
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(59,130,246,0.1),transparent_70%)]" />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(59,130,246,0.01)_1px,transparent_1px),linear-gradient(to_bottom,rgba(59,130,246,0.01)_1px,transparent_1px)] bg-[size:14px_14px]" />
|
||||
</div>
|
||||
|
||||
<div className="container px-4 md:px-6" ref={ref}>
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
|
||||
<motion.div
|
||||
className="space-y-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl">
|
||||
Komunitas{' '}
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
Kami
|
||||
</span>
|
||||
</h2>
|
||||
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg">
|
||||
Bergabunglah dengan ribuan programmer Indonesia yang saling
|
||||
membantu dan berbagi pengalaman.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="grid gap-8 md:grid-cols-3"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
>
|
||||
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl text-center mb-4">
|
||||
Komunitas{' '}
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
Kami
|
||||
</span>
|
||||
</h2>
|
||||
<p className="max-w-[800px] mx-auto text-muted-foreground text-center md:text-lg">
|
||||
Bergabunglah dengan ribuan programmer Indonesia yang saling membantu
|
||||
dan berbagi pengalaman.
|
||||
</p>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="grid gap-8 md:grid-cols-3 mt-12"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
>
|
||||
{communities.map((community, index) => (
|
||||
{items.map((c, i) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
key={i}
|
||||
className="group relative overflow-hidden rounded-xl border bg-background p-6 transition-all hover:shadow-xl"
|
||||
variants={itemVariants}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-blue-400/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div
|
||||
className={`mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full ${community.iconBg}`}
|
||||
>
|
||||
{community.icon}
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
|
||||
<Icon
|
||||
icon={c.iconName}
|
||||
className="h-6 w-6 text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-bold">{community.title}</h3>
|
||||
<p className="mb-6 text-muted-foreground">
|
||||
{community.description}
|
||||
</p>
|
||||
<h3 className="mb-2 text-xl font-bold">{c.title}</h3>
|
||||
<p className="mb-6 text-muted-foreground">{c.description}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full group-hover:bg-primary group-hover:text-primary-foreground transition-colors duration-300"
|
||||
onClick={() => window.open(c.buttonLink, '_blank')}
|
||||
>
|
||||
{community.buttonText}
|
||||
{c.buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="absolute -bottom-1 -right-1 w-20 h-20 bg-gradient-to-tl from-primary/20 to-transparent rounded-tl-full opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Community Stats */}
|
||||
<motion.div
|
||||
className="mt-20 grid grid-cols-2 md:grid-cols-4 gap-8 text-center"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
10K+
|
||||
{stats.map((s, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
{s.value}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{s.label}</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Member Aktif</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
50+
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Event Bulanan</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
100+
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Mentor Profesional
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
5K+
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Diskusi Mingguan
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,45 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
BookOpenIcon,
|
||||
CodeIcon,
|
||||
LaptopIcon,
|
||||
UsersIcon,
|
||||
} from '@imphnen-frontend-service/shadcn-ui/atoms';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import { useRef } from 'react';
|
||||
|
||||
export function Features() {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <LaptopIcon className="h-6 w-6 text-primary" />,
|
||||
iconName: 'tabler:device-laptop',
|
||||
title: 'Belajar Tanpa Koding',
|
||||
description:
|
||||
'Pelajari konsep programming dengan cara yang mudah dipahami tanpa harus menulis kode yang rumit.',
|
||||
},
|
||||
{
|
||||
icon: <UsersIcon className="h-6 w-6 text-primary" />,
|
||||
iconName: 'tabler:users',
|
||||
title: 'Komunitas Supportif',
|
||||
description:
|
||||
'Bergabunglah dengan komunitas programmer Indonesia yang siap membantu dan berbagi pengalaman.',
|
||||
},
|
||||
{
|
||||
icon: <BookOpenIcon className="h-6 w-6 text-primary" />,
|
||||
iconName: 'tabler:book',
|
||||
title: 'Tutorial Interaktif',
|
||||
description:
|
||||
'Akses tutorial interaktif yang membuat konsep programming lebih mudah untuk dipahami.',
|
||||
},
|
||||
{
|
||||
icon: <CodeIcon className="h-6 w-6 text-primary" />,
|
||||
iconName: 'tabler:code',
|
||||
title: 'Proyek Praktis',
|
||||
description:
|
||||
'Terapkan pengetahuan Anda dalam proyek nyata dengan panduan langkah demi langkah.',
|
||||
},
|
||||
];
|
||||
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
@@ -64,7 +59,6 @@ export function Features() {
|
||||
id="fitur"
|
||||
className="w-full py-20 md:py-32 relative overflow-hidden"
|
||||
>
|
||||
{/* Background Elements */}
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-background via-background to-muted/50" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] rounded-full bg-gradient-to-tr from-primary/5 to-blue-400/5 blur-3xl" />
|
||||
@@ -82,10 +76,7 @@ export function Features() {
|
||||
Fitur Unggulan
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl">
|
||||
Belajar programming dengan{' '}
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
cara yang lebih baik
|
||||
</span>
|
||||
Belajar programming dengan cara yang lebih baik
|
||||
</h2>
|
||||
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg">
|
||||
IMPHNEN hadir dengan berbagai fitur untuk membantu kamu menjadi
|
||||
@@ -110,7 +101,10 @@ export function Features() {
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 group-hover:bg-primary/20 transition-colors">
|
||||
{feature.icon}
|
||||
<Icon
|
||||
icon={feature.iconName}
|
||||
className="h-6 w-6 text-primary"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-bold">{feature.title}</h3>
|
||||
<p className="text-muted-foreground">{feature.description}</p>
|
||||
|
||||
@@ -188,7 +188,10 @@ export default function Footer() {
|
||||
placeholder="Email Anda"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<button className="inline-flex items-center justify-center rounded-md bg-primary px-3 py-1 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50">
|
||||
<button
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary px-3 py-1 text-sm shadow hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50
|
||||
font-bold text-black hover:text-white cursor-pointer"
|
||||
>
|
||||
Daftar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Button,
|
||||
MenuIcon,
|
||||
XIcon,
|
||||
} from '@imphnen-frontend-service/shadcn-ui/atoms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { Button, MenuIcon, XIcon } from '@components';
|
||||
import { cn } from '@utils';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -35,13 +31,15 @@ export function Header() {
|
||||
<div className="container flex h-16 items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative overflow-hidden rounded">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="IMPHNEN Logo"
|
||||
width={64}
|
||||
height={64}
|
||||
className="object-cover"
|
||||
/>
|
||||
<Link href="/">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="IMPHNEN"
|
||||
width={64}
|
||||
height={64}
|
||||
className="object-cover"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,7 +80,13 @@ export function Header() {
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<SimpleThemeToggle />
|
||||
<Button className="hidden md:flex bg-gradient-to-r from-primary to-blue-400 hover:from-primary/90 hover:to-blue-400/90 transition-all duration-300">
|
||||
|
||||
<Button
|
||||
className="hidden md:flex bg-gradient-to-r from-primary to-blue-400 hover:from-primary/90 hover:to-blue-400/90 transition-all duration-300 font-bold text-black hover:text-white cursor-pointer"
|
||||
onClick={() =>
|
||||
window.open('https://discord.com/invite/imphnen', '_blank')
|
||||
}
|
||||
>
|
||||
Gabung Discord
|
||||
</Button>
|
||||
|
||||
@@ -134,9 +138,15 @@ export function Header() {
|
||||
>
|
||||
Testimoni
|
||||
</Link>
|
||||
<Button className="mt-4 bg-gradient-to-r from-primary to-blue-400 hover:from-primary/90 hover:to-blue-400/90">
|
||||
Gabung Discord
|
||||
</Button>
|
||||
<a
|
||||
href="https://discord.com/invite/imphnen"
|
||||
target="_blank"
|
||||
className="mt-4"
|
||||
>
|
||||
<Button className="mt-4 bg-gradient-to-r from-primary to-blue-400 hover:from-primary/90 hover:to-blue-400/90 font-bold text-black hover:text-white cursor-pointer">
|
||||
Gabung Discord
|
||||
</Button>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Button,
|
||||
CodeIcon,
|
||||
SparklesIcon,
|
||||
UsersIcon,
|
||||
} from '@imphnen-frontend-service/shadcn-ui/atoms';
|
||||
import { Button, CodeIcon, SparklesIcon, UsersIcon } from '@components';
|
||||
import { motion } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
|
||||
export function Hero() {
|
||||
const stats = [
|
||||
{ value: '180K+', label: 'Member' },
|
||||
{ value: '500+', label: 'Tutorial' },
|
||||
{ value: '24/7', label: 'Yapping' },
|
||||
];
|
||||
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -24,11 +25,8 @@ export function Hero() {
|
||||
|
||||
return (
|
||||
<section className="relative w-full py-20 md:py-32 lg:py-40 overflow-hidden">
|
||||
{/* Background Elements */}
|
||||
<div className="absolute inset-0 -z-10 overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-b from-background to-background/50" />
|
||||
|
||||
{/* Animated Gradient Orbs */}
|
||||
<div
|
||||
className="absolute top-1/4 -left-20 w-80 h-80 rounded-full bg-gradient-to-r from-primary/20 to-blue-400/20 blur-3xl"
|
||||
style={{
|
||||
@@ -43,8 +41,6 @@ export function Hero() {
|
||||
opacity: Math.max(0.2, 1 - scrollY * 0.001),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Grid Pattern */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(59,130,246,0.05)_1px,transparent_1px),linear-gradient(to_right,rgba(59,130,246,0.05)_1px,transparent_1px)] bg-[size:40px_40px]" />
|
||||
</div>
|
||||
|
||||
@@ -56,13 +52,13 @@ export function Hero() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="inline-flex items-center rounded-full border px-3 py-1 text-sm">
|
||||
<div className="inline-flex items-center rounded-full border px-3 py-1 text-sm w-fit">
|
||||
<SparklesIcon className="mr-1 h-3.5 w-3.5 text-primary" />
|
||||
<span>Komunitas Programmer Indonesia</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl lg:text-7xl bg-clip-text text-transparent bg-gradient-to-r from-foreground via-foreground to-foreground/70">
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-foreground via-foreground to-foreground/70">
|
||||
Programmer Handal, <br />
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
Tanpa Ribet
|
||||
@@ -75,72 +71,69 @@ export function Hero() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<a href="https://facebook.com">
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-primary to-blue-400 hover:from-primary/90 hover:to-blue-400/90 transition-all duration-300"
|
||||
>
|
||||
Mulai Belajar
|
||||
</Button>
|
||||
</a>
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full sm:w-auto bg-gradient-to-r from-primary to-blue-400 hover:from-primary/90 hover:to-blue-400/90 transition-all duration-300 font-bold text-black hover:text-white cursor-pointer"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
'https://facebook.com/groups/programmerhandal',
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
>
|
||||
Mulai Belajar
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="group relative overflow-hidden border-primary"
|
||||
className="w-full sm:w-auto group relative overflow-hidden border-primary"
|
||||
onClick={() =>
|
||||
window.open('https://discord.com/invite/imphnen', '_blank')
|
||||
}
|
||||
>
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-primary/10 to-blue-400/10 translate-y-full group-hover:translate-y-0 transition-transform duration-300"></span>
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-primary/10 to-blue-400/10 translate-y-full group-hover:translate-y-0 transition-transform duration-300" />
|
||||
<span className="relative">Gabung Discord</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
100K+
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Member</div>
|
||||
</div>
|
||||
<div className="h-10 border-r border-border"></div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
500+
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Tutorial</div>
|
||||
</div>
|
||||
<div className="h-10 border-r border-border"></div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
24/7
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Yapping</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-6 sm:gap-8">
|
||||
{stats.map(({ value, label }, i) => (
|
||||
<Fragment key={i}>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
{value}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
</div>
|
||||
{i < stats.length - 1 && (
|
||||
<div className="hidden sm:block h-10 border-r border-border mx-4" />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="relative mx-auto lg:ml-auto"
|
||||
className="relative w-full lg:w-auto mx-auto lg:ml-auto"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
<div className="relative">
|
||||
{/* Decorative Elements */}
|
||||
<div className="absolute -top-6 -left-6 w-12 h-12 rounded-lg border border-primary/30 bg-background/50 backdrop-blur-sm flex items-center justify-center">
|
||||
<CodeIcon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="absolute -bottom-6 -right-6 w-12 h-12 rounded-lg border border-blue-400/30 bg-background/50 backdrop-blur-sm flex items-center justify-center">
|
||||
<UsersIcon className="h-6 w-6 text-blue-400" />
|
||||
</div>
|
||||
|
||||
{/* Main Image */}
|
||||
<div className="relative z-10 rounded-2xl overflow-hidden border shadow-2xl">
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-primary/10 to-blue-400/10" />
|
||||
<Image
|
||||
src="/hero.jpeg"
|
||||
src="/logo.webp"
|
||||
alt="logo"
|
||||
width={600}
|
||||
height={500}
|
||||
alt="IMPHNEN Programming Community"
|
||||
className="w-full h-auto object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,65 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
BookOpenIcon,
|
||||
Button,
|
||||
CodeIcon,
|
||||
Share2Icon,
|
||||
VideoIcon,
|
||||
} from '@imphnen-frontend-service/shadcn-ui/atoms';
|
||||
import { Button } from '@components';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
|
||||
import { useRef } from 'react';
|
||||
|
||||
export function LearningResources() {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
||||
|
||||
const resources = [
|
||||
{
|
||||
icon: <VideoIcon className="h-6 w-6 text-blue-600 dark:text-blue-400" />,
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-900',
|
||||
icon: 'tabler:video',
|
||||
title: 'Video Tutorial',
|
||||
description:
|
||||
'Belajar melalui tutorial video dari langkah awal hingga mahir.',
|
||||
buttonText: 'Lihat Semua Video',
|
||||
buttonLink: '#',
|
||||
color: 'from-blue-500 to-blue-700',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<BookOpenIcon className="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
),
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-900',
|
||||
icon: 'tabler:article',
|
||||
title: 'Artikel & Tutorial',
|
||||
description:
|
||||
'Pelajari konsep programming melalui artikel yang disusun secara terstruktur.',
|
||||
buttonText: 'Baca Artikel',
|
||||
buttonLink: '#',
|
||||
color: 'from-blue-500 to-blue-700',
|
||||
},
|
||||
{
|
||||
icon: <CodeIcon className="h-6 w-6 text-blue-600 dark:text-blue-400" />,
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-900',
|
||||
icon: 'tabler:brand-vscode',
|
||||
title: 'Tantangan Koding',
|
||||
description:
|
||||
'Uji kemampuan koding kamu dengan tantangan yang menyenangkan dan menantang.',
|
||||
buttonText: 'Mulai Tantangan',
|
||||
buttonLink: '#',
|
||||
color: 'from-blue-500 to-blue-700',
|
||||
},
|
||||
{
|
||||
icon: <Share2Icon className="h-6 w-6 text-blue-600 dark:text-blue-400" />,
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-900',
|
||||
icon: 'tabler:device-desktop-share',
|
||||
title: 'Sharing Session',
|
||||
description:
|
||||
'Ikuti sesi berbagi pengalaman dari programmer berpengalaman dan belajar dari pengalaman mereka.',
|
||||
buttonText: 'Jadwal Session',
|
||||
buttonLink: '#',
|
||||
color: 'from-blue-500 to-blue-700',
|
||||
},
|
||||
];
|
||||
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
@@ -84,26 +68,20 @@ export function LearningResources() {
|
||||
id="sumber-belajar"
|
||||
className="w-full py-20 md:py-32 relative overflow-hidden"
|
||||
>
|
||||
{/* Background Elements */}
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-background via-background to-background" />
|
||||
<div className="absolute top-0 right-0 w-1/2 h-1/2 bg-gradient-to-bl from-primary/5 to-transparent blur-3xl" />
|
||||
<div className="absolute bottom-0 left-0 w-1/2 h-1/2 bg-gradient-to-tr from-blue-400/5 to-transparent blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="container px-4 md:px-6" ref={ref}>
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
|
||||
<motion.div
|
||||
className="space-y-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl">
|
||||
Sumber{' '}
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
Belajar
|
||||
</span>
|
||||
Sumber Belajar
|
||||
</h2>
|
||||
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg">
|
||||
Akses berbagai materi belajar yang akan membantu kamu menguasai
|
||||
@@ -111,54 +89,51 @@ export function LearningResources() {
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="grid gap-8 md:grid-cols-2 lg:grid-cols-4"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
>
|
||||
{resources.map((resource, index) => (
|
||||
{resources.map((r, i) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="group relative overflow-hidden rounded-xl border bg-background p-6 transition-all hover:shadow-lg"
|
||||
key={i}
|
||||
className="group relative overflow-hidden rounded-xl border bg-background p-6 hover:shadow-lg"
|
||||
variants={itemVariants}
|
||||
>
|
||||
<div className="absolute top-0 left-0 h-1 w-full bg-gradient-to-r from-primary/50 to-blue-400/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div
|
||||
className={`mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full ${resource.iconBg}`}
|
||||
>
|
||||
{resource.icon}
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
|
||||
<Icon
|
||||
icon={r.icon}
|
||||
className="h-6 w-6 text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-bold">{resource.title}</h3>
|
||||
<p className="mb-6 text-muted-foreground">
|
||||
{resource.description}
|
||||
</p>
|
||||
<h3 className="mb-2 text-xl font-bold">{r.title}</h3>
|
||||
<p className="mb-6 text-muted-foreground">{r.description}</p>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0 h-auto font-medium text-primary hover:text-primary/80"
|
||||
onClick={() => window.open(r.buttonLink, '_blank')}
|
||||
>
|
||||
{resource.buttonText} →
|
||||
{r.buttonText}
|
||||
<Icon icon="tabler:arrow-right" className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="absolute -bottom-32 -right-32 w-64 h-64 bg-gradient-to-tl from-primary/10 to-transparent rounded-full opacity-0 group-hover:opacity-100 transition-all duration-500 group-hover:-translate-y-10 group-hover:-translate-x-10" />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Featured Resource */}
|
||||
<motion.div
|
||||
className="mt-20 rounded-xl overflow-hidden border bg-background/50 backdrop-blur-sm"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-0">
|
||||
<div className="p-8 md:p-12 flex flex-col justify-center">
|
||||
<div className="inline-block rounded-full bg-primary/10 px-4 py-1.5 text-sm font-medium text-primary mb-4">
|
||||
<div className="inline-block rounded-full bg-primary/10 px-4 py-1.5 text-sm font-medium text-primary mb-4 w-fit">
|
||||
Rekomendasi Terbaik
|
||||
</div>
|
||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">
|
||||
@@ -170,10 +145,18 @@ export function LearningResources() {
|
||||
menengah.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Button className="bg-gradient-to-r from-primary to-blue-400 hover:from-primary/90 hover:to-blue-400/90">
|
||||
<Button
|
||||
className="bg-gradient-to-r from-primary to-blue-400 hover:from-primary/90 hover:to-blue-400/90 font-bold text-black hover:text-white cursor-pointer"
|
||||
onClick={() => window.open('/', '_blank')}
|
||||
>
|
||||
Mulai Kursus
|
||||
</Button>
|
||||
<Button variant="outline">Lihat Silabus</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.open('/', '_blank')}
|
||||
>
|
||||
Lihat Silabus
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-64 md:h-auto">
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Button,
|
||||
MoonIcon,
|
||||
SunIcon,
|
||||
} from '@imphnen-frontend-service/shadcn-ui/atoms';
|
||||
import { Button, MoonIcon, SunIcon } from '@components';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
@@ -29,7 +25,7 @@ export function SimpleThemeToggle() {
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
className="focus-visible:ring-0"
|
||||
className="focus-visible:ring-0 cursor-pointer"
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<SunIcon className="h-[1.2rem] w-[1.2rem]" />
|
||||
|
||||
@@ -1,87 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { QuoteIcon } from '@imphnen-frontend-service/shadcn-ui/atoms';
|
||||
import { QuoteIcon } from '@components';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import { useRef } from 'react';
|
||||
|
||||
export function Testimonials() {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
||||
|
||||
const testimonials = [
|
||||
const items = [
|
||||
{
|
||||
quote:
|
||||
'Saya yang tadinya tidak mengerti apa-apa tentang programming, sekarang bisa membuat website sendiri dengan mudah. Terima kasih IMPHNEN!',
|
||||
name: 'Budi Santoso',
|
||||
role: 'Web Developer Pemula',
|
||||
avatar: '/placeholder.svg?height=64&width=64',
|
||||
avatar: '',
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'Komunitas yang sangat supportif! Setiap pertanyaan selalu dijawab dengan cepat dan jelas. Diskusi programnya mudah dipahami.',
|
||||
name: 'Anita Ratna',
|
||||
role: 'Mobile App Developer',
|
||||
avatar: '/placeholder.svg?height=64&width=64',
|
||||
avatar: '',
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'Server Discord IMPHNEN adalah tempat belajar terbaik untuk programmer pemula seperti saya. Materinya lengkap dan komunitasnya sangat membantu!',
|
||||
name: 'Dedi Permana',
|
||||
role: 'Data Scientist',
|
||||
avatar: '/placeholder.svg?height=64&width=64',
|
||||
avatar: '',
|
||||
},
|
||||
];
|
||||
|
||||
const stats = [
|
||||
{ value: '98%', label: 'Tingkat Kemalasan' },
|
||||
{ value: '4.9/5', label: 'Rating Drama' },
|
||||
{ value: '85%', label: 'Mendapat Pekerjaan' },
|
||||
{ value: '24/7', label: 'Yapping' },
|
||||
];
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(ref, { once: true, amount: 0.2 });
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
visible: { opacity: 1, transition: { staggerChildren: 0.1 } },
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: { duration: 0.5 },
|
||||
},
|
||||
visible: { y: 0, opacity: 1, transition: { duration: 0.5 } },
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id="testimoni"
|
||||
className="w-full py-20 md:py-32 bg-muted relative overflow-hidden"
|
||||
ref={ref}
|
||||
>
|
||||
{/* Background Elements */}
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.1),transparent_50%)]" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom,rgba(96,165,250,0.1),transparent_50%)]" />
|
||||
</div>
|
||||
|
||||
<div className="container px-4 md:px-6" ref={ref}>
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
|
||||
<motion.div
|
||||
className="space-y-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl">
|
||||
Testimoni{' '}
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
Member
|
||||
</span>
|
||||
</h2>
|
||||
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg">
|
||||
Apa kata mereka yang telah bergabung dengan komunitas IMPHNEN?
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
<div className="container px-4 md:px-6">
|
||||
<motion.div
|
||||
className="flex flex-col items-center justify-center space-y-4 text-center mb-16"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl lg:text-5xl">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
Testimoni
|
||||
</span>
|
||||
<span> Member</span>
|
||||
</h2>
|
||||
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg">
|
||||
Apa kata mereka yang telah bergabung dengan komunitas IMPHNEN?
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="grid gap-8 md:grid-cols-3"
|
||||
@@ -89,60 +85,50 @@ export function Testimonials() {
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
>
|
||||
{testimonials.map((testimonial, index) => (
|
||||
{items.map((t, idx) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
key={idx}
|
||||
className="group relative overflow-hidden rounded-xl border bg-background p-6 transition-all hover:shadow-lg"
|
||||
variants={itemVariants}
|
||||
>
|
||||
<div className="absolute top-6 right-6 text-primary/20 group-hover:text-primary/40 transition-colors">
|
||||
<QuoteIcon className="h-8 w-8" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<p className="mb-6 text-muted-foreground italic">
|
||||
“{testimonial.quote}”
|
||||
“{t.quote}”
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative h-12 w-12 overflow-hidden rounded-full border-2 border-primary/20">
|
||||
<Image
|
||||
src={testimonial.avatar || '/placeholder.svg'}
|
||||
alt={testimonial.name}
|
||||
width={48}
|
||||
height={48}
|
||||
src={t.avatar}
|
||||
alt={t.name}
|
||||
width={600}
|
||||
height={500}
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold">{testimonial.name}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{testimonial.role}
|
||||
</p>
|
||||
<h4 className="font-bold">{t.name}</h4>
|
||||
<p className="text-sm text-muted-foreground">{t.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute -bottom-1 -left-1 w-20 h-20 bg-gradient-to-tr from-primary/10 to-transparent rounded-tr-full opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Testimonial Stats */}
|
||||
<motion.div
|
||||
className="mt-20 rounded-xl overflow-hidden border bg-background/50 backdrop-blur-sm p-8 md:p-12"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-8 items-center">
|
||||
<div>
|
||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">
|
||||
Bergabunglah dengan{' '}
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
10,000+
|
||||
</span>{' '}
|
||||
programmer Indonesia lainnya
|
||||
Bergabunglah dengan 10,000+ programmer Indonesia lainnya
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Komunitas kami terus berkembang dengan programmer dari berbagai
|
||||
@@ -151,36 +137,14 @@ export function Testimonials() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="rounded-lg border p-4 text-center">
|
||||
<div className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
98%
|
||||
{stats.map((s, idx) => (
|
||||
<div key={idx} className="rounded-lg border p-4 text-center">
|
||||
<div className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
{s.value}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{s.label}</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Tingkat Kemalasan
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 text-center">
|
||||
<div className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
4.9/5
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Rating Drama
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 text-center">
|
||||
<div className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
85%
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Mendapat Pekerjaan
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 text-center">
|
||||
<div className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
|
||||
24/7
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Yapping</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import '../../styles/globals.css';
|
||||
import Footer from './_components/footer';
|
||||
import { Header } from './_components/header';
|
||||
import { ThemeProvider } from './_components/theme-provider';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
@@ -24,7 +26,11 @@ export default function RootLayout({
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import { CallToAction } from './_components/call-to-action';
|
||||
import { Community } from './_components/community';
|
||||
import { Features } from './_components/features';
|
||||
import Footer from './_components/footer';
|
||||
import { Header } from './_components/header';
|
||||
import { Hero } from './_components/hero';
|
||||
import { LearningResources } from './_components/learning-resources';
|
||||
import { Testimonials } from './_components/testimonials';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">
|
||||
<Hero />
|
||||
<Features />
|
||||
<Community />
|
||||
<LearningResources />
|
||||
<Testimonials />
|
||||
<CallToAction />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
<>
|
||||
<Hero />
|
||||
<Features />
|
||||
<Community />
|
||||
<LearningResources />
|
||||
<Testimonials />
|
||||
<CallToAction />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views';
|
||||
import config from '../../../../payload.config';
|
||||
import { importMap } from '../importMap';
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[];
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({
|
||||
params,
|
||||
searchParams,
|
||||
}: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams });
|
||||
|
||||
const NotFound = ({ params, searchParams }: Args) =>
|
||||
NotFoundPage({ config, params, searchParams, importMap });
|
||||
|
||||
export default NotFound;
|
||||
@@ -1,27 +0,0 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { RootPage, generatePageMetadata } from '@payloadcms/next/views';
|
||||
import config from '../../../../payload.config';
|
||||
import { importMap } from '../importMap';
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[];
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({
|
||||
params,
|
||||
searchParams,
|
||||
}: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams });
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config, params, searchParams, importMap });
|
||||
|
||||
export default Page;
|
||||
@@ -1 +0,0 @@
|
||||
export const importMap = {};
|
||||
@@ -1,19 +0,0 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import '@payloadcms/next/css';
|
||||
import {
|
||||
REST_DELETE,
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_PUT,
|
||||
} from '@payloadcms/next/routes';
|
||||
import config from '../../../../payload.config';
|
||||
|
||||
export const GET = REST_GET(config);
|
||||
export const POST = REST_POST(config);
|
||||
export const DELETE = REST_DELETE(config);
|
||||
export const PATCH = REST_PATCH(config);
|
||||
export const PUT = REST_PUT(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
@@ -1,7 +0,0 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import '@payloadcms/next/css';
|
||||
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes';
|
||||
import config from '../../../../payload.config';
|
||||
|
||||
export const GET = GRAPHQL_PLAYGROUND_GET(config);
|
||||
@@ -1,8 +0,0 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes';
|
||||
import config from '../../../../payload.config';
|
||||
|
||||
export const POST = GRAPHQL_POST(config);
|
||||
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
@@ -1,35 +0,0 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import '@payloadcms/next/css';
|
||||
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts';
|
||||
import type { ServerFunctionClient } from 'payload';
|
||||
import React from 'react';
|
||||
import config from '../../payload.config';
|
||||
|
||||
import { importMap } from './admin/importMap.js';
|
||||
import './custom.scss';
|
||||
|
||||
type Args = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const serverFunction: ServerFunctionClient = async function (args) {
|
||||
'use server';
|
||||
return handleServerFunctions({
|
||||
...args,
|
||||
config,
|
||||
importMap,
|
||||
});
|
||||
};
|
||||
|
||||
const Layout = ({ children }: Args) => (
|
||||
<RootLayout
|
||||
config={config}
|
||||
importMap={importMap}
|
||||
serverFunction={serverFunction}
|
||||
>
|
||||
{children}
|
||||
</RootLayout>
|
||||
);
|
||||
|
||||
export default Layout;
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'alt',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
upload: true,
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
auth: true,
|
||||
fields: [
|
||||
// Email added by default
|
||||
// Add more fields as needed
|
||||
],
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
import { getPayload } from 'payload';
|
||||
import config from '../payload.config';
|
||||
|
||||
export const payload = await getPayload({ config });
|
||||
@@ -1,285 +0,0 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by Payload.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||
* and re-run `payload generate:types` to regenerate this file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported timezones in IANA format.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "supportedTimezones".
|
||||
*/
|
||||
export type SupportedTimezones =
|
||||
| 'Pacific/Midway'
|
||||
| 'Pacific/Niue'
|
||||
| 'Pacific/Honolulu'
|
||||
| 'Pacific/Rarotonga'
|
||||
| 'America/Anchorage'
|
||||
| 'Pacific/Gambier'
|
||||
| 'America/Los_Angeles'
|
||||
| 'America/Tijuana'
|
||||
| 'America/Denver'
|
||||
| 'America/Phoenix'
|
||||
| 'America/Chicago'
|
||||
| 'America/Guatemala'
|
||||
| 'America/New_York'
|
||||
| 'America/Bogota'
|
||||
| 'America/Caracas'
|
||||
| 'America/Santiago'
|
||||
| 'America/Buenos_Aires'
|
||||
| 'America/Sao_Paulo'
|
||||
| 'Atlantic/South_Georgia'
|
||||
| 'Atlantic/Azores'
|
||||
| 'Atlantic/Cape_Verde'
|
||||
| 'Europe/London'
|
||||
| 'Europe/Berlin'
|
||||
| 'Africa/Lagos'
|
||||
| 'Europe/Athens'
|
||||
| 'Africa/Cairo'
|
||||
| 'Europe/Moscow'
|
||||
| 'Asia/Riyadh'
|
||||
| 'Asia/Dubai'
|
||||
| 'Asia/Baku'
|
||||
| 'Asia/Karachi'
|
||||
| 'Asia/Tashkent'
|
||||
| 'Asia/Calcutta'
|
||||
| 'Asia/Dhaka'
|
||||
| 'Asia/Almaty'
|
||||
| 'Asia/Jakarta'
|
||||
| 'Asia/Bangkok'
|
||||
| 'Asia/Shanghai'
|
||||
| 'Asia/Singapore'
|
||||
| 'Asia/Tokyo'
|
||||
| 'Asia/Seoul'
|
||||
| 'Australia/Brisbane'
|
||||
| 'Australia/Sydney'
|
||||
| 'Pacific/Guam'
|
||||
| 'Pacific/Noumea'
|
||||
| 'Pacific/Auckland'
|
||||
| 'Pacific/Fiji';
|
||||
|
||||
export interface Config {
|
||||
auth: {
|
||||
users: UserAuthOperations;
|
||||
};
|
||||
blocks: {};
|
||||
collections: {
|
||||
users: User;
|
||||
media: Media;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
'payload-preferences': PayloadPreference;
|
||||
'payload-migrations': PayloadMigration;
|
||||
};
|
||||
collectionsJoins: {};
|
||||
collectionsSelect: {
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
media: MediaSelect<false> | MediaSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
};
|
||||
db: {
|
||||
defaultIDType: number;
|
||||
};
|
||||
globals: {};
|
||||
globalsSelect: {};
|
||||
locale: null;
|
||||
user: User & {
|
||||
collection: 'users';
|
||||
};
|
||||
jobs: {
|
||||
tasks: unknown;
|
||||
workflows: unknown;
|
||||
};
|
||||
}
|
||||
export interface UserAuthOperations {
|
||||
forgotPassword: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
login: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
registerFirstUser: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
unlock: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users".
|
||||
*/
|
||||
export interface User {
|
||||
id: number;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
email: string;
|
||||
resetPasswordToken?: string | null;
|
||||
resetPasswordExpiration?: string | null;
|
||||
salt?: string | null;
|
||||
hash?: string | null;
|
||||
loginAttempts?: number | null;
|
||||
lockUntil?: string | null;
|
||||
password?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media".
|
||||
*/
|
||||
export interface Media {
|
||||
id: number;
|
||||
alt: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
url?: string | null;
|
||||
thumbnailURL?: string | null;
|
||||
filename?: string | null;
|
||||
mimeType?: string | null;
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents".
|
||||
*/
|
||||
export interface PayloadLockedDocument {
|
||||
id: number;
|
||||
document?:
|
||||
| ({
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'media';
|
||||
value: number | Media;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-preferences".
|
||||
*/
|
||||
export interface PayloadPreference {
|
||||
id: number;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
};
|
||||
key?: string | null;
|
||||
value?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-migrations".
|
||||
*/
|
||||
export interface PayloadMigration {
|
||||
id: number;
|
||||
name?: string | null;
|
||||
batch?: number | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users_select".
|
||||
*/
|
||||
export interface UsersSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
email?: T;
|
||||
resetPasswordToken?: T;
|
||||
resetPasswordExpiration?: T;
|
||||
salt?: T;
|
||||
hash?: T;
|
||||
loginAttempts?: T;
|
||||
lockUntil?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media_select".
|
||||
*/
|
||||
export interface MediaSelect<T extends boolean = true> {
|
||||
alt?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
url?: T;
|
||||
thumbnailURL?: T;
|
||||
filename?: T;
|
||||
mimeType?: T;
|
||||
filesize?: T;
|
||||
width?: T;
|
||||
height?: T;
|
||||
focalX?: T;
|
||||
focalY?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents_select".
|
||||
*/
|
||||
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
|
||||
document?: T;
|
||||
globalSlug?: T;
|
||||
user?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-preferences_select".
|
||||
*/
|
||||
export interface PayloadPreferencesSelect<T extends boolean = true> {
|
||||
user?: T;
|
||||
key?: T;
|
||||
value?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-migrations_select".
|
||||
*/
|
||||
export interface PayloadMigrationsSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
batch?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "auth".
|
||||
*/
|
||||
export interface Auth {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
declare module 'payload' {
|
||||
export interface GeneratedTypes extends Config {}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { postgresAdapter } from '@payloadcms/db-postgres';
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical';
|
||||
import path from 'path';
|
||||
import { buildConfig } from 'payload';
|
||||
import sharp from 'sharp';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { Media } from './collections/Media';
|
||||
import { Users } from './collections/Users';
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
user: Users.slug,
|
||||
importMap: {
|
||||
importMapFile: path.resolve(
|
||||
__dirname,
|
||||
'app',
|
||||
'(payload)',
|
||||
'admin',
|
||||
'importMap.js'
|
||||
),
|
||||
},
|
||||
},
|
||||
collections: [Users, Media],
|
||||
editor: lexicalEditor(),
|
||||
secret: process.env.CMS_SECRET || '',
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
},
|
||||
db: postgresAdapter({
|
||||
pool: {
|
||||
connectionString: process.env.CMS_POSTGRES_URL || '',
|
||||
},
|
||||
}),
|
||||
sharp,
|
||||
plugins: [],
|
||||
});
|
||||
+10
-12
@@ -11,11 +11,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
@@ -24,7 +20,14 @@
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@components": ["../../libs/shadcn-ui/src/atoms/index.ts"],
|
||||
"@utils": ["../../libs/shadcn-ui/src/lib/index.ts"],
|
||||
"@schemas": ["../../libs/service/src/schemas/index.ts"],
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.js",
|
||||
@@ -36,10 +39,5 @@
|
||||
"next-env.d.ts",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"jest.config.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts"
|
||||
]
|
||||
"exclude": ["node_modules", "jest.config.ts", "**/*.spec.ts", "**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
TRegisterRequest,
|
||||
TVerifyEmailRequest,
|
||||
} from '../../types/auth';
|
||||
import { SessionToken, SessionUser } from '@imphnen-frontend-service/utils';
|
||||
|
||||
import { TResponseError, TResponseMessage } from '../../types/common';
|
||||
|
||||
@@ -19,11 +18,6 @@ export const usePostLogin = (): UseMutationResult<
|
||||
return useMutation({
|
||||
mutationKey: ['post-login'],
|
||||
mutationFn: async (payload) => await postLogin(payload),
|
||||
onSuccess: (res) => {
|
||||
SessionUser.set(res.data.user);
|
||||
SessionToken.set(res.data.token);
|
||||
window.location.reload();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export const authLoginSchema = z.object({
|
||||
.min(1, 'Password tidak boleh kosong'),
|
||||
});
|
||||
|
||||
export const stepOneRegisterSchema = z
|
||||
export const authRegisterSchema = z
|
||||
.object({
|
||||
email: z
|
||||
.string({
|
||||
@@ -25,6 +25,12 @@ export const stepOneRegisterSchema = z
|
||||
})
|
||||
.min(1, 'Email tidak boleh kosong')
|
||||
.email('Email harus valid'),
|
||||
phone_number: z
|
||||
.string({
|
||||
required_error: 'Nomor telepon tidak boleh kosong',
|
||||
})
|
||||
.min(1, 'Nomor telepon tidak boleh kosong')
|
||||
.max(15, 'Nomor telepon tidak boleh lebih dari 15 digit'),
|
||||
fullname: z
|
||||
.string({
|
||||
required_error: 'Nama tidak boleh kosong',
|
||||
@@ -51,28 +57,3 @@ export const stepOneRegisterSchema = z
|
||||
message: 'Password dan Konfirmasi Password harus sama',
|
||||
path: ['confirm_password'],
|
||||
});
|
||||
|
||||
const stepTwoRegisterSchema = z.object({
|
||||
phone_number: z
|
||||
.string({
|
||||
required_error: 'Nomor telepon tidak boleh kosong',
|
||||
invalid_type_error: 'Nomor telepon harus berupa string',
|
||||
})
|
||||
.min(1, 'Nomor telepon tidak boleh kosong')
|
||||
.max(15, 'Nomor telepon tidak boleh lebih dari 15 karakter'),
|
||||
referral_code: z
|
||||
.string()
|
||||
.min(1, 'Kode referral tidak boleh kosong')
|
||||
.max(4, 'Kode referral tidak boleh lebih dari 4 karakter')
|
||||
.optional(),
|
||||
referred_by: z
|
||||
.string()
|
||||
.min(1, 'Kode referral tidak boleh kosong')
|
||||
.max(50, 'Kode referral tidak boleh lebih dari 50 karakter')
|
||||
.optional(),
|
||||
student_type: z.string(),
|
||||
});
|
||||
|
||||
export const authRegisterSchema = stepOneRegisterSchema.and(
|
||||
stepTwoRegisterSchema
|
||||
);
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
import { z } from 'zod';
|
||||
import { authLoginSchema, authRegisterSchema } from '../../schemas';
|
||||
import { TResponseDetail, TResponseMessage } from '../common';
|
||||
import { TUserItem } from '../users';
|
||||
|
||||
export type TLoginRequest = {
|
||||
email: string;
|
||||
password: string;
|
||||
export type TTokenItem = {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
};
|
||||
|
||||
export type TLoginResponse = {
|
||||
data: {
|
||||
token: {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
};
|
||||
user: TUserItem;
|
||||
};
|
||||
export type TLoginItem = {
|
||||
token?: TTokenItem;
|
||||
user?: TUserItem;
|
||||
};
|
||||
|
||||
export type TRegisterRequest = {
|
||||
email: string;
|
||||
fullname: string;
|
||||
password: string;
|
||||
phone_number: string;
|
||||
referral_code?: string;
|
||||
referred_by?: string;
|
||||
student_type: string;
|
||||
confirm_password: string;
|
||||
export type TLoginRequest = z.infer<typeof authLoginSchema>;
|
||||
export type TLoginResponse = TResponseDetail<TLoginItem>;
|
||||
|
||||
export type TRegisterRequest = z.infer<typeof authRegisterSchema>;
|
||||
export type TRegisterResponse = TResponseDetail<TResponseMessage>;
|
||||
|
||||
export type TVerifyOtpRequest = {
|
||||
otp: number;
|
||||
};
|
||||
|
||||
export type TVerifyEmailRequest = {
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
export type TResponse<T = unknown> = {
|
||||
export type TMetaResponse = {
|
||||
page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type TResponseDetail<T = unknown> = {
|
||||
data: T;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type TResponseList<T = unknown> = {
|
||||
data: T[];
|
||||
meta: TMetaResponse;
|
||||
};
|
||||
|
||||
export type TResponseMessage = {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { TPermissionItem } from '../permissions';
|
||||
|
||||
export type TRoleItem = {
|
||||
export type TRoleDetailItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: TPermissionItem[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type TRolesListItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
permissions_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
permissions: TPermissionItem[];
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TRoleItem } from '../roles';
|
||||
import { TRoleDetailItem } from '../roles';
|
||||
|
||||
export type TUserItem = {
|
||||
id: string;
|
||||
@@ -7,13 +7,7 @@ export type TUserItem = {
|
||||
email: string;
|
||||
fullname: string;
|
||||
gender: string;
|
||||
identity_number: string;
|
||||
is_active: boolean;
|
||||
is_profile_completed: boolean;
|
||||
phone_number: string;
|
||||
referral_code: string;
|
||||
referred_by: string;
|
||||
religion: string;
|
||||
student_type: string;
|
||||
role: TRoleItem;
|
||||
role: TRoleDetailItem;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export const cn = (...inputs: ClassValue[]) => {
|
||||
return twMerge(clsx(inputs));
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './cn';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './permissions';
|
||||
@@ -0,0 +1,40 @@
|
||||
export const PERMISSIONS = {
|
||||
USERS: {
|
||||
READ_LIST: 'Read List Users',
|
||||
READ_DETAIL: 'Read Detail Users',
|
||||
CREATE: 'Create Users',
|
||||
UPDATE: 'Update Users',
|
||||
DELETE: 'Delete Users',
|
||||
ACTIVATE: 'Activate Users',
|
||||
},
|
||||
ROLES: {
|
||||
READ_LIST: 'Read List Roles',
|
||||
READ_DETAIL: 'Read Detail Roles',
|
||||
CREATE: 'Create Roles',
|
||||
UPDATE: 'Update Roles',
|
||||
DELETE: 'Delete Roles',
|
||||
},
|
||||
PERMISSIONS: {
|
||||
READ_LIST: 'Read List Permissions',
|
||||
READ_DETAIL: 'Read Detail Permissions',
|
||||
CREATE: 'Create Permissions',
|
||||
UPDATE: 'Update Permissions',
|
||||
DELETE: 'Delete Permissions',
|
||||
},
|
||||
GACHA_CLAIMS: {
|
||||
CREATE: 'Create Gacha Claims',
|
||||
READ_DETAIL: 'Read Detail Gacha Claims',
|
||||
},
|
||||
GACHA_ITEMS: {
|
||||
READ_LIST: 'Read List Gacha Items',
|
||||
READ_DETAIL: 'Read Detail Gacha Items',
|
||||
CREATE: 'Create Gacha Items',
|
||||
UPDATE: 'Update Gacha Items',
|
||||
DELETE: 'Delete Gacha Items',
|
||||
},
|
||||
GACHA_ROLLS: {
|
||||
READ_DETAIL: 'Read Detail Gacha Rolls',
|
||||
CREATE: 'Create Gacha Rolls',
|
||||
EXECUTE: 'Execute Gacha Rolls',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
type TTokenItem = {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
};
|
||||
|
||||
const TOKEN_KEY = 'token';
|
||||
|
||||
type TStoredToken =
|
||||
| {
|
||||
token?: TTokenItem;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
export const SessionToken = {
|
||||
set: (val: TStoredToken) => {
|
||||
Cookies.set(TOKEN_KEY, JSON.stringify(val), {
|
||||
secure: true,
|
||||
sameSite: 'Strict',
|
||||
expires: 7,
|
||||
});
|
||||
},
|
||||
get: (): TStoredToken => {
|
||||
const token = Cookies.get(TOKEN_KEY);
|
||||
if (!token) return undefined;
|
||||
try {
|
||||
return JSON.parse(token);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
remove: () => {
|
||||
Cookies.remove(TOKEN_KEY);
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './use-query-state';
|
||||
export * from './use-session';
|
||||
export * from './use-auth-store';
|
||||
export * from './use-modal-login';
|
||||
export * from './use-session';
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { create } from 'zustand';
|
||||
import { TLoginItem } from '@imphnen-frontend-service/service';
|
||||
import { SessionToken } from '../cookies';
|
||||
import { SessionUser } from '../local-storage';
|
||||
|
||||
export enum ESessionStatus {
|
||||
Authenticated = 'authenticated',
|
||||
Authenticating = 'authenticating',
|
||||
Unauthenticated = 'unauthenticated',
|
||||
}
|
||||
|
||||
type SessionState = {
|
||||
isLoading: boolean;
|
||||
session?: TLoginItem;
|
||||
status?: ESessionStatus;
|
||||
setLoading: (val: boolean) => void;
|
||||
setSession: (payload: TLoginItem) => void;
|
||||
clearSession: () => void;
|
||||
};
|
||||
|
||||
export const useAuthStore = create<SessionState>((set) => {
|
||||
const session = SessionToken.get();
|
||||
const user = SessionUser.get();
|
||||
const isAuthenticated = !!session;
|
||||
|
||||
return {
|
||||
isLoading: false,
|
||||
session: isAuthenticated ? { ...session, ...user } : undefined,
|
||||
status: isAuthenticated
|
||||
? ESessionStatus.Authenticated
|
||||
: ESessionStatus.Unauthenticated,
|
||||
setLoading: (val) => set({ isLoading: val }),
|
||||
setSession: (data) => {
|
||||
SessionToken.set({ token: data.token });
|
||||
SessionUser.set(data.user);
|
||||
set({
|
||||
session: data,
|
||||
status: ESessionStatus.Authenticated,
|
||||
});
|
||||
},
|
||||
clearSession: () => {
|
||||
SessionToken.remove();
|
||||
SessionUser.remove();
|
||||
set({ session: undefined, status: ESessionStatus.Unauthenticated });
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1,22 +1,37 @@
|
||||
import { SessionToken, SessionUser } from '../local-storage';
|
||||
import { TLoginRequest, usePostLogin } from '@imphnen-frontend-service/service';
|
||||
import { useAuthStore } from './';
|
||||
import { toast } from 'sonner';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
export const useSession = () => {
|
||||
const session = {
|
||||
user: SessionUser.get(),
|
||||
token: SessionToken.get(),
|
||||
const navigate = useNavigate();
|
||||
const { mutate, isPending } = usePostLogin();
|
||||
const { setLoading, setSession, clearSession, session } = useAuthStore();
|
||||
const signIn = (payload: TLoginRequest) => {
|
||||
setLoading(true);
|
||||
mutate(payload, {
|
||||
onSuccess: (data) => {
|
||||
toast.success('Login sukses');
|
||||
setSession(data.data);
|
||||
navigate(0);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err?.response?.data?.message ??
|
||||
'Terjadi Kesalahan yang tidak diketahui'
|
||||
);
|
||||
clearSession();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isAuthenticated = !!session.token?.access_token;
|
||||
|
||||
const signOut = () => {
|
||||
SessionUser.remove();
|
||||
SessionToken.remove();
|
||||
window.location.reload();
|
||||
clearSession();
|
||||
navigate(0);
|
||||
};
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
session,
|
||||
signIn,
|
||||
signOut,
|
||||
isLoading: isPending,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,3 +3,6 @@ export * from './react-router';
|
||||
export * from './tailwind-merge';
|
||||
export * from './hooks';
|
||||
export * from './local-storage';
|
||||
export * from './cookies';
|
||||
export * from './session';
|
||||
export * from './constants';
|
||||
|
||||
@@ -20,41 +20,16 @@ type TUserItem = {
|
||||
email: string;
|
||||
fullname: string;
|
||||
gender: string;
|
||||
identity_number: string;
|
||||
is_active: boolean;
|
||||
is_profile_completed: boolean;
|
||||
phone_number: string;
|
||||
referral_code: string;
|
||||
referred_by: string;
|
||||
religion: string;
|
||||
student_type: string;
|
||||
role: TRoleItem;
|
||||
};
|
||||
|
||||
export const SessionUser = {
|
||||
set: (val: TUserItem) => localStorage.setItem('users', JSON.stringify(val)),
|
||||
set: (val?: TUserItem) => localStorage.setItem('users', JSON.stringify(val)),
|
||||
get: (): TUserItem | undefined => {
|
||||
const users = localStorage.getItem('users');
|
||||
return users ? JSON.parse(users) : undefined;
|
||||
},
|
||||
remove: () => localStorage.removeItem('users'),
|
||||
};
|
||||
|
||||
export const SessionToken = {
|
||||
set: (val: { access_token: string; refresh_token: string }) => {
|
||||
localStorage.setItem('access_token', val.access_token);
|
||||
localStorage.setItem('refresh_token', val.refresh_token);
|
||||
},
|
||||
get: ():
|
||||
| { access_token?: string | null; refresh_token?: string | null }
|
||||
| undefined => {
|
||||
return {
|
||||
access_token: localStorage.getItem('access_token'),
|
||||
refresh_token: localStorage.getItem('refresh_token'),
|
||||
};
|
||||
},
|
||||
remove: () => {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { lazy, LazyExoticComponent, ReactElement } from 'react';
|
||||
import { lazy, LazyExoticComponent, ReactNode } from 'react';
|
||||
import { ActionFunction, LoaderFunction, RouteObject } from 'react-router';
|
||||
|
||||
type TPermissionItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
interface PageModuleExports {
|
||||
default: () => ReactElement;
|
||||
default: () => ReactNode;
|
||||
loader?: LoaderFunction;
|
||||
action?: ActionFunction;
|
||||
permissions?: Array<string>;
|
||||
}
|
||||
|
||||
interface LoadingModuleExports {
|
||||
default: () => ReactElement;
|
||||
default: () => ReactNode;
|
||||
}
|
||||
|
||||
interface RouteHandle {
|
||||
@@ -38,7 +45,6 @@ export function convertPagesToRoute(
|
||||
filePath,
|
||||
loadingFiles
|
||||
);
|
||||
|
||||
const route = createRoute({
|
||||
PageComponent: page,
|
||||
LoadingComponent: loadingComponent,
|
||||
@@ -51,6 +57,20 @@ export function convertPagesToRoute(
|
||||
const result = (await importer()) as PageModuleExports;
|
||||
return 'loader' in result ? result.loader?.(args) : null;
|
||||
},
|
||||
async guard() {
|
||||
const result = (await importer()) as PageModuleExports;
|
||||
const localStoragePermission = localStorage.getItem('permissions');
|
||||
const permissions: TPermissionItem[] | undefined =
|
||||
localStoragePermission
|
||||
? JSON.parse(localStoragePermission)
|
||||
: undefined;
|
||||
return 'permissions' in result
|
||||
? result.permissions?.every(
|
||||
(permission) =>
|
||||
permissions?.some((item) => permission === item.name) || false
|
||||
) || false
|
||||
: true;
|
||||
},
|
||||
});
|
||||
routes = mergeRoutes(routes, route);
|
||||
});
|
||||
@@ -62,19 +82,14 @@ function findMatchingLoadingComponent(
|
||||
loadingFiles: Record<string, () => Promise<unknown>>
|
||||
) {
|
||||
const loadingPath = filePath.replace(/(page|layout)\.tsx$/, 'loading.tsx');
|
||||
|
||||
const groupMatch = filePath.match(/\([^/]+\//);
|
||||
const groupLoadingPath = groupMatch ? `/${groupMatch[0]}loading.tsx` : null;
|
||||
|
||||
const globalLoadingPath = './app/loading.tsx';
|
||||
|
||||
const loader =
|
||||
loadingFiles[loadingPath] ||
|
||||
(groupLoadingPath && loadingFiles[groupLoadingPath]) ||
|
||||
loadingFiles[globalLoadingPath];
|
||||
|
||||
if (!loader) return undefined;
|
||||
|
||||
return lazy(loader as () => Promise<LoadingModuleExports>);
|
||||
}
|
||||
|
||||
@@ -86,68 +101,110 @@ function mergeRoutes(
|
||||
throw new Error(
|
||||
`Paths do not match: "${target.path}" and "${source.path}"`
|
||||
);
|
||||
target.children = target.children || [];
|
||||
if (source.handle?.pageType === 'layout') {
|
||||
return handleLayoutMerge(target, source);
|
||||
}
|
||||
if (source.handle?.pageType === 'page') {
|
||||
return handlePageMerge(target, source);
|
||||
}
|
||||
if (source.children && source.children.length > 0) {
|
||||
if (target.handle?.pageType === 'page') {
|
||||
if (!target.children?.some((child) => child.index)) {
|
||||
target.children = target.children || [];
|
||||
target.children.unshift({
|
||||
index: true,
|
||||
element: target.element,
|
||||
HydrateFallback: target.HydrateFallback,
|
||||
action: target.action,
|
||||
loader: target.loader,
|
||||
handle: target.handle,
|
||||
errorElement: target.errorElement,
|
||||
});
|
||||
}
|
||||
delete target.element;
|
||||
delete target.action;
|
||||
delete target.loader;
|
||||
delete target.handle;
|
||||
}
|
||||
mergeChildRoutes(target, source);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export function mergeChildRoutes(
|
||||
target: ExtendedRouteObject,
|
||||
source: ExtendedRouteObject
|
||||
): void {
|
||||
if (!source.children) return;
|
||||
if (!target.children) {
|
||||
target.children = [];
|
||||
}
|
||||
source.children.forEach((sourceChild) => {
|
||||
const matchingChild = target.children!.find(
|
||||
(targetChild) => targetChild.path === sourceChild.path
|
||||
);
|
||||
|
||||
if (source.handle?.pageType === 'layout') {
|
||||
if (!target.element) {
|
||||
target.element = source.element;
|
||||
target.HydrateFallback = source.HydrateFallback;
|
||||
target.action = source.action;
|
||||
target.loader = source.loader;
|
||||
target.handle = source.handle;
|
||||
target.errorElement = source.errorElement;
|
||||
target.children = target.children ?? [];
|
||||
} else if (target.handle?.pageType === 'page') {
|
||||
target = swapTargetRouteAsIndexRouteAndUpdateWithRoute(target, source);
|
||||
if (matchingChild) {
|
||||
mergeRoutes(matchingChild, sourceChild);
|
||||
} else {
|
||||
target.children!.push(sourceChild);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
source.handle?.pageType === 'page' &&
|
||||
!target.children.some((child) => child.index)
|
||||
) {
|
||||
target.children.unshift({
|
||||
index: true,
|
||||
export function handleLayoutMerge(
|
||||
target: ExtendedRouteObject,
|
||||
source: ExtendedRouteObject
|
||||
): ExtendedRouteObject {
|
||||
if (!target.element) {
|
||||
Object.assign(target, {
|
||||
element: source.element,
|
||||
HydrateFallback: source.HydrateFallback,
|
||||
action: source.action,
|
||||
loader: source.loader,
|
||||
handle: source.handle,
|
||||
errorElement: source.errorElement,
|
||||
});
|
||||
return target;
|
||||
} else if (target.handle?.pageType === 'page') {
|
||||
target = swapTargetRouteAsIndexRouteAndUpdateWithRoute(target, source);
|
||||
}
|
||||
|
||||
if (
|
||||
target.handle?.pageType === 'layout' &&
|
||||
source.handle?.pageType === 'page'
|
||||
) {
|
||||
target = addRouteAsIndexRouteForTargetRoute(target, source);
|
||||
return target;
|
||||
}
|
||||
|
||||
if (source.children) {
|
||||
target.children = target.children ?? [];
|
||||
source.children.forEach((sourceChild) => {
|
||||
const matchingChild = target.children?.find(
|
||||
(targetChild) => targetChild.path === sourceChild.path
|
||||
);
|
||||
if (matchingChild) mergeRoutes(matchingChild, sourceChild);
|
||||
else target.children?.push(sourceChild);
|
||||
});
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
function swapTargetRouteAsIndexRouteAndUpdateWithRoute(
|
||||
export function handlePageMerge(
|
||||
target: ExtendedRouteObject,
|
||||
route: ExtendedRouteObject
|
||||
source: ExtendedRouteObject
|
||||
): ExtendedRouteObject {
|
||||
target.children = target.children ?? [];
|
||||
if (!target.children) {
|
||||
target.children = [];
|
||||
}
|
||||
if (
|
||||
!target.children.some((child) => child.index) ||
|
||||
target.handle?.pageType === 'layout'
|
||||
) {
|
||||
if (target.handle?.pageType === 'layout') {
|
||||
addRouteAsIndexRouteForTargetRoute(target, source);
|
||||
} else {
|
||||
target.children.unshift({
|
||||
index: true,
|
||||
element: source.element,
|
||||
HydrateFallback: source.HydrateFallback,
|
||||
action: source.action,
|
||||
loader: source.loader,
|
||||
handle: source.handle,
|
||||
errorElement: source.errorElement,
|
||||
});
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export function swapTargetRouteAsIndexRouteAndUpdateWithRoute(
|
||||
target: ExtendedRouteObject,
|
||||
layout: ExtendedRouteObject
|
||||
): ExtendedRouteObject {
|
||||
target.children = target.children || [];
|
||||
target.children.push({
|
||||
index: true,
|
||||
element: target.element,
|
||||
@@ -157,38 +214,38 @@ function swapTargetRouteAsIndexRouteAndUpdateWithRoute(
|
||||
handle: target.handle,
|
||||
errorElement: target.errorElement,
|
||||
});
|
||||
|
||||
target.element = route.element;
|
||||
target.HydrateFallback = route.HydrateFallback;
|
||||
target.action = route.action;
|
||||
target.loader = route.loader;
|
||||
target.handle = route.handle;
|
||||
target.errorElement = route.errorElement;
|
||||
|
||||
Object.assign(target, {
|
||||
element: layout.element,
|
||||
HydrateFallback: layout.HydrateFallback,
|
||||
action: layout.action,
|
||||
loader: layout.loader,
|
||||
handle: layout.handle,
|
||||
errorElement: layout.errorElement,
|
||||
});
|
||||
return target;
|
||||
}
|
||||
|
||||
function addRouteAsIndexRouteForTargetRoute(
|
||||
export function addRouteAsIndexRouteForTargetRoute(
|
||||
target: ExtendedRouteObject,
|
||||
route: ExtendedRouteObject
|
||||
page: ExtendedRouteObject
|
||||
): ExtendedRouteObject {
|
||||
target.children = target.children ?? [];
|
||||
target.children = target.children || [];
|
||||
target.children.push({
|
||||
index: true,
|
||||
element: route.element,
|
||||
HydrateFallback: route.HydrateFallback,
|
||||
action: route.action,
|
||||
loader: route.loader,
|
||||
handle: route.handle,
|
||||
errorElement: route.errorElement,
|
||||
element: page.element,
|
||||
HydrateFallback: page.HydrateFallback,
|
||||
action: page.action,
|
||||
loader: page.loader,
|
||||
handle: page.handle,
|
||||
errorElement: page.errorElement,
|
||||
});
|
||||
return target;
|
||||
}
|
||||
|
||||
function createRoute(args: {
|
||||
segments: string[];
|
||||
PageComponent: LazyExoticComponent<() => ReactElement>;
|
||||
LoadingComponent?: LazyExoticComponent<() => ReactElement>;
|
||||
PageComponent: LazyExoticComponent<() => ReactNode>;
|
||||
LoadingComponent?: LazyExoticComponent<() => ReactNode>;
|
||||
loader?: LoaderFunction;
|
||||
action?: ActionFunction;
|
||||
guard?: () => Promise<boolean>;
|
||||
@@ -196,21 +253,24 @@ function createRoute(args: {
|
||||
const [current, ...rest] = args.segments;
|
||||
const [cleanPath, pageType] = current.split(separator);
|
||||
const route: ExtendedRouteObject = { path: cleanPath };
|
||||
|
||||
if (pageType === 'page' || pageType === 'layout') {
|
||||
route.element = <args.PageComponent />;
|
||||
route.HydrateFallback =
|
||||
args.LoadingComponent ?? (() => <div>Loading...</div>);
|
||||
route.action = args.action;
|
||||
route.loader = async (...props) => {
|
||||
if (!(await args.guard?.())) {
|
||||
throw new Response('Forbidden', {
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
});
|
||||
}
|
||||
return args.loader?.(...props);
|
||||
};
|
||||
route.handle = { pageType: pageType as 'layout' | 'page' };
|
||||
}
|
||||
|
||||
if (rest.length > 0) {
|
||||
const nextSegment = rest[0].split(separator)[0];
|
||||
|
||||
if (nextSegment === 'update' || nextSegment === 'edit') {
|
||||
return {
|
||||
path: `${cleanPath}/${nextSegment}`,
|
||||
@@ -221,20 +281,16 @@ function createRoute(args: {
|
||||
handle: { pageType: pageType as 'layout' | 'page' },
|
||||
};
|
||||
}
|
||||
|
||||
const childRoute = createRoute({ ...args, segments: rest });
|
||||
|
||||
if (!route.children) {
|
||||
route.children = [];
|
||||
}
|
||||
|
||||
if (cleanPath.startsWith(':')) {
|
||||
route.children.unshift(childRoute);
|
||||
} else {
|
||||
route.children.push(childRoute);
|
||||
}
|
||||
}
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
@@ -258,7 +314,6 @@ export function getRouteSegmentsFromFilePath(
|
||||
if (segment.startsWith('[')) return getParamFromSegment(segment);
|
||||
return segment;
|
||||
});
|
||||
|
||||
return getRouteSegments(segments[0], segments, transformer);
|
||||
}
|
||||
|
||||
@@ -306,7 +361,7 @@ export function addErrorElementToRoutes(
|
||||
(_, prevSegment) => prevSegment
|
||||
);
|
||||
const ErrorBoundary = lazy(
|
||||
importer as () => Promise<{ default: () => ReactElement }>
|
||||
importer as () => Promise<{ default: () => ReactNode }>
|
||||
);
|
||||
setRoute(segments, routes, (route) => {
|
||||
route.errorElement = <ErrorBoundary />;
|
||||
@@ -325,7 +380,7 @@ export function add404PageToRoutesChildren(
|
||||
(_, prevSegment) => prevSegment
|
||||
);
|
||||
const NotFound = lazy(
|
||||
importer as () => Promise<{ default: () => ReactElement }>
|
||||
importer as () => Promise<{ default: () => ReactNode }>
|
||||
);
|
||||
setRoute(segments, routes, (route) => {
|
||||
if (route.children) {
|
||||
@@ -340,9 +395,7 @@ export function add404PageToRoutesChildren(
|
||||
action: tempRoute.action,
|
||||
loader: tempRoute.loader,
|
||||
});
|
||||
|
||||
route.children.push({ path: '*', element: <NotFound /> });
|
||||
|
||||
delete route.element;
|
||||
delete route.action;
|
||||
delete route.loader;
|
||||
@@ -352,7 +405,7 @@ export function add404PageToRoutesChildren(
|
||||
});
|
||||
}
|
||||
|
||||
function set404NonPage(routes: RouteObject, notFoundElement: ReactElement) {
|
||||
function set404NonPage(routes: RouteObject, notFoundElement: ReactNode) {
|
||||
if (
|
||||
routes.path &&
|
||||
routes.children?.length &&
|
||||
@@ -377,7 +430,6 @@ function setRoute(
|
||||
segments.forEach((_segment, i) => {
|
||||
const isLastSegment = i === segments.length - 1;
|
||||
if (isLastSegment) return (temp = updater(temp));
|
||||
|
||||
if (!isLastSegment) {
|
||||
const nextSegment = segments[i + 1];
|
||||
const index = temp.children?.findIndex(
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { FC, PropsWithChildren, ReactNode, useMemo } from 'react';
|
||||
import { useSession } from '../../hooks/use-session';
|
||||
|
||||
type TProps = PropsWithChildren<{
|
||||
permissions: Array<string>;
|
||||
fallback?: ReactNode;
|
||||
}>;
|
||||
|
||||
export const Guard: FC<TProps> = (props): ReactNode => {
|
||||
const { session } = useSession();
|
||||
|
||||
const permissionKeys = useMemo(() => {
|
||||
return session?.user?.role?.permissions.map((perm) => perm.name) ?? [];
|
||||
}, [session?.user?.role]);
|
||||
|
||||
const allowed = useMemo(() => {
|
||||
return props.permissions.every((permission) =>
|
||||
permissionKeys.includes(permission)
|
||||
);
|
||||
}, [permissionKeys, props.permissions]);
|
||||
|
||||
if (allowed) {
|
||||
return props.children;
|
||||
}
|
||||
|
||||
return props.fallback ?? null;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './guard';
|
||||
Generated
+241
-4104
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user