Compare commits

...
Author SHA1 Message Date
Maulana Sodiqin 9a0fee65a5 fixing so much trouble 2025-05-23 00:28:29 +07:00
Maulana Sodiqin 376e646c10 feat: add vercel json 2025-05-22 22:31:04 +07:00
Maulana Sodiqin f8cd57a1cf chore: update readme and env example 2025-05-20 11:09:14 +07:00
RasyidandGitHub 5618a8e99b fix: hero section responsiveness (#28)
* fix: hero section responsiveness

* fix: adjust max-width for hero section on smaller screens

* fix: increase title font size in hero section
2025-05-17 00:58:23 +07:00
598bd22b5a feat: make content data dynamic from cms and fix button and badge ui (#27)
* feat: integrate payload cms to landing page

* feat: add payload configuration and update TypeScript target

* fix: update TypeScript paths for payload configuration

* refactor: update config import paths to use local payload.config

* fix: update quotation marks for testimonials to use HTML entities

* fix: include withPayload in Next.js plugins for proper configuration

* feat: implement HeroSection global configuration and integrate with Hero component

* feat: add FeaturesSection global configuration and integrate with Features component

* feat: add revalidate constant to control page revalidation interval

* refactor: optimize data fetching by using Promise.all

* fix: update revalidation interval to 10 seconds

* feat: add CommunitiesSection configuration and integrate with Community component

* refactor: move global section functions to services directory

* feat: add support for s3 bucket for media

* feat: update Community component to use Iconify for icons and adjust CommunitiesSection configuration

* feat: replace Link component with Button for improved button styling in Community component

* feat: update FeaturesSection to use Iconify icons and adjust icon properties

* feat: add heroImage field to HeroSection and implement image formatting utility

* feat: add Learning Resources section with dynamic data fetching and UI integration

* feat: enhance LearningResourcesSection with primary and secondary button links

* refactor: reorganize layout structure by moving Header and Footer components to RootLayout

* feat: implement Testimonials section with dynamic data fetching and UI integration

* feat: simplify joinTitle rendering in Testimonials section

* feat: implement CallToAction section with dynamic data fetching and UI integration

* feat: update CallToActionSection label and wrap Discord button in link

* feat: update button components to use onClick for external links and improve styling

* feat: wrap logo image in a link to navigate to the homepage

* fix: supress scss deps warning

* fix: add placeholder to .env.example

* chore: add environment placeholder variables for CMS configuration in test build workflow

* fix: remove unused environment variables from test build workflow

* fix: text color (#26)

* chore: update action

* feat: integrate payload cms to landing page

* feat: add payload configuration and update TypeScript target

* fix: update TypeScript paths for payload configuration

* refactor: update config import paths to use local payload.config

* fix: include withPayload in Next.js plugins for proper configuration

* feat: implement HeroSection global configuration and integrate with Hero component

* feat: add FeaturesSection global configuration and integrate with Features component

* feat: add revalidate constant to control page revalidation interval

* refactor: optimize data fetching by using Promise.all

* fix: update revalidation interval to 10 seconds

* feat: add CommunitiesSection configuration and integrate with Community component

* refactor: move global section functions to services directory

* feat: add support for s3 bucket for media

* feat: update Community component to use Iconify for icons and adjust CommunitiesSection configuration

* feat: replace Link component with Button for improved button styling in Community component

* feat: update FeaturesSection to use Iconify icons and adjust icon properties

* feat: add heroImage field to HeroSection and implement image formatting utility

* feat: add Learning Resources section with dynamic data fetching and UI integration

* feat: enhance LearningResourcesSection with primary and secondary button links

* refactor: reorganize layout structure by moving Header and Footer components to RootLayout

* feat: implement Testimonials section with dynamic data fetching and UI integration

* feat: simplify joinTitle rendering in Testimonials section

* feat: implement CallToAction section with dynamic data fetching and UI integration

* feat: update CallToActionSection label and wrap Discord button in link

* feat: update button components to use onClick for external links and improve styling

* feat: wrap logo image in a link to navigate to the homepage

* fix: supress scss deps warning

* fix: add placeholder to .env.example

* chore: add environment placeholder variables for CMS configuration in test build workflow

* fix: remove unused environment variables from test build workflow

* fix: remove mistakenly added env from test-build workflow

---------

Co-authored-by: Fahim Ardani <fahimardani25@gmail.com>
Co-authored-by: Maulana Sodiqin <53475078+maulanasdqn@users.noreply.github.com>
2025-05-11 21:55:20 +07:00
95 changed files with 3770 additions and 864 deletions
-4
View File
@@ -1,4 +0,0 @@
VITE_API_URL=
CMS_SECRET=
CMS_POSTGRES_URL=
+26 -5
View File
@@ -6,9 +6,10 @@
This repository is a **monorepo** for all frontend services of IMPHNEN. The monorepo includes three main applications: This repository is a **monorepo** for all frontend services of IMPHNEN. The monorepo includes three main applications:
1. **Gacha** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">gacha website</a>. 1. **Gacha** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Gacha Website</a>.
2. **Backoffice** - Application for internal management. 2. **Backoffice** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Internal Management Website</a>.
3. **Dimentorin** - Application for mentoring services. 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 ## How to install
@@ -24,6 +25,15 @@ This repository is a **monorepo** for all frontend services of IMPHNEN. The mono
## How to run ## How to run
### Setup Environment Variables
Before running the applications, you need to set up the environment variables. You can do this by copying the `.env.example` file to `.env` and modifying the values according to your needs.
```sh
cd apps/{appname}
cp .env.example .env
```
### Development ### Development
Use the following commands to run in development mode: Use the following commands to run in development mode:
@@ -40,6 +50,10 @@ Use the following commands to run in development mode:
```sh ```sh
npm run dimentorin:dev npm run dimentorin:dev
``` ```
- **Landing Page**:
```sh
npm run landing:dev
```
### Build ### Build
@@ -57,6 +71,10 @@ Use the following commands to build the applications:
```sh ```sh
npm run dimentorin:build npm run dimentorin:build
``` ```
- **Landing Page**:
```sh
npm run landing:build
```
### Production ### Production
@@ -74,19 +92,22 @@ Use the following commands to run the applications in production mode:
```sh ```sh
npm run dimentorin:prod npm run dimentorin:prod
``` ```
- **Landing Page**:
```sh
npm run landing:prod
```
### Storybook ### Storybook
This repository uses Storybook to develop, test, and document UI components in an isolated and interactive environment. Below are the commands to work with Storybook: This repository uses Storybook to develop, test, and document UI components in an isolated and interactive environment. Below are the commands to work with Storybook:
- **Run Storybook** - **Run Storybook**
This command starts Storybook in development mode, allowing you to view and test UI components interactively. This command starts Storybook in development mode, allowing you to view and test UI components interactively.
```sh ```sh
npm run ui:storybook npm run ui:storybook
``` ```
- **Run Unit Test** - **Run Unit Test**
+1
View File
@@ -0,0 +1 @@
VITE_API_URL=
@@ -18,7 +18,7 @@ import {
RowSelectionState, RowSelectionState,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import ModalEditAccount from './_components/modal-edit-account'; import ModalEditAccount from './_components/modal-edit-account';
import { useQueryState } from '../../hook/use-query-state'; import { useQueryState } from '@imphnen-frontend-service/utils';
interface Account { interface Account {
id: number; id: number;
@@ -166,7 +166,7 @@ export const Components: FC = (): ReactElement => {
</Button> </Button>
{showFilter && ( {showFilter && (
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg"> <div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
<Filter onClose={() => setShowFilter(false)} /> <Filter onClose={() => setShowFilter(false)} options={[]} />
</div> </div>
)} )}
</div> </div>
@@ -10,7 +10,7 @@ import { FC, Fragment, ReactElement, useState } from 'react';
import ModalAddItem from './_components/modal-add-item'; import ModalAddItem from './_components/modal-add-item';
import ModalEditItem from './_components/modal-edit-item'; import ModalEditItem from './_components/modal-edit-item';
import ModalDeleteItem from './_components/modal-delete-item'; import ModalDeleteItem from './_components/modal-delete-item';
import { useQueryState } from '../../hook/use-query-state'; import { useQueryState } from '@imphnen-frontend-service/utils';
export const Components: FC = (): ReactElement => { export const Components: FC = (): ReactElement => {
const [showModalAddItem, setShowModalAddItem] = useState(false); const [showModalAddItem, setShowModalAddItem] = useState(false);
@@ -2,24 +2,19 @@ import { useForm } from 'react-hook-form';
import { import {
authLoginSchema, authLoginSchema,
TLoginRequest, TLoginRequest,
usePostLogin,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner'; import { useSession } from '@imphnen-frontend-service/utils';
export const useLogin = () => { export const useLogin = () => {
const postLogin = usePostLogin();
const form = useForm<TLoginRequest>({ const form = useForm<TLoginRequest>({
resolver: zodResolver(authLoginSchema), resolver: zodResolver(authLoginSchema),
mode: 'all', mode: 'all',
}); });
const onSubmit = form.handleSubmit((data) => { const { signIn } = useSession();
postLogin.mutate(data, {
onSuccess: () => toast.success("Login sukses"), const onSubmit = form.handleSubmit((data) => signIn(data));
onError: (error) => toast.error(error.message),
});
});
return { return {
form, form,
@@ -32,7 +32,16 @@ export const Components: FC = (): ReactElement => {
size="lg" size="lg"
className="w-full" 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 Login
</Button> </Button>
</form> </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;
-18
View File
@@ -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;
-18
View File
@@ -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;
+100 -3
View File
@@ -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) => { export const middleware = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url); const url = new URL(request.url);
const pathname = url.pathname; const pathname = url.pathname;
const session = SessionUser.get();
const session_token = SessionToken.get();
const token = session_token?.token?.access_token;
const userPermissions =
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
if (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; return null;
}; };
+1
View File
@@ -0,0 +1 @@
VITE_API_URL=
+4 -9
View File
@@ -2,24 +2,19 @@ import { useForm } from 'react-hook-form';
import { import {
authLoginSchema, authLoginSchema,
TLoginRequest, TLoginRequest,
usePostLogin,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner'; import { useSession } from '@imphnen-frontend-service/utils';
export const useLogin = () => { export const useLogin = () => {
const postLogin = usePostLogin();
const form = useForm<TLoginRequest>({ const form = useForm<TLoginRequest>({
resolver: zodResolver(authLoginSchema), resolver: zodResolver(authLoginSchema),
mode: 'all', mode: 'all',
}); });
const onSubmit = form.handleSubmit((data) => { const { signIn } = useSession();
postLogin.mutate(data, {
onSuccess: () => toast.success("Login sukses"), const onSubmit = form.handleSubmit((data) => signIn(data));
onError: (error) => toast.error(error.message),
});
});
return { return {
form, form,
+101 -3
View File
@@ -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) => { export const middleware = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url); const url = new URL(request.url);
const pathname = url.pathname; const pathname = url.pathname;
const session = SessionUser.get();
const session_token = SessionToken.get();
const token = session_token?.token?.access_token;
const userPermissions =
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
if (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; return null;
}; };
+1
View File
@@ -0,0 +1 @@
VITE_API_URL=
+4 -7
View File
@@ -2,22 +2,19 @@ import { useForm } from 'react-hook-form';
import { import {
authLoginSchema, authLoginSchema,
TLoginRequest, TLoginRequest,
usePostLogin,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useSession } from '@imphnen-frontend-service/utils';
export const useLogin = () => { export const useLogin = () => {
const postLogin = usePostLogin();
const form = useForm<TLoginRequest>({ const form = useForm<TLoginRequest>({
resolver: zodResolver(authLoginSchema), resolver: zodResolver(authLoginSchema),
mode: 'all', mode: 'all',
}); });
const onSubmit = form.handleSubmit((data) => { const { signIn } = useSession();
postLogin.mutate(data, {
onSuccess: () => console.log('Success Login'), const onSubmit = form.handleSubmit((data) => signIn(data));
});
});
return { return {
form, form,
+1 -24
View File
@@ -1,14 +1,11 @@
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { import {
authRegisterSchema, authRegisterSchema,
stepOneRegisterSchema,
TRegisterRequest, TRegisterRequest,
usePostRegister, usePostRegister,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useEffect, useState } from 'react';
export const useRegister = () => { export const useRegister = () => {
const postRegister = usePostRegister(); const postRegister = usePostRegister();
@@ -21,31 +18,12 @@ export const useRegister = () => {
password: '', password: '',
confirm_password: '', confirm_password: '',
phone_number: '', phone_number: '',
referral_code: '',
referred_by: '',
student_type: '',
}, },
}); });
const [stepValid, setStepValid] = useState(false);
useEffect(() => {
const subscription = form.watch(() => {
const { fullname, email, password, confirm_password } = form.getValues();
const valid = stepOneRegisterSchema.safeParse({
fullname,
email,
password,
confirm_password,
}).success;
setStepValid(valid);
});
return () => subscription.unsubscribe();
}, [form]);
const onSubmit = form.handleSubmit((data) => { const onSubmit = form.handleSubmit((data) => {
postRegister.mutate(data, { postRegister.mutate(data, {
onSuccess: (data) => toast.success(data.message), onSuccess: () => toast.success('Registrasi sukses'),
onError: (error) => toast.error(error.message), onError: (error) => toast.error(error.message),
}); });
}); });
@@ -53,6 +31,5 @@ export const useRegister = () => {
return { return {
form, form,
onSubmit, onSubmit,
isStepOneValid: stepValid,
}; };
}; };
+101 -3
View File
@@ -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) => { export const middleware = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url); const url = new URL(request.url);
const pathname = url.pathname; const pathname = url.pathname;
const session = SessionUser.get();
const session_token = SessionToken.get();
const token = session_token?.token?.access_token;
const userPermissions =
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
if (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; return null;
}; };
+6
View File
@@ -0,0 +1,6 @@
CMS_SECRET=your-very-long-secret-key
CMS_POSTGRES_URL=postgres://user:password@host:port/database
CMS_STORAGE_ENDPOINT=https://your-s3-endpoint.com
CMS_STORAGE_ACCESS_KEY_ID=your-access-key-id
CMS_STORAGE_SECRET_ACCESS_KEY=your-secret-access-key
CMS_STORAGE_REGION=ap-southeast-1
+8 -13
View File
@@ -1,20 +1,15 @@
//@ts-check
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { composePlugins, withNx } = require('@nx/next'); const { composePlugins, withNx } = require('@nx/next');
const { withPayload } = require('@payloadcms/next/withPayload'); const { withPayload } = require('@payloadcms/next/withPayload');
/** /** @type {import('@nx/next/plugins/with-nx').WithNxOptions} */
* @type {import('@nx/next/plugins/with-nx').WithNxOptions}
**/
const nextConfig = { const nextConfig = {
nx: { nx: { svgr: false },
// Set this to true if you would like to to use SVGR sassOptions: {
// See: https://github.com/gregberge/svgr quietDeps: true,
svgr: false, logger: {
warn: () => {},
},
}, },
}; };
const plugins = [withNx, withPayload]; module.exports = composePlugins(withNx, withPayload)(nextConfig);
module.exports = composePlugins(...plugins)(nextConfig);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

@@ -1,10 +1,21 @@
'use client'; 'use client';
import { Button } from '@imphnen-frontend-service/shadcn-ui/atoms'; import { Button } from '@imphnen-frontend-service/shadcn-ui/atoms';
import { CallToActionSection } from 'apps/landing/src/payload-types';
import { motion, useInView } from 'framer-motion'; import { motion, useInView } from 'framer-motion';
import { useRef } from 'react'; import { useRef } from 'react';
export function CallToAction() { export function CallToAction(props: CallToActionSection) {
const {
title,
highlightedTitle,
subtitle,
primaryButtonLabel,
primaryButtonLink,
secondaryButtonLabel,
secondaryButtonLink,
} = props;
const ref = useRef(null); const ref = useRef(null);
const isInView = useInView(ref, { once: true, amount: 0.2 }); const isInView = useInView(ref, { once: true, amount: 0.2 });
@@ -28,30 +39,30 @@ export function CallToAction() {
<div className="relative z-10 text-center"> <div className="relative z-10 text-center">
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-6"> <h2 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-6">
Siap Menjadi{' '} {title}{' '}
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400"> <span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
Programmer Handal? {highlightedTitle}
</span> </span>
</h2> </h2>
<p className="text-lg text-muted-foreground mb-8 max-w-2xl mx-auto"> <p className="text-lg text-muted-foreground mb-8 max-w-2xl mx-auto">
Bergabunglah dengan komunitas IMPHNEN sekarang dan mulai {subtitle}
perjalanan programming mu dengan cara yang menyenangkan!
</p> </p>
<div className="flex flex-col sm:flex-row gap-4 justify-center"> <div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button <Button
size="lg" 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"
font-bold text-black hover:text-white cursor-pointer" onClick={() => window.open(primaryButtonLink, '_blank')}
> >
Gabung Discord {primaryButtonLabel}
</Button> </Button>
<Button <Button
size="lg" size="lg"
variant="outline" variant="outline"
className="border-primary hover:bg-primary/10" className="border-primary hover:bg-primary/10"
onClick={() => window.open(secondaryButtonLink, '_blank')}
> >
Join Facebook Group {secondaryButtonLabel}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1,54 +1,16 @@
'use client'; 'use client';
import { import { Icon } from '@iconify/react';
Button, import { Button } from '@imphnen-frontend-service/shadcn-ui/atoms';
FacebookIcon, import { CommunitiesSection } from 'apps/landing/src/payload-types';
InstagramIcon,
MessageCircleIcon,
} from '@imphnen-frontend-service/shadcn-ui/atoms';
import { motion, useInView } from 'framer-motion'; import { motion, useInView } from 'framer-motion';
import { useRef } from 'react'; import { useRef } from 'react';
export function Community() { export function Community(props: CommunitiesSection) {
const { items, stats } = props;
const ref = useRef(null); const ref = useRef(null);
const isInView = useInView(ref, { once: true, amount: 0.2 }); const isInView = useInView(ref, { once: true, amount: 0.2 });
const communities = [
{
icon: (
<FacebookIcon className="h-6 w-6 text-blue-600 dark:text-blue-400" />
),
iconBg: 'bg-blue-100 dark:bg-blue-900',
title: 'Facebook Group',
description:
'Bergabunglah dengan grup Facebook kami untuk diskusi santai dan berbagi artikel menarik.',
buttonText: 'Gabung Sekarang',
buttonLink: '#',
},
{
icon: (
<InstagramIcon className="h-6 w-6 text-blue-600 dark:text-blue-400" />
),
iconBg: 'bg-blue-100 dark:bg-blue-900',
title: 'Instagram',
description:
'Ikuti kami di Instagram untuk tips programming, konten inspiratif, dan info event terbaru.',
buttonText: 'Follow Kami',
buttonLink: '#',
},
{
icon: (
<MessageCircleIcon className="h-6 w-6 text-blue-600 dark:text-blue-400" />
),
iconBg: 'bg-blue-100 dark:bg-blue-900',
title: 'Discord Server',
description:
'Diskusikan langsung dengan sesama programmer dan dapatkan bantuan langsung dari para ahli.',
buttonText: 'Join Server',
buttonLink: '#',
},
];
const containerVariants = { const containerVariants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
visible: { visible: {
@@ -73,105 +35,74 @@ export function Community() {
id="komunitas" id="komunitas"
className="w-full py-20 md:py-32 bg-muted relative overflow-hidden" 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 -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-[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 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>
<div className="container px-4 md:px-6" ref={ref}> <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 <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} variants={containerVariants}
initial="hidden" initial="hidden"
animate={isInView ? 'visible' : 'hidden'} animate={isInView ? 'visible' : 'hidden'}
> >
{communities.map((community, index) => ( {items?.map((c, i) => (
<motion.div <motion.div
key={index} key={i}
className="group relative overflow-hidden rounded-xl border bg-background p-6 transition-all hover:shadow-xl" className="group relative overflow-hidden rounded-xl border bg-background p-6 transition-all hover:shadow-xl"
variants={itemVariants} 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="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="relative z-10">
<div <div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
className={`mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full ${community.iconBg}`} <Icon
> icon={c.iconName}
{community.icon} className="h-6 w-6 text-blue-600 dark:text-blue-400"
/>
</div> </div>
<h3 className="mb-2 text-xl font-bold">{community.title}</h3> <h3 className="mb-2 text-xl font-bold">{c.title}</h3>
<p className="mb-6 text-muted-foreground"> <p className="mb-6 text-muted-foreground">{c.description}</p>
{community.description}
</p>
<Button <Button
variant="outline" variant="outline"
className="w-full group-hover:bg-primary group-hover:text-primary-foreground transition-colors duration-300" 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> </Button>
</div> </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" /> <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>
))} ))}
</motion.div> </motion.div>
{/* Community Stats */}
<motion.div <motion.div
className="mt-20 grid grid-cols-2 md:grid-cols-4 gap-8 text-center" className="mt-20 grid grid-cols-2 md:grid-cols-4 gap-8 text-center"
initial={{ opacity: 0, y: 20 }} 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 }} transition={{ duration: 0.5, delay: 0.3 }}
> >
<div className="space-y-2"> {stats?.map((s, i) => (
<div className="text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400"> <div key={i} className="space-y-2">
10K+ <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>
<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> </motion.div>
</div> </div>
</section> </section>
@@ -1,45 +1,16 @@
'use client'; 'use client';
import { import { Icon } from '@iconify/react';
BookOpenIcon, import { FeaturesSection } from 'apps/landing/src/payload-types';
CodeIcon,
LaptopIcon,
UsersIcon,
} from '@imphnen-frontend-service/shadcn-ui/atoms';
import { motion, useInView } from 'framer-motion'; import { motion, useInView } from 'framer-motion';
import { useRef } from 'react'; import { useRef } from 'react';
export function Features() { export function Features(props: FeaturesSection) {
const { heading, subheading, features } = props;
const ref = useRef(null); const ref = useRef(null);
const isInView = useInView(ref, { once: true, amount: 0.2 }); const isInView = useInView(ref, { once: true, amount: 0.2 });
const features = [
{
icon: <LaptopIcon className="h-6 w-6 text-primary" />,
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" />,
title: 'Komunitas Supportif',
description:
'Bergabunglah dengan komunitas programmer Indonesia yang siap membantu dan berbagi pengalaman.',
},
{
icon: <BookOpenIcon className="h-6 w-6 text-primary" />,
title: 'Tutorial Interaktif',
description:
'Akses tutorial interaktif yang membuat konsep programming lebih mudah untuk dipahami.',
},
{
icon: <CodeIcon className="h-6 w-6 text-primary" />,
title: 'Proyek Praktis',
description:
'Terapkan pengetahuan Anda dalam proyek nyata dengan panduan langkah demi langkah.',
},
];
const containerVariants = { const containerVariants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
visible: { visible: {
@@ -64,7 +35,6 @@ export function Features() {
id="fitur" id="fitur"
className="w-full py-20 md:py-32 relative overflow-hidden" 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 -z-10">
<div className="absolute inset-0 bg-gradient-to-b from-background via-background to-muted/50" /> <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" /> <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,14 +52,10 @@ export function Features() {
Fitur Unggulan Fitur Unggulan
</div> </div>
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl"> <h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl">
Belajar programming dengan{' '} {heading}
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
cara yang lebih baik
</span>
</h2> </h2>
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg"> <p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg">
IMPHNEN hadir dengan berbagai fitur untuk membantu kamu menjadi {subheading}
programmer handal tanpa harus pusing dengan coding.
</p> </p>
</motion.div> </motion.div>
</div> </div>
@@ -100,7 +66,7 @@ export function Features() {
initial="hidden" initial="hidden"
animate={isInView ? 'visible' : 'hidden'} animate={isInView ? 'visible' : 'hidden'}
> >
{features.map((feature, index) => ( {features?.map((feature, index) => (
<motion.div <motion.div
key={index} key={index}
className="group relative overflow-hidden rounded-xl border bg-background/50 backdrop-blur-sm p-6 transition-all hover:shadow-md hover:shadow-primary/5 hover:border-primary/50" className="group relative overflow-hidden rounded-xl border bg-background/50 backdrop-blur-sm p-6 transition-all hover:shadow-md hover:shadow-primary/5 hover:border-primary/50"
@@ -110,7 +76,10 @@ export function Features() {
<div className="relative z-10"> <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"> <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> </div>
<h3 className="mb-2 text-xl font-bold">{feature.title}</h3> <h3 className="mb-2 text-xl font-bold">{feature.title}</h3>
<p className="text-muted-foreground">{feature.description}</p> <p className="text-muted-foreground">{feature.description}</p>
@@ -35,13 +35,15 @@ export function Header() {
<div className="container flex h-16 items-center justify-between"> <div className="container flex h-16 items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative overflow-hidden rounded"> <div className="relative overflow-hidden rounded">
<Image <Link href="/">
src="/logo.png" <Image
alt="IMPHNEN Logo" src="/logo.png"
width={64} alt="IMPHNEN"
height={64} width={64}
className="object-cover cursor-pointer" height={64}
/> className="object-cover"
/>
</Link>
</div> </div>
</div> </div>
@@ -82,7 +84,13 @@ export function Header() {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<SimpleThemeToggle /> <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 font-bold text-black hover:text-white cursor-pointer">
<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 Gabung Discord
</Button> </Button>
@@ -134,9 +142,15 @@ export function Header() {
> >
Testimoni Testimoni
</Link> </Link>
<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"> <a
Gabung Discord href="https://discord.com/invite/imphnen"
</Button> 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> </nav>
</div> </div>
)} )}
@@ -6,11 +6,23 @@ import {
SparklesIcon, SparklesIcon,
UsersIcon, UsersIcon,
} from '@imphnen-frontend-service/shadcn-ui/atoms'; } from '@imphnen-frontend-service/shadcn-ui/atoms';
import { formatCMSImageDataToMedia } from 'apps/landing/src/lib/format';
import { HeroSection } from 'apps/landing/src/payload-types';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import Image from 'next/image'; import Image from 'next/image';
import { useEffect, useState } from 'react'; import { Fragment, useEffect, useState } from 'react';
export function Hero(props: HeroSection) {
const {
badgeText,
buttons,
description,
highlight,
title,
stats,
heroImage,
} = props;
export function Hero() {
const [scrollY, setScrollY] = useState(0); const [scrollY, setScrollY] = useState(0);
useEffect(() => { useEffect(() => {
@@ -24,11 +36,8 @@ export function Hero() {
return ( return (
<section className="relative w-full py-20 md:py-32 lg:py-40 overflow-hidden"> <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 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" /> <div className="absolute top-0 left-0 w-full h-full bg-gradient-to-b from-background to-background/50" />
{/* Animated Gradient Orbs */}
<div <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" 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={{ style={{
@@ -43,8 +52,6 @@ export function Hero() {
opacity: Math.max(0.2, 1 - scrollY * 0.001), 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 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> </div>
@@ -56,94 +63,87 @@ export function Hero() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }} 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" /> <SparklesIcon className="mr-1 h-3.5 w-3.5 text-primary" />
<span>Komunitas Programmer Indonesia</span> <span>{badgeText}</span>
</div> </div>
<div className="space-y-4"> <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 /> {title} <br />
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400"> <span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
Tanpa Ribet {highlight}
</span> </span>
</h1> </h1>
<p className="max-w-[600px] text-muted-foreground md:text-xl"> <p className="max-w-[600px] text-muted-foreground md:text-xl">
Temukan potensi programming Anda bersama komunitas yang {description}
mendukung, tutorial interaktif, dan sumber daya berkualitas
tinggi.
</p> </p>
</div> </div>
<div className="flex flex-col sm:flex-row gap-4"> <div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
<a href="https://facebook.com"> <Button
<Button size="lg"
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"
className="bg-gradient-to-r from-primary to-blue-400 hover:from-primary/90 hover:to-blue-400/90 transition-all duration-300 onClick={() => window.open(buttons.primaryUrl, '_blank')}
font-bold text-black hover:text-white cursor-pointer" >
> {buttons.primaryLabel}
Mulai Belajar </Button>
</Button>
</a>
<Button <Button
size="lg" size="lg"
variant="outline" variant="outline"
className="group relative overflow-hidden border-primary" className="w-full sm:w-auto group relative overflow-hidden border-primary"
onClick={() => window.open(buttons.secondaryUrl, '_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> <span className="relative">{buttons.secondaryLabel}</span>
</Button> </Button>
</div> </div>
<div className="flex items-center gap-8"> <div className="flex flex-wrap justify-center gap-6 sm:gap-8">
<div className="flex flex-col items-center"> {stats?.map(({ value, label }, i) => (
<div className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400"> <Fragment key={i}>
100K+ <div className="flex flex-col items-center">
</div> <div className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
<div className="text-xs text-muted-foreground">Member</div> {value}
</div> </div>
<div className="h-10 border-r border-border"></div> <div className="text-xs text-muted-foreground">{label}</div>
<div className="flex flex-col items-center"> </div>
<div className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400"> {i < stats.length - 1 && (
500+ <div className="hidden sm:block h-10 border-r border-border mx-4" />
</div> )}
<div className="text-xs text-muted-foreground">Tutorial</div> </Fragment>
</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> </div>
</motion.div> </motion.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 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 0.2 }} transition={{ duration: 0.5, delay: 0.2 }}
> >
<div className="relative"> <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"> <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" /> <CodeIcon className="h-6 w-6 text-primary" />
</div> </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"> <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" /> <UsersIcon className="h-6 w-6 text-blue-400" />
</div> </div>
{/* Main Image */}
<div className="relative z-10 rounded-2xl overflow-hidden border shadow-2xl"> <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" /> <div className="absolute inset-0 bg-gradient-to-tr from-primary/10 to-blue-400/10" />
<Image {(() => {
src="/hero.jpeg" const media = formatCMSImageDataToMedia(heroImage);
width={600} if (!media) return null;
height={500} return (
alt="IMPHNEN Programming Community" <Image
className="w-full h-auto object-cover" src={media.url!}
/> alt={media.alt}
width={600}
height={500}
className="w-full h-auto object-cover"
/>
);
})()}
</div> </div>
</div> </div>
</motion.div> </motion.div>
@@ -1,65 +1,17 @@
'use client'; 'use client';
import { import { Icon } from '@iconify/react';
BookOpenIcon, import { Button } from '@imphnen-frontend-service/shadcn-ui/atoms';
Button, import { LearningResourcesSection } from 'apps/landing/src/payload-types';
CodeIcon,
Share2Icon,
VideoIcon,
} from '@imphnen-frontend-service/shadcn-ui/atoms';
import { motion, useInView } from 'framer-motion'; import { motion, useInView } from 'framer-motion';
import { useRef } from 'react'; import { useRef } from 'react';
export function LearningResources() { export function LearningResources(props: LearningResourcesSection) {
const { resources, featured, title, description } = props;
const ref = useRef(null); const ref = useRef(null);
const isInView = useInView(ref, { once: true, amount: 0.2 }); 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',
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',
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',
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',
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 containerVariants = { const containerVariants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
visible: { visible: {
@@ -84,97 +36,96 @@ export function LearningResources() {
id="sumber-belajar" id="sumber-belajar"
className="w-full py-20 md:py-32 relative overflow-hidden" 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 -z-10">
<div className="absolute inset-0 bg-gradient-to-b from-background via-background to-background" /> <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 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 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>
<div className="container px-4 md:px-6" ref={ref}> <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"> <div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
<motion.div <motion.div
className="space-y-2"
initial={{ opacity: 0, y: 20 }} 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 }} transition={{ duration: 0.5 }}
> >
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl"> <h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl">
Sumber{' '} {title}
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
Belajar
</span>
</h2> </h2>
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg"> <p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg">
Akses berbagai materi belajar yang akan membantu kamu menguasai {description}
konsep programming dengan cara yang menyenangkan.
</p> </p>
</motion.div> </motion.div>
</div> </div>
<motion.div <motion.div
className="grid gap-8 md:grid-cols-2 lg:grid-cols-4" className="grid gap-8 md:grid-cols-2 lg:grid-cols-4"
variants={containerVariants} variants={containerVariants}
initial="hidden" initial="hidden"
animate={isInView ? 'visible' : 'hidden'} animate={isInView ? 'visible' : 'hidden'}
> >
{resources.map((resource, index) => ( {resources.map((r, i) => (
<motion.div <motion.div
key={index} key={i}
className="group relative overflow-hidden rounded-xl border bg-background p-6 transition-all hover:shadow-lg" className="group relative overflow-hidden rounded-xl border bg-background p-6 hover:shadow-lg"
variants={itemVariants} 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="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="relative z-10">
<div <div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
className={`mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full ${resource.iconBg}`} <Icon
> icon={r.icon}
{resource.icon} className="h-6 w-6 text-blue-600 dark:text-blue-400"
/>
</div> </div>
<h3 className="mb-2 text-xl font-bold">{resource.title}</h3> <h3 className="mb-2 text-xl font-bold">{r.title}</h3>
<p className="mb-6 text-muted-foreground"> <p className="mb-6 text-muted-foreground">{r.description}</p>
{resource.description}
</p>
<Button <Button
variant="link" variant="link"
className="p-0 h-auto font-medium text-primary hover:text-primary/80" 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> </Button>
</div> </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" /> <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>
))} ))}
</motion.div> </motion.div>
{/* Featured Resource */}
<motion.div <motion.div
className="mt-20 rounded-xl overflow-hidden border bg-background/50 backdrop-blur-sm" className="mt-20 rounded-xl overflow-hidden border bg-background/50 backdrop-blur-sm"
initial={{ opacity: 0, y: 20 }} 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 }} transition={{ duration: 0.5, delay: 0.3 }}
> >
<div className="grid md:grid-cols-2 gap-0"> <div className="grid md:grid-cols-2 gap-0">
<div className="p-8 md:p-12 flex flex-col justify-center"> <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 {featured.label}
</div> </div>
<h3 className="text-2xl md:text-3xl font-bold mb-4"> <h3 className="text-2xl md:text-3xl font-bold mb-4">
Kursus Lengkap Web Development {featured.title}
</h3> </h3>
<p className="text-muted-foreground mb-6"> <p className="text-muted-foreground mb-6">
Pelajari HTML, CSS, JavaScript, React, dan Node.js dalam satu {featured.description}
kursus komprehensif yang dirancang untuk pemula hingga tingkat
menengah.
</p> </p>
<div className="flex flex-wrap gap-4"> <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
font-bold text-black hover:text-white cursor-pointer"> 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"
Mulai Kursus onClick={() =>
window.open(featured.primaryButtonLink, '_blank')
}
>
{featured.primaryButtonText}
</Button>
<Button
variant="outline"
onClick={() =>
window.open(featured.secondaryButtonLink, '_blank')
}
>
{featured.secondaryButtonText}
</Button> </Button>
<Button variant="outline">Lihat Silabus</Button>
</div> </div>
</div> </div>
<div className="relative h-64 md:h-auto"> <div className="relative h-64 md:h-auto">
@@ -1,87 +1,64 @@
'use client'; 'use client';
import { QuoteIcon } from '@imphnen-frontend-service/shadcn-ui/atoms'; import { QuoteIcon } from '@imphnen-frontend-service/shadcn-ui/atoms';
import { formatCMSImageDataToMedia } from 'apps/landing/src/lib/format';
import { TestimonialsSection } from 'apps/landing/src/payload-types';
import { motion, useInView } from 'framer-motion'; import { motion, useInView } from 'framer-motion';
import Image from 'next/image'; import Image from 'next/image';
import { useRef } from 'react'; import { useRef } from 'react';
export function Testimonials() { export function Testimonials(props: TestimonialsSection) {
const ref = useRef(null); const { title, subtitle, items, stats, joinTitle, joinText } = props;
const isInView = useInView(ref, { once: true, amount: 0.2 });
const testimonials = [ const ref = useRef<HTMLDivElement>(null);
{ const isInView = useInView(ref, { once: true, amount: 0.2 });
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: '/Budi Santoso.jpg',
},
{
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: '/Anita Ratna.jpg',
},
{
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: '/Dedi Permana.jpg',
},
];
const containerVariants = { const containerVariants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
visible: { visible: { opacity: 1, transition: { staggerChildren: 0.1 } },
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
}; };
const itemVariants = { const itemVariants = {
hidden: { y: 20, opacity: 0 }, hidden: { y: 20, opacity: 0 },
visible: { visible: { y: 0, opacity: 1, transition: { duration: 0.5 } },
y: 0,
opacity: 1,
transition: { duration: 0.5 },
},
}; };
return ( return (
<section <section
id="testimoni" id="testimoni"
className="w-full py-20 md:py-32 bg-muted relative overflow-hidden" 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 -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_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 className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom,rgba(96,165,250,0.1),transparent_50%)]" />
</div> </div>
<div className="container px-4 md:px-6" ref={ref}> <div className="container px-4 md:px-6">
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16"> <motion.div
<motion.div className="flex flex-col items-center justify-center space-y-4 text-center mb-16"
className="space-y-2" initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 20 }} animate={isInView ? { opacity: 1, y: 0 } : {}}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }} transition={{ duration: 0.5 }}
transition={{ duration: 0.5 }} >
> <h2 className="text-3xl font-bold tracking-tighter md:text-4xl lg:text-5xl">
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl"> {title.split(' ').map((word, i) =>
Testimoni{' '} i === title.split(' ').length - 1 ? (
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400"> <span
Member key={i}
</span> className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400"
</h2> >
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg"> {word}
Apa kata mereka yang telah bergabung dengan komunitas IMPHNEN? </span>
</p> ) : (
</motion.div> <span key={i}>{word} </span>
</div> )
)}
</h2>
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg">
{subtitle}
</p>
</motion.div>
<motion.div <motion.div
className="grid gap-8 md:grid-cols-3" className="grid gap-8 md:grid-cols-3"
@@ -89,98 +66,68 @@ export function Testimonials() {
initial="hidden" initial="hidden"
animate={isInView ? 'visible' : 'hidden'} animate={isInView ? 'visible' : 'hidden'}
> >
{testimonials.map((testimonial, index) => ( {items?.map((t, idx) => (
<motion.div <motion.div
key={index} key={idx}
className="group relative overflow-hidden rounded-xl border bg-background p-6 transition-all hover:shadow-lg" className="group relative overflow-hidden rounded-xl border bg-background p-6 transition-all hover:shadow-lg"
variants={itemVariants} variants={itemVariants}
> >
<div className="absolute top-6 right-6 text-primary/20 group-hover:text-primary/40 transition-colors"> <div className="absolute top-6 right-6 text-primary/20 group-hover:text-primary/40 transition-colors">
<QuoteIcon className="h-8 w-8" /> <QuoteIcon className="h-8 w-8" />
</div> </div>
<div className="relative z-10"> <div className="relative z-10">
<p className="mb-6 text-muted-foreground italic"> <p className="mb-6 text-muted-foreground italic">
&ldquo;{testimonial.quote}&rdquo; &ldquo;{t.quote}&rdquo;
</p> </p>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="relative h-12 w-12 overflow-hidden rounded-full border-2 border-primary/20"> <div className="relative h-12 w-12 overflow-hidden rounded-full border-2 border-primary/20">
<Image {(() => {
src={testimonial.avatar || '/placeholder.svg'} const media = formatCMSImageDataToMedia(t.avatar);
alt={testimonial.name} if (!media) return null;
width={48} return (
height={48} <Image
className="object-cover" src={media.url!}
/> alt={media.alt}
width={600}
height={500}
className="object-cover"
/>
);
})()}
</div> </div>
<div> <div>
<h4 className="font-bold">{testimonial.name}</h4> <h4 className="font-bold">{t.name}</h4>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{t.role}</p>
{testimonial.role}
</p>
</div> </div>
</div> </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" /> <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>
))} ))}
</motion.div> </motion.div>
{/* Testimonial Stats */}
<motion.div <motion.div
className="mt-20 rounded-xl overflow-hidden border bg-background/50 backdrop-blur-sm p-8 md:p-12" className="mt-20 rounded-xl overflow-hidden border bg-background/50 backdrop-blur-sm p-8 md:p-12"
initial={{ opacity: 0, y: 20 }} 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 }} transition={{ duration: 0.5, delay: 0.3 }}
> >
<div className="grid md:grid-cols-2 gap-8 items-center"> <div className="grid md:grid-cols-2 gap-8 items-center">
<div> <div>
<h3 className="text-2xl md:text-3xl font-bold mb-4"> <h3 className="text-2xl md:text-3xl font-bold mb-4">
Bergabunglah dengan{' '} {joinTitle}
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
10,000+
</span>{' '}
programmer Indonesia lainnya
</h3> </h3>
<p className="text-muted-foreground"> <p className="text-muted-foreground">{joinText}</p>
Komunitas kami terus berkembang dengan programmer dari berbagai
latar belakang dan tingkat keahlian. Bersama-sama, kita belajar,
berbagi, dan tumbuh sebagai profesional.
</p>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="rounded-lg border p-4 text-center"> {stats?.map((s, idx) => (
<div className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400"> <div key={idx} className="rounded-lg border p-4 text-center">
98% <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>
<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>
</div> </div>
</motion.div> </motion.div>
+7 -1
View File
@@ -1,6 +1,8 @@
import { type Metadata } from 'next'; import { type Metadata } from 'next';
import { Inter } from 'next/font/google'; import { Inter } from 'next/font/google';
import '../../styles/globals.css'; import '../../styles/globals.css';
import Footer from './_components/footer';
import { Header } from './_components/header';
import { ThemeProvider } from './_components/theme-provider'; import { ThemeProvider } from './_components/theme-provider';
const inter = Inter({ subsets: ['latin'] }); const inter = Inter({ subsets: ['latin'] });
@@ -24,7 +26,11 @@ export default function RootLayout({
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
{children} <div className="flex min-h-screen flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
+33 -15
View File
@@ -1,25 +1,43 @@
import { getGlobalsCallToActionSection } from '../../services/get-globals-call-to-action-section';
import { getGlobalsCommunitiesSection } from '../../services/get-globals-communitues-section';
import { getGlobalsFeaturesSection } from '../../services/get-globals-features-section';
import { getGlobalsHeroSection } from '../../services/get-globals-hero-section';
import { getGlobalsLearningResourcesSection } from '../../services/get-globals-learning-resources-section';
import { getGlobalsTestimonialsSection } from '../../services/get-globals-testimonials-section';
import { CallToAction } from './_components/call-to-action'; import { CallToAction } from './_components/call-to-action';
import { Community } from './_components/community'; import { Community } from './_components/community';
import { Features } from './_components/features'; import { Features } from './_components/features';
import Footer from './_components/footer';
import { Header } from './_components/header';
import { Hero } from './_components/hero'; import { Hero } from './_components/hero';
import { LearningResources } from './_components/learning-resources'; import { LearningResources } from './_components/learning-resources';
import { Testimonials } from './_components/testimonials'; import { Testimonials } from './_components/testimonials';
export default function Page() { export const revalidate = 10; // Seconds
export default async function Page() {
const [
heroData,
featuresData,
communitiesData,
learningResourcesData,
testimonialsData,
callToActionData,
] = await Promise.all([
getGlobalsHeroSection(),
getGlobalsFeaturesSection(),
getGlobalsCommunitiesSection(),
getGlobalsLearningResourcesSection(),
getGlobalsTestimonialsSection(),
getGlobalsCallToActionSection(),
]);
return ( return (
<div className="flex min-h-screen flex-col"> <>
<Header /> <Hero {...heroData} />
<main className="flex-1"> <Features {...featuresData} />
<Hero /> <Community {...communitiesData} />
<Features /> <LearningResources {...learningResourcesData} />
<Community /> <Testimonials {...testimonialsData} />
<LearningResources /> <CallToAction {...callToActionData} />
<Testimonials /> </>
<CallToAction />
</main>
<Footer />
</div>
); );
} }
@@ -1 +1,5 @@
export const importMap = {}; import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client'
export const importMap = {
"@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24
}
@@ -0,0 +1,51 @@
import { GlobalConfig } from 'payload';
export const CallToActionSection: GlobalConfig = {
slug: 'call-to-action-section',
label: 'Call To Action Section',
fields: [
{
name: 'title',
type: 'text',
required: true,
defaultValue: 'Siap Menjadi',
},
{
name: 'highlightedTitle',
type: 'text',
required: true,
defaultValue: 'Programmer Handal?',
},
{
name: 'subtitle',
type: 'textarea',
required: true,
defaultValue:
'Bergabunglah dengan komunitas IMPHNEN sekarang dan mulai perjalanan programming mu dengan cara yang menyenangkan!',
},
{
name: 'primaryButtonLabel',
type: 'text',
required: true,
defaultValue: 'Gabung Discord',
},
{
name: 'primaryButtonLink',
type: 'text',
required: true,
defaultValue: 'https://discord.gg/imphnen',
},
{
name: 'secondaryButtonLabel',
type: 'text',
required: true,
defaultValue: 'Join Facebook Group',
},
{
name: 'secondaryButtonLink',
type: 'text',
required: true,
defaultValue: 'https://facebook.com/groups/programmerhandal',
},
],
};
@@ -0,0 +1,65 @@
import { GlobalConfig } from 'payload';
export const CommunitiesSection: GlobalConfig = {
slug: 'communities-section',
label: 'Communities Section',
fields: [
{
name: 'items',
type: 'array',
required: true,
defaultValue: [
{
iconName: 'tabler:brand-facebook',
title: 'Facebook Group',
description:
'Bergabunglah dengan grup Facebook kami untuk diskusi santai dan berbagi artikel menarik.',
buttonText: 'Gabung Sekarang',
buttonLink: 'https://facebook.com/groups/programmerhandal',
},
{
iconName: 'tabler:brand-instagram',
title: 'Instagram',
description:
'Ikuti kami di Instagram untuk tips programming, konten inspiratif, dan info event terbaru.',
buttonText: 'Follow Kami',
buttonLink: 'https://www.instagram.com/imphnen.dev',
},
{
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: 'https://discord.com/invite/imphnen',
},
],
fields: [
{
name: 'iconName',
type: 'text',
required: true,
},
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{ name: 'buttonText', type: 'text', required: true },
{ name: 'buttonLink', type: 'text', required: true },
],
},
{
name: 'stats',
type: 'array',
required: true,
defaultValue: [
{ value: '100K+', label: 'Member Aktif' },
{ value: '50+', label: 'Event Bulanan' },
{ value: '100+', label: 'Mentor Profesional' },
{ value: '5K+', label: 'Diskusi Mingguan' },
],
fields: [
{ name: 'value', type: 'text', required: true },
{ name: 'label', type: 'text', required: true },
],
},
],
};
@@ -0,0 +1,65 @@
import { GlobalConfig } from 'payload';
export const FeaturesSection: GlobalConfig = {
slug: 'features-section',
label: 'Features Section',
fields: [
{
name: 'heading',
type: 'text',
defaultValue: 'Belajar programming dengan cara yang lebih baik',
},
{
name: 'subheading',
type: 'text',
defaultValue:
'IMPHNEN hadir dengan berbagai fitur untuk membantu kamu menjadi programmer handal tanpa harus pusing dengan coding.',
},
{
name: 'features',
type: 'array',
defaultValue: [
{
iconName: 'tabler:device-laptop',
title: 'Belajar Tanpa Koding',
description:
'Pelajari konsep programming dengan cara yang mudah dipahami tanpa harus menulis kode yang rumit.',
},
{
iconName: 'tabler:users',
title: 'Komunitas Supportif',
description:
'Bergabunglah dengan komunitas programmer Indonesia yang siap membantu dan berbagi pengalaman.',
},
{
iconName: 'tabler:book',
title: 'Tutorial Interaktif',
description:
'Akses tutorial interaktif yang membuat konsep programming lebih mudah untuk dipahami.',
},
{
iconName: 'tabler:code',
title: 'Proyek Praktis',
description:
'Terapkan pengetahuan Anda dalam proyek nyata dengan panduan langkah demi langkah.',
},
],
fields: [
{
name: 'iconName',
type: 'text',
required: true,
},
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'description',
type: 'textarea',
},
],
},
],
};
+104
View File
@@ -0,0 +1,104 @@
import type { GlobalConfig } from 'payload';
export const HeroSection: GlobalConfig = {
slug: 'hero-section',
label: 'Hero Section',
fields: [
{
name: 'heroImage',
label: 'Hero Image',
type: 'upload',
relationTo: 'media',
required: true,
},
{
name: 'badgeText',
label: 'Badge Text',
type: 'text',
required: true,
defaultValue: 'Komunitas Programmer Indonesia',
},
{
name: 'title',
label: 'Main Title',
type: 'text',
required: true,
defaultValue: 'Programmer Handal,',
},
{
name: 'highlight',
label: 'Highlight Text',
type: 'text',
required: true,
defaultValue: 'Tanpa Ribet',
},
{
name: 'description',
label: 'Description',
type: 'textarea',
required: true,
defaultValue:
'Temukan potensi programming Anda bersama komunitas yang mendukung, tutorial interaktif, dan sumber daya berkualitas tinggi.',
},
{
type: 'group',
name: 'buttons',
label: 'Buttons',
fields: [
{
name: 'primaryLabel',
label: 'Primary Button Label',
type: 'text',
required: true,
defaultValue: 'Mulai Belajar',
},
{
name: 'primaryUrl',
label: 'Primary Button URL',
type: 'text',
required: true,
defaultValue: 'https://facebook.com/groups/programmerhandal',
},
{
name: 'secondaryLabel',
label: 'Secondary Button Label',
type: 'text',
required: true,
defaultValue: 'Gabung Discord',
},
{
name: 'secondaryUrl',
label: 'Secondary Button URL',
type: 'text',
required: true,
defaultValue: 'https://discord.com/invite/imphnen',
},
],
},
{
name: 'stats',
label: 'Statistics',
type: 'array',
minRows: 1,
fields: [
{
name: 'value',
label: 'Value',
type: 'text',
required: true,
},
{
name: 'label',
label: 'Label',
type: 'text',
required: true,
},
],
defaultValue: [
{ value: '180K+', label: 'Member' },
{ value: '500+', label: 'Tutorial' },
{ value: '24/7', label: 'Yapping' },
],
},
],
};
@@ -0,0 +1,117 @@
// payload config
import { GlobalConfig } from 'payload';
export const LearningResourcesSection: GlobalConfig = {
slug: 'learning-resources-section',
label: 'Learning Resources Section',
fields: [
{
name: 'title',
type: 'text',
required: true,
defaultValue: 'Sumber Belajar',
},
{
name: 'description',
type: 'textarea',
required: true,
defaultValue:
'Akses berbagai materi belajar yang akan membantu kamu menguasai konsep programming dengan cara yang menyenangkan.',
},
{
name: 'resources',
type: 'array',
required: true,
fields: [
{ name: 'icon', type: 'text', required: true },
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{ name: 'buttonText', type: 'text', required: true },
{ name: 'buttonLink', type: 'text', required: true },
],
defaultValue: [
{
icon: 'tabler:video',
title: 'Video Tutorial',
description:
'Belajar melalui tutorial video dari langkah awal hingga mahir.',
buttonText: 'Lihat Semua Video',
buttonLink: '#',
},
{
icon: 'tabler:article',
title: 'Artikel & Tutorial',
description:
'Pelajari konsep programming melalui artikel yang disusun secara terstruktur.',
buttonText: 'Baca Artikel',
buttonLink: '#',
},
{
icon: 'tabler:brand-vscode',
title: 'Tantangan Koding',
description:
'Uji kemampuan koding kamu dengan tantangan yang menyenangkan dan menantang.',
buttonText: 'Mulai Tantangan',
buttonLink: '#',
},
{
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: '#',
},
],
},
{
name: 'featured',
type: 'group',
fields: [
{
name: 'label',
type: 'text',
required: true,
defaultValue: 'Rekomendasi Terbaik',
},
{
name: 'title',
type: 'text',
required: true,
defaultValue: 'Kursus Lengkap Web Development',
},
{
name: 'description',
type: 'textarea',
required: true,
defaultValue:
'Pelajari HTML, CSS, JavaScript, React, dan Node.js dalam satu kursus komprehensif yang dirancang untuk pemula hingga tingkat menengah.',
},
{
name: 'primaryButtonText',
type: 'text',
required: true,
defaultValue: 'Mulai Kursus',
},
{
name: 'primaryButtonLink',
type: 'text',
required: true,
defaultValue: '#',
},
{
name: 'secondaryButtonText',
type: 'text',
required: true,
defaultValue: 'Lihat Silabus',
},
{
name: 'secondaryButtonLink',
type: 'text',
required: true,
defaultValue: '#',
},
],
},
],
};
@@ -0,0 +1,93 @@
import { GlobalConfig } from 'payload';
export const TestimonialsSection: GlobalConfig = {
slug: 'testimonials-section',
label: 'Testimonials Section',
fields: [
{
name: 'title',
label: 'Section Title',
type: 'text',
defaultValue: 'Testimoni Member',
required: true,
},
{
name: 'subtitle',
label: 'Section Subtitle',
type: 'textarea',
defaultValue:
'Apa kata mereka yang telah bergabung dengan komunitas IMPHNEN?',
required: true,
},
{
name: 'items',
label: 'Testimonials Items',
type: 'array',
fields: [
{ name: 'quote', type: 'textarea', label: 'Quote', required: true },
{ name: 'name', type: 'text', label: 'Name', required: true },
{ name: 'role', type: 'text', label: 'Role', required: true },
{
name: 'avatar',
type: 'upload',
label: 'Avatar',
relationTo: 'media',
required: true,
},
],
defaultValue: [
{
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: { id: '', relationTo: 'media' },
},
{
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: { id: '', relationTo: 'media' },
},
{
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: { id: '', relationTo: 'media' },
},
],
},
{
name: 'stats',
label: 'Statistics',
type: 'array',
fields: [
{ name: 'value', type: 'text', label: 'Value', required: true },
{ name: 'label', type: 'text', label: 'Label', required: true },
],
defaultValue: [
{ value: '98%', label: 'Tingkat Kemalasan' },
{ value: '4.9/5', label: 'Rating Drama' },
{ value: '85%', label: 'Mendapat Pekerjaan' },
{ value: '24/7', label: 'Yapping' },
],
},
{
name: 'joinTitle',
label: 'Join Section Title',
type: 'text',
defaultValue: 'Bergabunglah dengan 10,000+ programmer Indonesia lainnya',
required: true,
},
{
name: 'joinText',
label: 'Join Section Text',
type: 'textarea',
defaultValue:
'Komunitas kami terus berkembang dengan programmer dari berbagai latar belakang dan tingkat keahlian. Bersama-sama, kita belajar, berbagi, dan tumbuh sebagai profesional.',
required: true,
},
],
};
+10
View File
@@ -0,0 +1,10 @@
import { Media } from '../payload-types';
export function formatCMSImageDataToMedia(
image: number | Media | null | undefined
): Media | null {
if (image && typeof image === 'object' && 'url' in image) {
return image as Media;
}
return null;
}
+308 -2
View File
@@ -84,8 +84,22 @@ export interface Config {
db: { db: {
defaultIDType: number; defaultIDType: number;
}; };
globals: {}; globals: {
globalsSelect: {}; 'hero-section': HeroSection;
'features-section': FeaturesSection;
'communities-section': CommunitiesSection;
'learning-resources-section': LearningResourcesSection;
'testimonials-section': TestimonialsSection;
'call-to-action-section': CallToActionSection;
};
globalsSelect: {
'hero-section': HeroSectionSelect<false> | HeroSectionSelect<true>;
'features-section': FeaturesSectionSelect<false> | FeaturesSectionSelect<true>;
'communities-section': CommunitiesSectionSelect<false> | CommunitiesSectionSelect<true>;
'learning-resources-section': LearningResourcesSectionSelect<false> | LearningResourcesSectionSelect<true>;
'testimonials-section': TestimonialsSectionSelect<false> | TestimonialsSectionSelect<true>;
'call-to-action-section': CallToActionSectionSelect<false> | CallToActionSectionSelect<true>;
};
locale: null; locale: null;
user: User & { user: User & {
collection: 'users'; collection: 'users';
@@ -271,6 +285,298 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "hero-section".
*/
export interface HeroSection {
id: number;
heroImage: number | Media;
badgeText: string;
title: string;
highlight: string;
description: string;
buttons: {
primaryLabel: string;
primaryUrl: string;
secondaryLabel: string;
secondaryUrl: string;
};
stats?:
| {
value: string;
label: string;
id?: string | null;
}[]
| null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "features-section".
*/
export interface FeaturesSection {
id: number;
heading?: string | null;
subheading?: string | null;
features?:
| {
iconName: string;
title: string;
description?: string | null;
id?: string | null;
}[]
| null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "communities-section".
*/
export interface CommunitiesSection {
id: number;
items: {
iconName: string;
title: string;
description: string;
buttonText: string;
buttonLink: string;
id?: string | null;
}[];
stats: {
value: string;
label: string;
id?: string | null;
}[];
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "learning-resources-section".
*/
export interface LearningResourcesSection {
id: number;
title: string;
description: string;
resources: {
icon: string;
title: string;
description: string;
buttonText: string;
buttonLink: string;
id?: string | null;
}[];
featured: {
label: string;
title: string;
description: string;
primaryButtonText: string;
primaryButtonLink: string;
secondaryButtonText: string;
secondaryButtonLink: string;
};
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "testimonials-section".
*/
export interface TestimonialsSection {
id: number;
title: string;
subtitle: string;
items?:
| {
quote: string;
name: string;
role: string;
avatar: number | Media;
id?: string | null;
}[]
| null;
stats?:
| {
value: string;
label: string;
id?: string | null;
}[]
| null;
joinTitle: string;
joinText: string;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "call-to-action-section".
*/
export interface CallToActionSection {
id: number;
title: string;
highlightedTitle: string;
subtitle: string;
primaryButtonLabel: string;
primaryButtonLink: string;
secondaryButtonLabel: string;
secondaryButtonLink: string;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "hero-section_select".
*/
export interface HeroSectionSelect<T extends boolean = true> {
heroImage?: T;
badgeText?: T;
title?: T;
highlight?: T;
description?: T;
buttons?:
| T
| {
primaryLabel?: T;
primaryUrl?: T;
secondaryLabel?: T;
secondaryUrl?: T;
};
stats?:
| T
| {
value?: T;
label?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "features-section_select".
*/
export interface FeaturesSectionSelect<T extends boolean = true> {
heading?: T;
subheading?: T;
features?:
| T
| {
iconName?: T;
title?: T;
description?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "communities-section_select".
*/
export interface CommunitiesSectionSelect<T extends boolean = true> {
items?:
| T
| {
iconName?: T;
title?: T;
description?: T;
buttonText?: T;
buttonLink?: T;
id?: T;
};
stats?:
| T
| {
value?: T;
label?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "learning-resources-section_select".
*/
export interface LearningResourcesSectionSelect<T extends boolean = true> {
title?: T;
description?: T;
resources?:
| T
| {
icon?: T;
title?: T;
description?: T;
buttonText?: T;
buttonLink?: T;
id?: T;
};
featured?:
| T
| {
label?: T;
title?: T;
description?: T;
primaryButtonText?: T;
primaryButtonLink?: T;
secondaryButtonText?: T;
secondaryButtonLink?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "testimonials-section_select".
*/
export interface TestimonialsSectionSelect<T extends boolean = true> {
title?: T;
subtitle?: T;
items?:
| T
| {
quote?: T;
name?: T;
role?: T;
avatar?: T;
id?: T;
};
stats?:
| T
| {
value?: T;
label?: T;
id?: T;
};
joinTitle?: T;
joinText?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "call-to-action-section_select".
*/
export interface CallToActionSectionSelect<T extends boolean = true> {
title?: T;
highlightedTitle?: T;
subtitle?: T;
primaryButtonLabel?: T;
primaryButtonLink?: T;
secondaryButtonLabel?: T;
secondaryButtonLink?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth". * via the `definition` "auth".
+39 -1
View File
@@ -1,11 +1,20 @@
import { postgresAdapter } from '@payloadcms/db-postgres'; import { postgresAdapter } from '@payloadcms/db-postgres';
import { lexicalEditor } from '@payloadcms/richtext-lexical'; import { lexicalEditor } from '@payloadcms/richtext-lexical';
import { s3Storage } from '@payloadcms/storage-s3';
import { NodeHttpHandler } from '@smithy/node-http-handler';
import https from 'https';
import path from 'path'; import path from 'path';
import { buildConfig } from 'payload'; import { buildConfig } from 'payload';
import sharp from 'sharp'; import sharp from 'sharp';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { CallToActionSection } from './collections/CallToActionSection';
import { CommunitiesSection } from './collections/CommunitiesSection';
import { FeaturesSection } from './collections/FeaturesSection';
import { HeroSection } from './collections/HeroSection';
import { LearningResourcesSection } from './collections/LearningResourcesSection';
import { Media } from './collections/Media'; import { Media } from './collections/Media';
import { TestimonialsSection } from './collections/TestimonialsSection';
import { Users } from './collections/Users'; import { Users } from './collections/Users';
const filename = fileURLToPath(import.meta.url); const filename = fileURLToPath(import.meta.url);
@@ -25,6 +34,14 @@ export default buildConfig({
}, },
}, },
collections: [Users, Media], collections: [Users, Media],
globals: [
HeroSection,
FeaturesSection,
CommunitiesSection,
LearningResourcesSection,
TestimonialsSection,
CallToActionSection,
],
editor: lexicalEditor(), editor: lexicalEditor(),
secret: process.env.CMS_SECRET || '', secret: process.env.CMS_SECRET || '',
typescript: { typescript: {
@@ -36,5 +53,26 @@ export default buildConfig({
}, },
}), }),
sharp, sharp,
plugins: [], plugins: [
s3Storage({
collections: {
media: {
disableLocalStorage: true,
},
},
bucket: 'landing-cms',
config: {
endpoint: process.env.CMS_STORAGE_ENDPOINT,
credentials: {
accessKeyId: process.env.CMS_STORAGE_ACCESS_KEY_ID!,
secretAccessKey: process.env.CMS_STORAGE_SECRET_ACCESS_KEY!,
},
region: process.env.CMS_STORAGE_REGION!,
forcePathStyle: true,
requestHandler: new NodeHttpHandler({
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
}),
},
}),
],
}); });
@@ -0,0 +1,7 @@
import { payload } from '../lib/payload';
export async function getGlobalsCallToActionSection() {
return await payload.findGlobal({
slug: 'call-to-action-section',
});
}
@@ -0,0 +1,7 @@
import { payload } from '../lib/payload';
export async function getGlobalsCommunitiesSection() {
return await payload.findGlobal({
slug: 'communities-section',
});
}
@@ -0,0 +1,7 @@
import { payload } from '../lib/payload';
export async function getGlobalsFeaturesSection() {
return await payload.findGlobal({
slug: 'features-section',
});
}
@@ -0,0 +1,7 @@
import { payload } from '../lib/payload';
export async function getGlobalsHeroSection() {
return await payload.findGlobal({
slug: 'hero-section',
});
}
@@ -0,0 +1,7 @@
import { payload } from '../lib/payload';
export async function getGlobalsLearningResourcesSection() {
return await payload.findGlobal({
slug: 'learning-resources-section',
});
}
@@ -0,0 +1,7 @@
import { payload } from '../lib/payload';
export async function getGlobalsTestimonialsSection() {
return await payload.findGlobal({
slug: 'testimonials-section',
});
}
-6
View File
@@ -6,7 +6,6 @@ import {
TRegisterRequest, TRegisterRequest,
TVerifyEmailRequest, TVerifyEmailRequest,
} from '../../types/auth'; } from '../../types/auth';
import { SessionToken, SessionUser } from '@imphnen-frontend-service/utils';
import { TResponseError, TResponseMessage } from '../../types/common'; import { TResponseError, TResponseMessage } from '../../types/common';
@@ -19,11 +18,6 @@ export const usePostLogin = (): UseMutationResult<
return useMutation({ return useMutation({
mutationKey: ['post-login'], mutationKey: ['post-login'],
mutationFn: async (payload) => await postLogin(payload), mutationFn: async (payload) => await postLogin(payload),
onSuccess: (res) => {
SessionUser.set(res.data.user);
SessionToken.set(res.data.token);
window.location.reload();
},
}); });
}; };
+7 -26
View File
@@ -16,7 +16,7 @@ export const authLoginSchema = z.object({
.min(1, 'Password tidak boleh kosong'), .min(1, 'Password tidak boleh kosong'),
}); });
export const stepOneRegisterSchema = z export const authRegisterSchema = z
.object({ .object({
email: z email: z
.string({ .string({
@@ -25,6 +25,12 @@ export const stepOneRegisterSchema = z
}) })
.min(1, 'Email tidak boleh kosong') .min(1, 'Email tidak boleh kosong')
.email('Email harus valid'), .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 fullname: z
.string({ .string({
required_error: 'Nama tidak boleh kosong', required_error: 'Nama tidak boleh kosong',
@@ -51,28 +57,3 @@ export const stepOneRegisterSchema = z
message: 'Password dan Konfirmasi Password harus sama', message: 'Password dan Konfirmasi Password harus sama',
path: ['confirm_password'], 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
);
+17 -20
View File
@@ -1,29 +1,26 @@
import { z } from 'zod';
import { authLoginSchema, authRegisterSchema } from '../../schemas';
import { TResponseDetail, TResponseMessage } from '../common';
import { TUserItem } from '../users'; import { TUserItem } from '../users';
export type TLoginRequest = { export type TTokenItem = {
email: string; access_token: string;
password: string; refresh_token: string;
}; };
export type TLoginResponse = { export type TLoginItem = {
data: { token?: TTokenItem;
token: { user?: TUserItem;
access_token: string;
refresh_token: string;
};
user: TUserItem;
};
}; };
export type TRegisterRequest = { export type TLoginRequest = z.infer<typeof authLoginSchema>;
email: string; export type TLoginResponse = TResponseDetail<TLoginItem>;
fullname: string;
password: string; export type TRegisterRequest = z.infer<typeof authRegisterSchema>;
phone_number: string; export type TRegisterResponse = TResponseDetail<TResponseMessage>;
referral_code?: string;
referred_by?: string; export type TVerifyOtpRequest = {
student_type: string; otp: number;
confirm_password: string;
}; };
export type TVerifyEmailRequest = { export type TVerifyEmailRequest = {
+13 -1
View File
@@ -1,7 +1,19 @@
import { AxiosError } from 'axios'; 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; data: T;
message: string;
};
export type TResponseList<T = unknown> = {
data: T[];
meta: TMetaResponse;
}; };
export type TResponseMessage = { export type TResponseMessage = {
+10 -2
View File
@@ -1,9 +1,17 @@
import { TPermissionItem } from '../permissions'; import { TPermissionItem } from '../permissions';
export type TRoleItem = { export type TRoleDetailItem = {
id: string; id: string;
name: string; name: string;
permissions: TPermissionItem[];
created_at: string;
updated_at: string;
};
export type TRolesListItem = {
id: string;
name: string;
permissions_count: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
permissions: TPermissionItem[];
}; };
+2 -8
View File
@@ -1,4 +1,4 @@
import { TRoleItem } from '../roles'; import { TRoleDetailItem } from '../roles';
export type TUserItem = { export type TUserItem = {
id: string; id: string;
@@ -7,13 +7,7 @@ export type TUserItem = {
email: string; email: string;
fullname: string; fullname: string;
gender: string; gender: string;
identity_number: string;
is_active: boolean; is_active: boolean;
is_profile_completed: boolean;
phone_number: string; phone_number: string;
referral_code: string; role: TRoleDetailItem;
referred_by: string;
religion: string;
student_type: string;
role: TRoleItem;
}; };
+1
View File
@@ -0,0 +1 @@
export * from './permissions';
+40
View File
@@ -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',
},
};
+36
View File
@@ -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);
},
};
+2 -1
View File
@@ -1,3 +1,4 @@
export * from './use-query-state'; export * from './use-query-state';
export * from './use-session'; export * from './use-auth-store';
export * from './use-modal-login'; export * from './use-modal-login';
export * from './use-session';
+47
View File
@@ -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 });
},
};
});
+27 -12
View File
@@ -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 = () => { export const useSession = () => {
const session = { const navigate = useNavigate();
user: SessionUser.get(), const { mutate, isPending } = usePostLogin();
token: SessionToken.get(), 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 = () => { const signOut = () => {
SessionUser.remove(); clearSession();
SessionToken.remove(); navigate(0);
window.location.reload();
}; };
return { return {
isAuthenticated,
session, session,
signIn,
signOut, signOut,
isLoading: isPending,
}; };
}; };
+3
View File
@@ -3,3 +3,6 @@ export * from './react-router';
export * from './tailwind-merge'; export * from './tailwind-merge';
export * from './hooks'; export * from './hooks';
export * from './local-storage'; export * from './local-storage';
export * from './cookies';
export * from './session';
export * from './constants';
+1 -26
View File
@@ -20,41 +20,16 @@ type TUserItem = {
email: string; email: string;
fullname: string; fullname: string;
gender: string; gender: string;
identity_number: string;
is_active: boolean; is_active: boolean;
is_profile_completed: boolean;
phone_number: string; phone_number: string;
referral_code: string;
referred_by: string;
religion: string;
student_type: string;
role: TRoleItem; role: TRoleItem;
}; };
export const SessionUser = { export const SessionUser = {
set: (val: TUserItem) => localStorage.setItem('users', JSON.stringify(val)), set: (val?: TUserItem) => localStorage.setItem('users', JSON.stringify(val)),
get: (): TUserItem | undefined => { get: (): TUserItem | undefined => {
const users = localStorage.getItem('users'); const users = localStorage.getItem('users');
return users ? JSON.parse(users) : undefined; return users ? JSON.parse(users) : undefined;
}, },
remove: () => localStorage.removeItem('users'), 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'; import { ActionFunction, LoaderFunction, RouteObject } from 'react-router';
type TPermissionItem = {
id: string;
name: string;
created_at: string;
updated_at: string;
};
interface PageModuleExports { interface PageModuleExports {
default: () => ReactElement; default: () => ReactNode;
loader?: LoaderFunction; loader?: LoaderFunction;
action?: ActionFunction; action?: ActionFunction;
permissions?: Array<string>; permissions?: Array<string>;
} }
interface LoadingModuleExports { interface LoadingModuleExports {
default: () => ReactElement; default: () => ReactNode;
} }
interface RouteHandle { interface RouteHandle {
@@ -38,7 +45,6 @@ export function convertPagesToRoute(
filePath, filePath,
loadingFiles loadingFiles
); );
const route = createRoute({ const route = createRoute({
PageComponent: page, PageComponent: page,
LoadingComponent: loadingComponent, LoadingComponent: loadingComponent,
@@ -51,6 +57,20 @@ export function convertPagesToRoute(
const result = (await importer()) as PageModuleExports; const result = (await importer()) as PageModuleExports;
return 'loader' in result ? result.loader?.(args) : null; 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); routes = mergeRoutes(routes, route);
}); });
@@ -62,19 +82,14 @@ function findMatchingLoadingComponent(
loadingFiles: Record<string, () => Promise<unknown>> loadingFiles: Record<string, () => Promise<unknown>>
) { ) {
const loadingPath = filePath.replace(/(page|layout)\.tsx$/, 'loading.tsx'); const loadingPath = filePath.replace(/(page|layout)\.tsx$/, 'loading.tsx');
const groupMatch = filePath.match(/\([^/]+\//); const groupMatch = filePath.match(/\([^/]+\//);
const groupLoadingPath = groupMatch ? `/${groupMatch[0]}loading.tsx` : null; const groupLoadingPath = groupMatch ? `/${groupMatch[0]}loading.tsx` : null;
const globalLoadingPath = './app/loading.tsx'; const globalLoadingPath = './app/loading.tsx';
const loader = const loader =
loadingFiles[loadingPath] || loadingFiles[loadingPath] ||
(groupLoadingPath && loadingFiles[groupLoadingPath]) || (groupLoadingPath && loadingFiles[groupLoadingPath]) ||
loadingFiles[globalLoadingPath]; loadingFiles[globalLoadingPath];
if (!loader) return undefined; if (!loader) return undefined;
return lazy(loader as () => Promise<LoadingModuleExports>); return lazy(loader as () => Promise<LoadingModuleExports>);
} }
@@ -86,68 +101,110 @@ function mergeRoutes(
throw new Error( throw new Error(
`Paths do not match: "${target.path}" and "${source.path}"` `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) { if (!target.children) {
target.children = []; target.children = [];
} }
source.children.forEach((sourceChild) => {
const matchingChild = target.children!.find(
(targetChild) => targetChild.path === sourceChild.path
);
if (source.handle?.pageType === 'layout') { if (matchingChild) {
if (!target.element) { mergeRoutes(matchingChild, sourceChild);
target.element = source.element; } else {
target.HydrateFallback = source.HydrateFallback; target.children!.push(sourceChild);
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);
} }
return target; });
} }
if ( export function handleLayoutMerge(
source.handle?.pageType === 'page' && target: ExtendedRouteObject,
!target.children.some((child) => child.index) source: ExtendedRouteObject
) { ): ExtendedRouteObject {
target.children.unshift({ if (!target.element) {
index: true, Object.assign(target, {
element: source.element, element: source.element,
HydrateFallback: source.HydrateFallback, HydrateFallback: source.HydrateFallback,
action: source.action, action: source.action,
loader: source.loader, loader: source.loader,
handle: source.handle, 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; return target;
} }
function swapTargetRouteAsIndexRouteAndUpdateWithRoute( export function handlePageMerge(
target: ExtendedRouteObject, target: ExtendedRouteObject,
route: ExtendedRouteObject source: ExtendedRouteObject
): 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({ target.children.push({
index: true, index: true,
element: target.element, element: target.element,
@@ -157,38 +214,38 @@ function swapTargetRouteAsIndexRouteAndUpdateWithRoute(
handle: target.handle, handle: target.handle,
errorElement: target.errorElement, errorElement: target.errorElement,
}); });
Object.assign(target, {
target.element = route.element; element: layout.element,
target.HydrateFallback = route.HydrateFallback; HydrateFallback: layout.HydrateFallback,
target.action = route.action; action: layout.action,
target.loader = route.loader; loader: layout.loader,
target.handle = route.handle; handle: layout.handle,
target.errorElement = route.errorElement; errorElement: layout.errorElement,
});
return target; return target;
} }
function addRouteAsIndexRouteForTargetRoute( export function addRouteAsIndexRouteForTargetRoute(
target: ExtendedRouteObject, target: ExtendedRouteObject,
route: ExtendedRouteObject page: ExtendedRouteObject
): ExtendedRouteObject { ): ExtendedRouteObject {
target.children = target.children ?? []; target.children = target.children || [];
target.children.push({ target.children.push({
index: true, index: true,
element: route.element, element: page.element,
HydrateFallback: route.HydrateFallback, HydrateFallback: page.HydrateFallback,
action: route.action, action: page.action,
loader: route.loader, loader: page.loader,
handle: route.handle, handle: page.handle,
errorElement: route.errorElement, errorElement: page.errorElement,
}); });
return target; return target;
} }
function createRoute(args: { function createRoute(args: {
segments: string[]; segments: string[];
PageComponent: LazyExoticComponent<() => ReactElement>; PageComponent: LazyExoticComponent<() => ReactNode>;
LoadingComponent?: LazyExoticComponent<() => ReactElement>; LoadingComponent?: LazyExoticComponent<() => ReactNode>;
loader?: LoaderFunction; loader?: LoaderFunction;
action?: ActionFunction; action?: ActionFunction;
guard?: () => Promise<boolean>; guard?: () => Promise<boolean>;
@@ -196,21 +253,24 @@ function createRoute(args: {
const [current, ...rest] = args.segments; const [current, ...rest] = args.segments;
const [cleanPath, pageType] = current.split(separator); const [cleanPath, pageType] = current.split(separator);
const route: ExtendedRouteObject = { path: cleanPath }; const route: ExtendedRouteObject = { path: cleanPath };
if (pageType === 'page' || pageType === 'layout') { if (pageType === 'page' || pageType === 'layout') {
route.element = <args.PageComponent />; route.element = <args.PageComponent />;
route.HydrateFallback = route.HydrateFallback =
args.LoadingComponent ?? (() => <div>Loading...</div>); args.LoadingComponent ?? (() => <div>Loading...</div>);
route.action = args.action; route.action = args.action;
route.loader = async (...props) => { route.loader = async (...props) => {
if (!(await args.guard?.())) {
throw new Response('Forbidden', {
status: 403,
statusText: 'Forbidden',
});
}
return args.loader?.(...props); return args.loader?.(...props);
}; };
route.handle = { pageType: pageType as 'layout' | 'page' }; route.handle = { pageType: pageType as 'layout' | 'page' };
} }
if (rest.length > 0) { if (rest.length > 0) {
const nextSegment = rest[0].split(separator)[0]; const nextSegment = rest[0].split(separator)[0];
if (nextSegment === 'update' || nextSegment === 'edit') { if (nextSegment === 'update' || nextSegment === 'edit') {
return { return {
path: `${cleanPath}/${nextSegment}`, path: `${cleanPath}/${nextSegment}`,
@@ -221,20 +281,16 @@ function createRoute(args: {
handle: { pageType: pageType as 'layout' | 'page' }, handle: { pageType: pageType as 'layout' | 'page' },
}; };
} }
const childRoute = createRoute({ ...args, segments: rest }); const childRoute = createRoute({ ...args, segments: rest });
if (!route.children) { if (!route.children) {
route.children = []; route.children = [];
} }
if (cleanPath.startsWith(':')) { if (cleanPath.startsWith(':')) {
route.children.unshift(childRoute); route.children.unshift(childRoute);
} else { } else {
route.children.push(childRoute); route.children.push(childRoute);
} }
} }
return route; return route;
} }
@@ -258,7 +314,6 @@ export function getRouteSegmentsFromFilePath(
if (segment.startsWith('[')) return getParamFromSegment(segment); if (segment.startsWith('[')) return getParamFromSegment(segment);
return segment; return segment;
}); });
return getRouteSegments(segments[0], segments, transformer); return getRouteSegments(segments[0], segments, transformer);
} }
@@ -306,7 +361,7 @@ export function addErrorElementToRoutes(
(_, prevSegment) => prevSegment (_, prevSegment) => prevSegment
); );
const ErrorBoundary = lazy( const ErrorBoundary = lazy(
importer as () => Promise<{ default: () => ReactElement }> importer as () => Promise<{ default: () => ReactNode }>
); );
setRoute(segments, routes, (route) => { setRoute(segments, routes, (route) => {
route.errorElement = <ErrorBoundary />; route.errorElement = <ErrorBoundary />;
@@ -325,7 +380,7 @@ export function add404PageToRoutesChildren(
(_, prevSegment) => prevSegment (_, prevSegment) => prevSegment
); );
const NotFound = lazy( const NotFound = lazy(
importer as () => Promise<{ default: () => ReactElement }> importer as () => Promise<{ default: () => ReactNode }>
); );
setRoute(segments, routes, (route) => { setRoute(segments, routes, (route) => {
if (route.children) { if (route.children) {
@@ -340,9 +395,7 @@ export function add404PageToRoutesChildren(
action: tempRoute.action, action: tempRoute.action,
loader: tempRoute.loader, loader: tempRoute.loader,
}); });
route.children.push({ path: '*', element: <NotFound /> }); route.children.push({ path: '*', element: <NotFound /> });
delete route.element; delete route.element;
delete route.action; delete route.action;
delete route.loader; delete route.loader;
@@ -352,7 +405,7 @@ export function add404PageToRoutesChildren(
}); });
} }
function set404NonPage(routes: RouteObject, notFoundElement: ReactElement) { function set404NonPage(routes: RouteObject, notFoundElement: ReactNode) {
if ( if (
routes.path && routes.path &&
routes.children?.length && routes.children?.length &&
@@ -377,7 +430,6 @@ function setRoute(
segments.forEach((_segment, i) => { segments.forEach((_segment, i) => {
const isLastSegment = i === segments.length - 1; const isLastSegment = i === segments.length - 1;
if (isLastSegment) return (temp = updater(temp)); if (isLastSegment) return (temp = updater(temp));
if (!isLastSegment) { if (!isLastSegment) {
const nextSegment = segments[i + 1]; const nextSegment = segments[i + 1];
const index = temp.children?.findIndex( const index = temp.children?.findIndex(
+27
View File
@@ -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;
};
+1
View File
@@ -0,0 +1 @@
export * from './guard';
+1811 -22
View File
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -3,15 +3,15 @@
"version": "0.0.0", "version": "0.0.0",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"gacha:dev": "nx serve gacha", "gacha:dev": "nx dev gacha",
"gacha:build": "nx build gacha", "gacha:build": "nx build gacha",
"gacha:test": "nx test gacha", "gacha:test": "nx test gacha",
"gacha:prod": "serve ./dist/apps/gacha", "gacha:prod": "serve ./dist/apps/gacha",
"backoffice:dev": "nx serve backoffice", "backoffice:dev": "nx dev backoffice",
"backoffice:build": "nx build backoffice", "backoffice:build": "nx build backoffice",
"backoffice:test": "nx test backoffice", "backoffice:test": "nx test backoffice",
"backoffice:prod": "serve ./dist/apps/backoffice", "backoffice:prod": "serve ./dist/apps/backoffice",
"dimentorin:dev": "nx serve dimentorin", "dimentorin:dev": "nx dev dimentorin",
"dimentorin:test": "nx test dimentorin", "dimentorin:test": "nx test dimentorin",
"dimentorin:build": "nx build dimentorin", "dimentorin:build": "nx build dimentorin",
"dimentorin:prod": "serve ./dist/apps/dimentorin", "dimentorin:prod": "serve ./dist/apps/dimentorin",
@@ -28,10 +28,13 @@
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.0.0", "@ant-design/icons": "^6.0.0",
"@hookform/resolvers": "^5.0.1", "@hookform/resolvers": "^5.0.1",
"@iconify/react": "^6.0.0",
"@payloadcms/db-postgres": "^3.37.0", "@payloadcms/db-postgres": "^3.37.0",
"@payloadcms/next": "^3.37.0", "@payloadcms/next": "^3.37.0",
"@payloadcms/richtext-lexical": "^3.37.0", "@payloadcms/richtext-lexical": "^3.37.0",
"@payloadcms/storage-s3": "^3.37.0",
"@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-slot": "^1.2.0",
"@smithy/node-http-handler": "^4.0.4",
"@tanstack/react-query": "^5.74.4", "@tanstack/react-query": "^5.74.4",
"@tanstack/react-store": "^0.7.0", "@tanstack/react-store": "^0.7.0",
"@tanstack/react-table": "^8.21.2", "@tanstack/react-table": "^8.21.2",
@@ -52,7 +55,8 @@
"sharp": "^0.34.1", "sharp": "^0.34.1",
"sonner": "^2.0.3", "sonner": "^2.0.3",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^3.0.2",
"zod": "^3.24.2" "zod": "^3.24.2",
"zustand": "^5.0.5"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.14.5", "@babel/core": "^7.14.5",
+8
View File
@@ -0,0 +1,8 @@
{
"routes": [
{
"src": "/[^.]+",
"dest": "/"
}
]
}