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
Rasyid 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=
+25 -4
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,6 +92,10 @@ 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
@@ -87,7 +109,6 @@ This repository uses Storybook to develop, test, and document UI components in a
npm run ui:storybook npm run ui:storybook
``` ```
- **Run Unit Test** - **Run Unit Test**
This command runs unit tests for the UI components to ensure they function as expected. This command runs unit tests for the UI components to ensure they function as expected.
+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 <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 }}
> >
<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 text-center mb-4">
Komunitas{' '} Komunitas{' '}
<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">
Kami Kami
</span> </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 text-center md:text-lg">
Bergabunglah dengan ribuan programmer Indonesia yang saling Bergabunglah dengan ribuan programmer Indonesia yang saling membantu
membantu dan berbagi pengalaman. dan berbagi pengalaman.
</p> </p>
</motion.div> </motion.div>
</div>
<motion.div <motion.div
className="grid gap-8 md:grid-cols-3" 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 key={i} className="space-y-2">
<div className="text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400"> <div className="text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
10K+ {s.value}
</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>
<div className="text-sm text-muted-foreground">{s.label}</div>
</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">
<Link href="/">
<Image <Image
src="/logo.png" src="/logo.png"
alt="IMPHNEN Logo" alt="IMPHNEN"
width={64} width={64}
height={64} height={64}
className="object-cover cursor-pointer" 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>
<a
href="https://discord.com/invite/imphnen"
target="_blank"
className="mt-4"
>
<Button className="mt-4 bg-gradient-to-r from-primary to-blue-400 hover:from-primary/90 hover:to-blue-400/90 font-bold text-black hover:text-white cursor-pointer"> <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 Gabung Discord
</Button> </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="bg-gradient-to-r from-primary to-blue-400 hover:from-primary/90 hover:to-blue-400/90 transition-all duration-300 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"
font-bold text-black hover:text-white cursor-pointer" onClick={() => window.open(buttons.primaryUrl, '_blank')}
> >
Mulai Belajar {buttons.primaryLabel}
</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">
{stats?.map(({ value, label }, i) => (
<Fragment key={i}>
<div className="flex flex-col items-center"> <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"> <div className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
100K+ {value}
</div> </div>
<div className="text-xs text-muted-foreground">Member</div> <div className="text-xs text-muted-foreground">{label}</div>
</div>
<div className="h-10 border-r border-border"></div>
<div className="flex flex-col items-center">
<div className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
500+
</div>
<div className="text-xs text-muted-foreground">Tutorial</div>
</div>
<div className="h-10 border-r border-border"></div>
<div className="flex flex-col items-center">
<div className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
24/7
</div>
<div className="text-xs text-muted-foreground">Yapping</div>
</div> </div>
{i < stats.length - 1 && (
<div className="hidden sm:block h-10 border-r border-border mx-4" />
)}
</Fragment>
))}
</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" />
{(() => {
const media = formatCMSImageDataToMedia(heroImage);
if (!media) return null;
return (
<Image <Image
src="/hero.jpeg" src={media.url!}
alt={media.alt}
width={600} width={600}
height={500} height={500}
alt="IMPHNEN Programming Community"
className="w-full h-auto object-cover" 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="space-y-2" className="flex flex-col items-center justify-center space-y-4 text-center mb-16"
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 lg:text-5xl">
Testimoni{' '} {title.split(' ').map((word, i) =>
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400"> i === title.split(' ').length - 1 ? (
Member <span
key={i}
className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400"
>
{word}
</span> </span>
) : (
<span key={i}>{word} </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">
Apa kata mereka yang telah bergabung dengan komunitas IMPHNEN? {subtitle}
</p> </p>
</motion.div> </motion.div>
</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">
{(() => {
const media = formatCMSImageDataToMedia(t.avatar);
if (!media) return null;
return (
<Image <Image
src={testimonial.avatar || '/placeholder.svg'} src={media.url!}
alt={testimonial.name} alt={media.alt}
width={48} width={600}
height={48} height={500}
className="object-cover" 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 key={idx} className="rounded-lg border p-4 text-center">
<div className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400"> <div className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
98% {s.value}
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">{s.label}</div>
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> </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
);
+16 -19
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;
password: string;
};
export type TLoginResponse = {
data: {
token: {
access_token: string; access_token: string;
refresh_token: string; refresh_token: string;
}; };
user: TUserItem;
}; export type TLoginItem = {
token?: TTokenItem;
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,30 +101,91 @@ 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) {
mergeRoutes(matchingChild, sourceChild);
} else {
target.children!.push(sourceChild);
}
});
}
export function handleLayoutMerge(
target: ExtendedRouteObject,
source: ExtendedRouteObject
): ExtendedRouteObject {
if (!target.element) { if (!target.element) {
target.element = source.element; Object.assign(target, {
target.HydrateFallback = source.HydrateFallback; element: source.element,
target.action = source.action; HydrateFallback: source.HydrateFallback,
target.loader = source.loader; action: source.action,
target.handle = source.handle; loader: source.loader,
target.errorElement = source.errorElement; handle: source.handle,
target.children = target.children ?? []; errorElement: source.errorElement,
});
} else if (target.handle?.pageType === 'page') { } else if (target.handle?.pageType === 'page') {
target = swapTargetRouteAsIndexRouteAndUpdateWithRoute(target, source); target = swapTargetRouteAsIndexRouteAndUpdateWithRoute(target, source);
} }
return target; return target;
} }
export function handlePageMerge(
target: ExtendedRouteObject,
source: ExtendedRouteObject
): ExtendedRouteObject {
if (!target.children) {
target.children = [];
}
if ( if (
source.handle?.pageType === 'page' && !target.children.some((child) => child.index) ||
!target.children.some((child) => child.index) target.handle?.pageType === 'layout'
) { ) {
if (target.handle?.pageType === 'layout') {
addRouteAsIndexRouteForTargetRoute(target, source);
} else {
target.children.unshift({ target.children.unshift({
index: true, index: true,
element: source.element, element: source.element,
@@ -117,37 +193,18 @@ function mergeRoutes(
action: source.action, action: source.action,
loader: source.loader, loader: source.loader,
handle: source.handle, handle: source.handle,
}); errorElement: source.errorElement,
return target;
}
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 swapTargetRouteAsIndexRouteAndUpdateWithRoute(
target: ExtendedRouteObject, target: ExtendedRouteObject,
route: ExtendedRouteObject layout: ExtendedRouteObject
): ExtendedRouteObject { ): ExtendedRouteObject {
target.children = target.children ?? []; 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": "/"
}
]
}