Compare commits

..
Author SHA1 Message Date
arraysid acb6f53a76 fix: update header component to use router for navigation and add signin page 2025-05-25 15:04:33 +07:00
arraysid f0a5c89407 fix: update testimonials placeholder sample data 2025-05-25 14:57:27 +07:00
arraysid 989d0fc21b fix: adjust alignment of hero stats section for better layout 2025-05-25 14:55:22 +07:00
arraysid 98ce76f496 fix: update import path for global styles in layout component 2025-05-25 14:53:02 +07:00
arraysid 25ca86f100 chore: restructure component organization 2025-05-25 14:51:53 +07:00
arraysid 76727c0762 fix: update button styles for improved visibility 2025-05-25 14:49:06 +07:00
arraysid 7dd13fce9e fix: change logo.png to logo.webp 2025-05-25 14:41:53 +07:00
arraysid a33c1a56a8 fix: remove unused image in public 2025-05-25 14:37:15 +07:00
arraysid 868b0c02df fix:remove hero logo box 2025-05-25 14:36:58 +07:00
arraysid 3d69d34a7e feat: refactor community, features, hero, learning resources, and testimonials components to use external JSON data 2025-05-25 14:28:08 +07:00
arraysid 738b0b5728 fix: nextjs landing utils 2025-05-25 14:17:36 +07:00
arraysid edc8fff13a chore: remove all references to PayloadCMS 2025-05-25 13:32:58 +07:00
Maulana Sodiqin 9a0fee65a5 fixing so much trouble 2025-05-23 00:28:29 +07:00
Maulana Sodiqin 376e646c10 feat: add vercel json 2025-05-22 22:31:04 +07:00
Maulana Sodiqin f8cd57a1cf chore: update readme and env example 2025-05-20 11:09:14 +07:00
RasyidandGitHub 5618a8e99b fix: hero section responsiveness (#28)
* fix: hero section responsiveness

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

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

* feat: add payload configuration and update TypeScript target

* fix: update TypeScript paths for payload configuration

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

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

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

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

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

* feat: add revalidate constant to control page revalidation interval

* refactor: optimize data fetching by using Promise.all

* fix: update revalidation interval to 10 seconds

* feat: add CommunitiesSection configuration and integrate with Community component

* refactor: move global section functions to services directory

* feat: add support for s3 bucket for media

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

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

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

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

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

* feat: enhance LearningResourcesSection with primary and secondary button links

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

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

* feat: simplify joinTitle rendering in Testimonials section

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

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

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

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

* fix: supress scss deps warning

* fix: add placeholder to .env.example

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

* fix: remove unused environment variables from test build workflow

* fix: text color (#26)

* chore: update action

* feat: integrate payload cms to landing page

* feat: add payload configuration and update TypeScript target

* fix: update TypeScript paths for payload configuration

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

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

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

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

* feat: add revalidate constant to control page revalidation interval

* refactor: optimize data fetching by using Promise.all

* fix: update revalidation interval to 10 seconds

* feat: add CommunitiesSection configuration and integrate with Community component

* refactor: move global section functions to services directory

* feat: add support for s3 bucket for media

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

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

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

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

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

* feat: enhance LearningResourcesSection with primary and secondary button links

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

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

* feat: simplify joinTitle rendering in Testimonials section

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

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

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

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

* fix: supress scss deps warning

* fix: add placeholder to .env.example

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

* fix: remove unused environment variables from test build workflow

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

---------

Co-authored-by: Fahim Ardani <fahimardani25@gmail.com>
Co-authored-by: Maulana Sodiqin <53475078+maulanasdqn@users.noreply.github.com>
2025-05-11 21:55:20 +07:00
Maulana SodiqinandGitHub 246adb57a1 chore: update action 2025-05-10 18:38:31 +07:00
Fahim ArdaniandGitHub 75839271ad fix: text color (#26) 2025-05-10 18:31:56 +07:00
RasyidandGitHub 6e78b49731 feat: integrate payload cms to landing page (#25)
* 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
2025-05-08 22:11:57 +07:00
RasyidandGitHub 3aa552afd6 feat: landing page migration (#24)
* chore: cleanup nextjs project

* feat(shadcn-ui): initialize shadcn ui libs

* feat: update landing app with shadcnI integration

* feat: add path mapping for shadcn-ui atoms in tsconfig

* feat: remove unused tw-animate-css import

* feat: migrate header component

* feat: migrate hero component

* feat: migrate features component

* feat: migrate community component

* feat: migrate learning resources component

* feat: migrate testimonials component

* feat: migrate call to action component

* feat: migrate footer component

* feat: migrate simple theme switcher component

* feat: update dependencies in package.json and package-lock.json

* chore: add tailwind-animate-css package

* feat: initialize index file to export atoms

* chore: remove pnpm, accidentally added out of habit
2025-04-27 09:31:24 +07:00
99 changed files with 2373 additions and 1100 deletions
-1
View File
@@ -1 +0,0 @@
VITE_API_URL=https://api.example.com
+9
View File
@@ -24,5 +24,14 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
- name: Set env vars
run: |
echo "CMS_SECRET=${{ secrets.CMS_SECRET }}" >> $GITHUB_ENV
echo "CMS_STORAGE_REGION=${{ secrets.CMS_STORAGE_REGION }}" >> $GITHUB_ENV
echo "CMS_STORAGE_ENDPOINT=${{ secrets.CMS_STORAGE_ENDPOINT }}" >> $GITHUB_ENV
echo "CMS_STORAGE_ACCESS_KEY_ID=${{ secrets.CMS_STORAGE_ACCESS_KEY_ID }}" >> $GITHUB_ENV
echo "CMS_STORAGE_SECRET_ACCESS_KEY=${{ secrets.CMS_STORAGE_SECRET_ACCESS_KEY }}" >> $GITHUB_ENV
echo "CMS_POSTGRES_URL=${{ secrets.CMS_POSTGRES_URL }}" >> $GITHUB_ENV
- name: Nx Build - name: Nx Build
run: npx nx run-many --target=build --all run: npx nx run-many --target=build --all
+26 -5
View File
@@ -6,9 +6,10 @@
This repository is a **monorepo** for all frontend services of IMPHNEN. The monorepo includes three main applications: This repository is a **monorepo** for all frontend services of IMPHNEN. The monorepo includes three main applications:
1. **Gacha** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">gacha website</a>. 1. **Gacha** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Gacha Website</a>.
2. **Backoffice** - Application for internal management. 2. **Backoffice** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Internal Management Website</a>.
3. **Dimentorin** - Application for mentoring services. 3. **Dimentorin** - Application for <a href="https://dimentorin.imphnen.dev/" target="_blank">Mentoring Service</a>.
4. **Landing Page** - Application for <a href="https://imphnen.dev/" target="_blank">Landing Page</a>.
## How to install ## How to install
@@ -24,6 +25,15 @@ This repository is a **monorepo** for all frontend services of IMPHNEN. The mono
## How to run ## How to run
### Setup Environment Variables
Before running the applications, you need to set up the environment variables. You can do this by copying the `.env.example` file to `.env` and modifying the values according to your needs.
```sh
cd apps/{appname}
cp .env.example .env
```
### Development ### Development
Use the following commands to run in development mode: Use the following commands to run in development mode:
@@ -40,6 +50,10 @@ Use the following commands to run in development mode:
```sh ```sh
npm run dimentorin:dev npm run dimentorin:dev
``` ```
- **Landing Page**:
```sh
npm run landing:dev
```
### Build ### Build
@@ -57,6 +71,10 @@ Use the following commands to build the applications:
```sh ```sh
npm run dimentorin:build npm run dimentorin:build
``` ```
- **Landing Page**:
```sh
npm run landing:build
```
### Production ### Production
@@ -74,19 +92,22 @@ Use the following commands to run the applications in production mode:
```sh ```sh
npm run dimentorin:prod npm run dimentorin:prod
``` ```
- **Landing Page**:
```sh
npm run landing:prod
```
### Storybook ### Storybook
This repository uses Storybook to develop, test, and document UI components in an isolated and interactive environment. Below are the commands to work with Storybook: This repository uses Storybook to develop, test, and document UI components in an isolated and interactive environment. Below are the commands to work with Storybook:
- **Run Storybook** - **Run Storybook**
This command starts Storybook in development mode, allowing you to view and test UI components interactively. This command starts Storybook in development mode, allowing you to view and test UI components interactively.
```sh ```sh
npm run ui:storybook npm run ui:storybook
``` ```
- **Run Unit Test** - **Run Unit Test**
+1
View File
@@ -0,0 +1 @@
VITE_API_URL=
@@ -18,7 +18,7 @@ import {
RowSelectionState, RowSelectionState,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import ModalEditAccount from './_components/modal-edit-account'; import ModalEditAccount from './_components/modal-edit-account';
import { useQueryState } from '../../hook/use-query-state'; import { useQueryState } from '@imphnen-frontend-service/utils';
interface Account { interface Account {
id: number; id: number;
@@ -166,7 +166,7 @@ export const Components: FC = (): ReactElement => {
</Button> </Button>
{showFilter && ( {showFilter && (
<div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg"> <div className="absolute right-0 top-[calc(100%+12px)] z-10 shadow-lg">
<Filter onClose={() => setShowFilter(false)} /> <Filter onClose={() => setShowFilter(false)} options={[]} />
</div> </div>
)} )}
</div> </div>
@@ -10,7 +10,7 @@ import { FC, Fragment, ReactElement, useState } from 'react';
import ModalAddItem from './_components/modal-add-item'; import ModalAddItem from './_components/modal-add-item';
import ModalEditItem from './_components/modal-edit-item'; import ModalEditItem from './_components/modal-edit-item';
import ModalDeleteItem from './_components/modal-delete-item'; import ModalDeleteItem from './_components/modal-delete-item';
import { useQueryState } from '../../hook/use-query-state'; import { useQueryState } from '@imphnen-frontend-service/utils';
export const Components: FC = (): ReactElement => { export const Components: FC = (): ReactElement => {
const [showModalAddItem, setShowModalAddItem] = useState(false); const [showModalAddItem, setShowModalAddItem] = useState(false);
@@ -2,24 +2,19 @@ import { useForm } from 'react-hook-form';
import { import {
authLoginSchema, authLoginSchema,
TLoginRequest, TLoginRequest,
usePostLogin,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner'; import { useSession } from '@imphnen-frontend-service/utils';
export const useLogin = () => { export const useLogin = () => {
const postLogin = usePostLogin();
const form = useForm<TLoginRequest>({ const form = useForm<TLoginRequest>({
resolver: zodResolver(authLoginSchema), resolver: zodResolver(authLoginSchema),
mode: 'all', mode: 'all',
}); });
const onSubmit = form.handleSubmit((data) => { const { signIn } = useSession();
postLogin.mutate(data, {
onSuccess: () => toast.success("Login sukses"), const onSubmit = form.handleSubmit((data) => signIn(data));
onError: (error) => toast.error(error.message),
});
});
return { return {
form, form,
@@ -32,7 +32,16 @@ export const Components: FC = (): ReactElement => {
size="lg" size="lg"
className="w-full" className="w-full"
/> />
<Button type="submit" size="md" className="w-full"> <Button
disabled={
form.formState.isSubmitting ||
form.formState.isValidating ||
!form.formState.isValid
}
type="submit"
size="md"
className="w-full"
>
Login Login
</Button> </Button>
</form> </form>
@@ -1,18 +0,0 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
export const AppLayout: FC = (): ReactElement => {
return (
<div className="bg-primary-50 min-h-screen flex justify-center">
<div className="bg-primary-50 min-h-screen w-full flex">
<BackofficeSidebar />
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
<Outlet />
</div>
</div>
</div>
);
};
export default AppLayout;
@@ -1,18 +0,0 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
export const AppLayout: FC = (): ReactElement => {
return (
<div className="bg-primary-50 min-h-screen flex justify-center">
<div className="bg-primary-50 min-h-screen w-full flex">
<BackofficeSidebar />
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
<Outlet />
</div>
</div>
</div>
);
};
export default AppLayout;
@@ -1,18 +0,0 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
export const AppLayout: FC = (): ReactElement => {
return (
<div className="bg-primary-50 min-h-screen flex justify-center">
<div className="bg-primary-50 min-h-screen w-full flex">
<BackofficeSidebar />
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
<Outlet />
</div>
</div>
</div>
);
};
export default AppLayout;
-18
View File
@@ -1,18 +0,0 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
export const AppLayout: FC = (): ReactElement => {
return (
<div className="bg-primary-50 min-h-screen flex justify-center">
<div className="bg-primary-50 min-h-screen w-full flex">
<BackofficeSidebar />
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
<Outlet />
</div>
</div>
</div>
);
};
export default AppLayout;
-18
View File
@@ -1,18 +0,0 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
export const AppLayout: FC = (): ReactElement => {
return (
<div className="bg-primary-50 min-h-screen flex justify-center">
<div className="bg-primary-50 min-h-screen w-full flex">
<BackofficeSidebar />
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
<Outlet />
</div>
</div>
</div>
);
};
export default AppLayout;
@@ -1,18 +0,0 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
export const AppLayout: FC = (): ReactElement => {
return (
<div className="bg-primary-50 min-h-screen flex justify-center">
<div className="bg-primary-50 min-h-screen w-full flex">
<BackofficeSidebar />
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
<Outlet />
</div>
</div>
</div>
);
};
export default AppLayout;
+100 -3
View File
@@ -1,11 +1,108 @@
import { LoaderFunctionArgs } from 'react-router-dom'; import {
PERMISSIONS,
SessionToken,
SessionUser,
} from '@imphnen-frontend-service/utils';
import { LoaderFunctionArgs, redirect } from 'react-router';
const mappingPublicRoutes = [
'/auth/login',
'/auth/forgot',
'/auth/new-password',
];
const mappingRoutePermissions = [
{
path: '/dashboard',
permissions: [],
},
{
path: '/users',
permissions: [PERMISSIONS.USERS.READ_LIST],
},
{
path: '/users/create',
permissions: [PERMISSIONS.USERS.CREATE],
},
{
path: '/users/update',
permissions: [PERMISSIONS.USERS.UPDATE],
},
{
path: '/users/detail',
permissions: [PERMISSIONS.USERS.READ_DETAIL],
},
{
path: '/roles',
permissions: [PERMISSIONS.ROLES.READ_LIST],
},
{
path: '/roles/create',
permissions: [PERMISSIONS.ROLES.CREATE],
},
{
path: '/roles/update',
permissions: [PERMISSIONS.ROLES.UPDATE],
},
{
path: '/roles/detail',
permissions: [PERMISSIONS.ROLES.READ_DETAIL],
},
{
path: '/permissions',
permissions: [PERMISSIONS.PERMISSIONS.READ_LIST],
},
{
path: '/permissions/create',
permissions: [PERMISSIONS.PERMISSIONS.CREATE],
},
{
path: '/permissions/update',
permissions: [PERMISSIONS.PERMISSIONS.UPDATE],
},
{
path: '/permissions/detail',
permissions: [PERMISSIONS.PERMISSIONS.READ_DETAIL],
},
];
//TODO : Fix this later
// const redirectToFirstAccessibleRoute = (userPermissions: string[]) => {
// const fallback = mappingRoutePermissions.find((route) =>
// route.permissions.some((perm) => userPermissions.includes(perm))
// );
// return redirect(fallback?.path ?? '/auth/login');
// };
export const middleware = async ({ request }: LoaderFunctionArgs) => { export const middleware = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url); const url = new URL(request.url);
const pathname = url.pathname; const pathname = url.pathname;
const session = SessionUser.get();
const session_token = SessionToken.get();
const token = session_token?.token?.access_token;
const userPermissions =
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
if (pathname) return null; if (mappingPublicRoutes.includes(pathname)) {
if (token) return redirect('/dashboard');
return null;
}
if (!session) return redirect('/auth/login');
const matchedRoute = mappingRoutePermissions.find(
(route) => route.path === pathname
);
if (matchedRoute) {
const hasPermission =
!matchedRoute.permissions ||
matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
if (!hasPermission) {
return '/dashboard';
}
}
return null; return null;
}; };
+1
View File
@@ -0,0 +1 @@
VITE_API_URL=
+4 -9
View File
@@ -2,24 +2,19 @@ import { useForm } from 'react-hook-form';
import { import {
authLoginSchema, authLoginSchema,
TLoginRequest, TLoginRequest,
usePostLogin,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner'; import { useSession } from '@imphnen-frontend-service/utils';
export const useLogin = () => { export const useLogin = () => {
const postLogin = usePostLogin();
const form = useForm<TLoginRequest>({ const form = useForm<TLoginRequest>({
resolver: zodResolver(authLoginSchema), resolver: zodResolver(authLoginSchema),
mode: 'all', mode: 'all',
}); });
const onSubmit = form.handleSubmit((data) => { const { signIn } = useSession();
postLogin.mutate(data, {
onSuccess: () => toast.success("Login sukses"), const onSubmit = form.handleSubmit((data) => signIn(data));
onError: (error) => toast.error(error.message),
});
});
return { return {
form, form,
+101 -3
View File
@@ -1,11 +1,109 @@
import { LoaderFunctionArgs } from 'react-router-dom'; import {
PERMISSIONS,
SessionToken,
SessionUser,
} from '@imphnen-frontend-service/utils';
import { LoaderFunctionArgs, redirect } from 'react-router';
const mappingPublicRoutes = [
'/',
'/auth/login',
'/auth/forgot',
'/auth/new-password',
];
const mappingRoutePermissions = [
{
path: '/dashboard',
permissions: [],
},
{
path: '/users',
permissions: [PERMISSIONS.USERS.READ_LIST],
},
{
path: '/users/create',
permissions: [PERMISSIONS.USERS.CREATE],
},
{
path: '/users/update',
permissions: [PERMISSIONS.USERS.UPDATE],
},
{
path: '/users/detail',
permissions: [PERMISSIONS.USERS.READ_DETAIL],
},
{
path: '/roles',
permissions: [PERMISSIONS.ROLES.READ_LIST],
},
{
path: '/roles/create',
permissions: [PERMISSIONS.ROLES.CREATE],
},
{
path: '/roles/update',
permissions: [PERMISSIONS.ROLES.UPDATE],
},
{
path: '/roles/detail',
permissions: [PERMISSIONS.ROLES.READ_DETAIL],
},
{
path: '/permissions',
permissions: [PERMISSIONS.PERMISSIONS.READ_LIST],
},
{
path: '/permissions/create',
permissions: [PERMISSIONS.PERMISSIONS.CREATE],
},
{
path: '/permissions/update',
permissions: [PERMISSIONS.PERMISSIONS.UPDATE],
},
{
path: '/permissions/detail',
permissions: [PERMISSIONS.PERMISSIONS.READ_DETAIL],
},
];
//TODO : Fix this later
// const redirectToFirstAccessibleRoute = (userPermissions: string[]) => {
// const fallback = mappingRoutePermissions.find((route) =>
// route.permissions.some((perm) => userPermissions.includes(perm))
// );
// return redirect(fallback?.path ?? '/auth/login');
// };
export const middleware = async ({ request }: LoaderFunctionArgs) => { export const middleware = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url); const url = new URL(request.url);
const pathname = url.pathname; const pathname = url.pathname;
const session = SessionUser.get();
const session_token = SessionToken.get();
const token = session_token?.token?.access_token;
const userPermissions =
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
if (pathname) return null; if (mappingPublicRoutes.includes(pathname)) {
if (token) return redirect('/dashboard');
return null;
}
if (!session) return redirect('/auth/login');
const matchedRoute = mappingRoutePermissions.find(
(route) => route.path === pathname
);
if (matchedRoute) {
const hasPermission =
!matchedRoute.permissions ||
matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
if (!hasPermission) {
return '/dashboard';
}
}
return null; return null;
}; };
+1
View File
@@ -0,0 +1 @@
VITE_API_URL=
+4 -7
View File
@@ -2,22 +2,19 @@ import { useForm } from 'react-hook-form';
import { import {
authLoginSchema, authLoginSchema,
TLoginRequest, TLoginRequest,
usePostLogin,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useSession } from '@imphnen-frontend-service/utils';
export const useLogin = () => { export const useLogin = () => {
const postLogin = usePostLogin();
const form = useForm<TLoginRequest>({ const form = useForm<TLoginRequest>({
resolver: zodResolver(authLoginSchema), resolver: zodResolver(authLoginSchema),
mode: 'all', mode: 'all',
}); });
const onSubmit = form.handleSubmit((data) => { const { signIn } = useSession();
postLogin.mutate(data, {
onSuccess: () => console.log('Success Login'), const onSubmit = form.handleSubmit((data) => signIn(data));
});
});
return { return {
form, form,
+1 -24
View File
@@ -1,14 +1,11 @@
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { import {
authRegisterSchema, authRegisterSchema,
stepOneRegisterSchema,
TRegisterRequest, TRegisterRequest,
usePostRegister, usePostRegister,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useEffect, useState } from 'react';
export const useRegister = () => { export const useRegister = () => {
const postRegister = usePostRegister(); const postRegister = usePostRegister();
@@ -21,31 +18,12 @@ export const useRegister = () => {
password: '', password: '',
confirm_password: '', confirm_password: '',
phone_number: '', phone_number: '',
referral_code: '',
referred_by: '',
student_type: '',
}, },
}); });
const [stepValid, setStepValid] = useState(false);
useEffect(() => {
const subscription = form.watch(() => {
const { fullname, email, password, confirm_password } = form.getValues();
const valid = stepOneRegisterSchema.safeParse({
fullname,
email,
password,
confirm_password,
}).success;
setStepValid(valid);
});
return () => subscription.unsubscribe();
}, [form]);
const onSubmit = form.handleSubmit((data) => { const onSubmit = form.handleSubmit((data) => {
postRegister.mutate(data, { postRegister.mutate(data, {
onSuccess: (data) => toast.success(data.message), onSuccess: () => toast.success('Registrasi sukses'),
onError: (error) => toast.error(error.message), onError: (error) => toast.error(error.message),
}); });
}); });
@@ -53,6 +31,5 @@ export const useRegister = () => {
return { return {
form, form,
onSubmit, onSubmit,
isStepOneValid: stepValid,
}; };
}; };
+101 -3
View File
@@ -1,11 +1,109 @@
import { LoaderFunctionArgs } from 'react-router-dom'; import {
PERMISSIONS,
SessionToken,
SessionUser,
} from '@imphnen-frontend-service/utils';
import { LoaderFunctionArgs, redirect } from 'react-router';
const mappingPublicRoutes = [
'/',
'/auth/login',
'/auth/forgot',
'/auth/new-password',
];
const mappingRoutePermissions = [
{
path: '/dashboard',
permissions: [],
},
{
path: '/users',
permissions: [PERMISSIONS.USERS.READ_LIST],
},
{
path: '/users/create',
permissions: [PERMISSIONS.USERS.CREATE],
},
{
path: '/users/update',
permissions: [PERMISSIONS.USERS.UPDATE],
},
{
path: '/users/detail',
permissions: [PERMISSIONS.USERS.READ_DETAIL],
},
{
path: '/roles',
permissions: [PERMISSIONS.ROLES.READ_LIST],
},
{
path: '/roles/create',
permissions: [PERMISSIONS.ROLES.CREATE],
},
{
path: '/roles/update',
permissions: [PERMISSIONS.ROLES.UPDATE],
},
{
path: '/roles/detail',
permissions: [PERMISSIONS.ROLES.READ_DETAIL],
},
{
path: '/permissions',
permissions: [PERMISSIONS.PERMISSIONS.READ_LIST],
},
{
path: '/permissions/create',
permissions: [PERMISSIONS.PERMISSIONS.CREATE],
},
{
path: '/permissions/update',
permissions: [PERMISSIONS.PERMISSIONS.UPDATE],
},
{
path: '/permissions/detail',
permissions: [PERMISSIONS.PERMISSIONS.READ_DETAIL],
},
];
//TODO : Fix this later
// const redirectToFirstAccessibleRoute = (userPermissions: string[]) => {
// const fallback = mappingRoutePermissions.find((route) =>
// route.permissions.some((perm) => userPermissions.includes(perm))
// );
// return redirect(fallback?.path ?? '/auth/login');
// };
export const middleware = async ({ request }: LoaderFunctionArgs) => { export const middleware = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url); const url = new URL(request.url);
const pathname = url.pathname; const pathname = url.pathname;
const session = SessionUser.get();
const session_token = SessionToken.get();
const token = session_token?.token?.access_token;
const userPermissions =
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
if (pathname) return null; if (mappingPublicRoutes.includes(pathname)) {
if (token) return redirect('/dashboard');
return null;
}
if (!session) return redirect('/auth/login');
const matchedRoute = mappingRoutePermissions.find(
(route) => route.path === pathname
);
if (matchedRoute) {
const hasPermission =
!matchedRoute.permissions ||
matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
if (!hasPermission) {
return '/dashboard';
}
}
return null; return null;
}; };
+1
View File
@@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=
+3 -17
View File
@@ -1,22 +1,8 @@
//@ts-check
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { composePlugins, withNx } = require('@nx/next'); const { composePlugins, withNx } = require('@nx/next');
/** /** @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
// See: https://github.com/gregberge/svgr
svgr: false,
},
}; };
const plugins = [ module.exports = composePlugins(withNx)(nextConfig);
// Add more Next.js plugins to this list if needed.
withNx,
];
module.exports = composePlugins(...plugins)(nextConfig);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { Button } from '@imphnen-frontend-service/shadcn-ui/atoms'; import { Button } from '@components';
import { motion, useInView } from 'framer-motion'; import { motion, useInView } from 'framer-motion';
import { useRef } from 'react'; import { useRef } from 'react';
@@ -41,7 +41,10 @@ export function CallToAction() {
<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="group relative w-full sm:w-auto bg-gradient-to-r from-primary to-blue-600 hover:from-primary/90 hover:to-blue-600/90 transition-all duration-300 font-bold text-white hover:text-white/90 shadow-lg cursor-pointer"
onClick={() =>
window.open('https://discord.gg/imphnen', '_blank')
}
> >
Gabung Discord Gabung Discord
</Button> </Button>
@@ -49,6 +52,12 @@ export function CallToAction() {
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(
'https://facebook.com/groups/programmerhandal',
'_blank'
)
}
> >
Join Facebook Group Join Facebook Group
</Button> </Button>
@@ -0,0 +1,110 @@
'use client';
import COMMUNITIES_STATS from '@/data/communities-stats.json';
import COMMUNITIES from '@/data/communities.json';
import { Button } from '@components';
import { Icon } from '@iconify/react';
import { motion, useInView } from 'framer-motion';
import { useRef } from 'react';
export function Community() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, amount: 0.2 });
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { y: 20, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: { duration: 0.5 },
},
};
return (
<section
id="komunitas"
className="w-full py-20 md:py-32 bg-muted relative overflow-hidden"
>
<div className="absolute inset-0 -z-10">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(59,130,246,0.1),transparent_70%)]" />
<div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(59,130,246,0.01)_1px,transparent_1px),linear-gradient(to_bottom,rgba(59,130,246,0.01)_1px,transparent_1px)] bg-[size:14px_14px]" />
</div>
<div className="container px-4 md:px-6" ref={ref}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
>
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl text-center mb-4">
Komunitas{' '}
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
Kami
</span>
</h2>
<p className="max-w-[800px] mx-auto text-muted-foreground text-center md:text-lg">
Bergabunglah dengan ribuan programmer Indonesia yang saling membantu
dan berbagi pengalaman.
</p>
</motion.div>
<motion.div
className="grid gap-8 md:grid-cols-3 mt-12"
variants={containerVariants}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
>
{COMMUNITIES.map((c, i) => (
<motion.div
key={i}
className="group relative overflow-hidden rounded-xl border bg-background p-6 transition-all hover:shadow-xl"
variants={itemVariants}
>
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-blue-400/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="relative z-10">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
<Icon
icon={c.iconName}
className="h-6 w-6 text-blue-600 dark:text-blue-400"
/>
</div>
<h3 className="mb-2 text-xl font-bold">{c.title}</h3>
<p className="mb-6 text-muted-foreground">{c.description}</p>
<Button
variant="outline"
className="w-full group-hover:bg-primary group-hover:text-primary-foreground transition-colors duration-300"
onClick={() => window.open(c.buttonLink, '_blank')}
>
{c.buttonText}
</Button>
</div>
<div className="absolute -bottom-1 -right-1 w-20 h-20 bg-gradient-to-tl from-primary/20 to-transparent rounded-tl-full opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
</motion.div>
))}
</motion.div>
<motion.div
className="mt-20 grid grid-cols-2 md:grid-cols-4 gap-8 text-center"
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: 0.3 }}
>
{COMMUNITIES_STATS.map((s, i) => (
<div key={i} className="space-y-2">
<div className="text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
{s.value}
</div>
<div className="text-sm text-muted-foreground">{s.label}</div>
</div>
))}
</motion.div>
</div>
</section>
);
}
@@ -1,11 +1,7 @@
'use client'; 'use client';
import { import FEATURES from '@/data/features.json';
BookOpenIcon, import { Icon } from '@iconify/react';
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';
@@ -13,33 +9,6 @@ export function Features() {
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 +33,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,10 +50,7 @@ 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{' '} Belajar programming dengan cara yang lebih baik
<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 IMPHNEN hadir dengan berbagai fitur untuk membantu kamu menjadi
@@ -100,7 +65,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 +75,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>
@@ -1,14 +1,10 @@
'use client'; 'use client';
import { import HERO_STATS from '@/data/hero-stats.json';
Button, import { Button, SparklesIcon } from '@components';
CodeIcon,
SparklesIcon,
UsersIcon,
} from '@imphnen-frontend-service/shadcn-ui/atoms';
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() { export function Hero() {
const [scrollY, setScrollY] = useState(0); const [scrollY, setScrollY] = useState(0);
@@ -24,11 +20,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 +36,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,13 +47,13 @@ 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>Komunitas Programmer Indonesia</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 /> Programmer Handal, <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 Tanpa Ribet
@@ -75,76 +66,62 @@ export function Hero() {
</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="group relative w-full sm:w-auto bg-gradient-to-r from-primary to-blue-600 hover:from-primary/90 hover:to-blue-600/90 transition-all duration-300 font-bold text-white hover:text-white/90 shadow-lg cursor-pointer"
className="bg-gradient-to-r from-primary to-blue-400 hover:from-primary/90 hover:to-blue-400/90 transition-all duration-300" onClick={() =>
> window.open(
Mulai Belajar 'https://facebook.com/groups/programmerhandal',
</Button> '_blank'
</a> )
}
>
Mulai Belajar
</Button>
<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('https://discord.com/invite/imphnen', '_blank')
}
> >
<span className="absolute inset-0 bg-gradient-to-r from-primary/10 to-blue-400/10 translate-y-full group-hover:translate-y-0 transition-transform duration-300"></span> <span className="absolute inset-0 bg-gradient-to-r from-primary/10 to-blue-400/10 translate-y-full group-hover:translate-y-0 transition-transform duration-300" />
<span className="relative">Gabung Discord</span> <span className="relative">Gabung Discord</span>
</Button> </Button>
</div> </div>
<div className="flex items-center gap-8"> <div className="flex flex-wrap justify-center md:justify-start gap-6 sm:gap-8">
<div className="flex flex-col items-center"> {HERO_STATS.map(({ value, label }, i) => (
<div className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400"> <Fragment key={i}>
100K+ <div className="flex flex-col items-center">
</div> <div className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
<div className="text-xs text-muted-foreground">Member</div> {value}
</div> </div>
<div className="h-10 border-r border-border"></div> <div className="text-xs text-muted-foreground">{label}</div>
<div className="flex flex-col items-center"> </div>
<div className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400"> {i < HERO_STATS.length - 1 && (
500+ <div className="hidden sm:block h-10 border-r border-border mx-4" />
</div> )}
<div className="text-xs text-muted-foreground">Tutorial</div> </Fragment>
</div> ))}
<div className="h-10 border-r border-border"></div>
<div className="flex flex-col items-center">
<div className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
24/7
</div>
<div className="text-xs text-muted-foreground">Yapping</div>
</div>
</div> </div>
</motion.div> </motion.div>
<motion.div <motion.div
className="relative mx-auto lg:ml-auto" className="relative w-full lg:w-auto mx-auto lg:ml-auto"
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 0.2 }} transition={{ duration: 0.5, delay: 0.2 }}
> >
<div className="relative"> <Image
{/* Decorative Elements */} src="/logo.webp"
<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"> alt="logo"
<CodeIcon className="h-6 w-6 text-primary" /> width={600}
</div> height={500}
<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"> className="w-full h-auto object-cover"
<UsersIcon className="h-6 w-6 text-blue-400" /> />
</div>
{/* Main Image */}
<div className="relative z-10 rounded-2xl overflow-hidden border shadow-2xl">
<div className="absolute inset-0 bg-gradient-to-tr from-primary/10 to-blue-400/10" />
<Image
src="/hero.jpeg"
width={600}
height={500}
alt="IMPHNEN Programming Community"
className="w-full h-auto object-cover"
/>
</div>
</div>
</motion.div> </motion.div>
</div> </div>
</div> </div>
@@ -1,65 +1,15 @@
'use client'; 'use client';
import { import LEARNING_RESOURCES from '@/data/learning-resources.json';
BookOpenIcon, import { Button } from '@components';
Button, import { Icon } from '@iconify/react';
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() {
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,26 +34,20 @@ 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{' '} Sumber Belajar
<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 Akses berbagai materi belajar yang akan membantu kamu menguasai
@@ -111,54 +55,51 @@ export function LearningResources() {
</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) => ( {LEARNING_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 Rekomendasi Terbaik
</div> </div>
<h3 className="text-2xl md:text-3xl font-bold mb-4"> <h3 className="text-2xl md:text-3xl font-bold mb-4">
@@ -170,10 +111,18 @@ export function LearningResources() {
menengah. 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
className="group relative w-full sm:w-auto bg-gradient-to-r from-primary to-blue-600 hover:from-primary/90 hover:to-blue-600/90 transition-all duration-300 font-bold text-white hover:text-white/90 shadow-lg cursor-pointer"
onClick={() => window.open('/', '_blank')}
>
Mulai Kursus Mulai Kursus
</Button> </Button>
<Button variant="outline">Lihat Silabus</Button> <Button
variant="outline"
onClick={() => window.open('/', '_blank')}
>
Lihat Silabus
</Button>
</div> </div>
</div> </div>
<div className="relative h-64 md:h-auto"> <div className="relative h-64 md:h-auto">
@@ -0,0 +1,126 @@
'use client';
import TESTIMONIAL_STATS from '@/data/testimonial-stats.json';
import TESTIMONIALS from '@/data/testimonials.json';
import { QuoteIcon } from '@components';
import { motion, useInView } from 'framer-motion';
import Image from 'next/image';
import { useRef } from 'react';
export function Testimonials() {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once: true, amount: 0.2 });
const containerVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { staggerChildren: 0.1 } },
};
const itemVariants = {
hidden: { y: 20, opacity: 0 },
visible: { y: 0, opacity: 1, transition: { duration: 0.5 } },
};
return (
<section
id="testimoni"
className="w-full py-20 md:py-32 bg-muted relative overflow-hidden"
ref={ref}
>
<div className="absolute inset-0 -z-10">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.1),transparent_50%)]" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom,rgba(96,165,250,0.1),transparent_50%)]" />
</div>
<div className="container px-4 md:px-6">
<motion.div
className="flex flex-col items-center justify-center space-y-4 text-center mb-16"
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5 }}
>
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl lg:text-5xl">
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
Testimoni
</span>
<span> Member</span>
</h2>
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg">
Apa kata mereka yang telah bergabung dengan komunitas IMPHNEN?
</p>
</motion.div>
<motion.div
className="grid gap-8 md:grid-cols-3"
variants={containerVariants}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
>
{TESTIMONIALS.map((t, idx) => (
<motion.div
key={idx}
className="group relative overflow-hidden rounded-xl border bg-background p-6 transition-all hover:shadow-lg"
variants={itemVariants}
>
<div className="absolute top-6 right-6 text-primary/20 group-hover:text-primary/40 transition-colors">
<QuoteIcon className="h-8 w-8" />
</div>
<div className="relative z-10">
<p className="mb-6 text-muted-foreground italic">
&ldquo;{t.quote}&rdquo;
</p>
<div className="flex items-center gap-4">
<div className="relative h-12 w-12 overflow-hidden rounded-full border-2 border-primary/20">
<Image
src={t.avatar}
alt={t.name}
width={600}
height={500}
className="object-cover"
unoptimized
/>
</div>
<div>
<h4 className="font-bold">{t.name}</h4>
<p className="text-sm text-muted-foreground">{t.role}</p>
</div>
</div>
</div>
<div className="absolute -bottom-1 -left-1 w-20 h-20 bg-gradient-to-tr from-primary/10 to-transparent rounded-tr-full opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
</motion.div>
))}
</motion.div>
<motion.div
className="mt-20 rounded-xl overflow-hidden border bg-background/50 backdrop-blur-sm p-8 md:p-12"
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: 0.3 }}
>
<div className="grid md:grid-cols-2 gap-8 items-center">
<div>
<h3 className="text-2xl md:text-3xl font-bold mb-4">
Bergabunglah dengan 10,000+ programmer Indonesia lainnya
</h3>
<p className="text-muted-foreground">
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 className="grid grid-cols-2 gap-4">
{TESTIMONIAL_STATS.map((s, idx) => (
<div key={idx} className="rounded-lg border p-4 text-center">
<div className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
{s.value}
</div>
<div className="text-sm text-muted-foreground">{s.label}</div>
</div>
))}
</div>
</div>
</motion.div>
</div>
</section>
);
}
@@ -1,25 +1,19 @@
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 default function Page() {
return ( return (
<div className="flex min-h-screen flex-col"> <>
<Header /> <Hero />
<main className="flex-1"> <Features />
<Hero /> <Community />
<Features /> <LearningResources />
<Community /> <Testimonials />
<LearningResources /> <CallToAction />
<Testimonials /> </>
<CallToAction />
</main>
<Footer />
</div>
); );
} }
@@ -1,179 +0,0 @@
'use client';
import {
Button,
FacebookIcon,
InstagramIcon,
MessageCircleIcon,
} from '@imphnen-frontend-service/shadcn-ui/atoms';
import { motion, useInView } from 'framer-motion';
import { useRef } from 'react';
export function Community() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, amount: 0.2 });
const communities = [
{
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 = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { y: 20, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: { duration: 0.5 },
},
};
return (
<section
id="komunitas"
className="w-full py-20 md:py-32 bg-muted relative overflow-hidden"
>
{/* Background Elements */}
<div className="absolute inset-0 -z-10">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(59,130,246,0.1),transparent_70%)]" />
<div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(59,130,246,0.01)_1px,transparent_1px),linear-gradient(to_bottom,rgba(59,130,246,0.01)_1px,transparent_1px)] bg-[size:14px_14px]" />
</div>
<div className="container px-4 md:px-6" ref={ref}>
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
<motion.div
className="space-y-2"
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 0.5 }}
>
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl">
Komunitas{' '}
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
Kami
</span>
</h2>
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg">
Bergabunglah dengan ribuan programmer Indonesia yang saling
membantu dan berbagi pengalaman.
</p>
</motion.div>
</div>
<motion.div
className="grid gap-8 md:grid-cols-3"
variants={containerVariants}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
>
{communities.map((community, index) => (
<motion.div
key={index}
className="group relative overflow-hidden rounded-xl border bg-background p-6 transition-all hover:shadow-xl"
variants={itemVariants}
>
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-blue-400/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="relative z-10">
<div
className={`mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full ${community.iconBg}`}
>
{community.icon}
</div>
<h3 className="mb-2 text-xl font-bold">{community.title}</h3>
<p className="mb-6 text-muted-foreground">
{community.description}
</p>
<Button
variant="outline"
className="w-full group-hover:bg-primary group-hover:text-primary-foreground transition-colors duration-300"
>
{community.buttonText}
</Button>
</div>
<div className="absolute -bottom-1 -right-1 w-20 h-20 bg-gradient-to-tl from-primary/20 to-transparent rounded-tl-full opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
</motion.div>
))}
</motion.div>
{/* Community Stats */}
<motion.div
className="mt-20 grid grid-cols-2 md:grid-cols-4 gap-8 text-center"
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<div className="space-y-2">
<div className="text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
10K+
</div>
<div className="text-sm text-muted-foreground">Member Aktif</div>
</div>
<div className="space-y-2">
<div className="text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
50+
</div>
<div className="text-sm text-muted-foreground">Event Bulanan</div>
</div>
<div className="space-y-2">
<div className="text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
100+
</div>
<div className="text-sm text-muted-foreground">
Mentor Profesional
</div>
</div>
<div className="space-y-2">
<div className="text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
5K+
</div>
<div className="text-sm text-muted-foreground">
Diskusi Mingguan
</div>
</div>
</motion.div>
</div>
</section>
);
}
+4 -1
View File
@@ -188,7 +188,10 @@ export default function Footer() {
placeholder="Email Anda" placeholder="Email Anda"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/> />
<button className="inline-flex items-center justify-center rounded-md bg-primary px-3 py-1 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"> <button
className="inline-flex items-center justify-center rounded-md bg-primary px-3 py-1 text-sm shadow hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50
font-bold text-black hover:text-white cursor-pointer"
>
Daftar Daftar
</button> </button>
</div> </div>
+27 -18
View File
@@ -1,17 +1,16 @@
'use client'; 'use client';
import { import { Button, MenuIcon, XIcon } from '@components';
Button, import { cn } from '@utils';
MenuIcon,
XIcon,
} from '@imphnen-frontend-service/shadcn-ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { SimpleThemeToggle } from './simple-theme-toggle'; import { SimpleThemeToggle } from './simple-theme-toggle';
export function Header() { export function Header() {
const router = useRouter();
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
@@ -35,13 +34,15 @@ export function Header() {
<div className="container flex h-16 items-center justify-between"> <div className="container flex h-16 items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative overflow-hidden rounded"> <div className="relative overflow-hidden rounded">
<Image <Link href="/">
src="/logo.png" <Image
alt="IMPHNEN Logo" src="/logo.webp"
width={64} alt="IMPHNEN"
height={64} width={64}
className="object-cover" height={64}
/> className="object-cover"
/>
</Link>
</div> </div>
</div> </div>
@@ -80,10 +81,14 @@ export function Header() {
</Link> </Link>
</nav> </nav>
<div className="flex items-center gap-4"> <div className="flex items-center gap-x-2">
<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">
Gabung Discord <Button
className="group relative w-full sm:w-auto bg-gradient-to-r from-primary to-blue-600 hover:from-primary/90 hover:to-blue-600/90 transition-all duration-300 font-bold text-white hover:text-white/90 shadow-lg cursor-pointer"
onClick={() => router.push('/signin')}
>
Login
</Button> </Button>
{/* Mobile Menu Button */} {/* Mobile Menu Button */}
@@ -134,8 +139,12 @@ export function Header() {
> >
Testimoni Testimoni
</Link> </Link>
<Button className="mt-4 bg-gradient-to-r from-primary to-blue-400 hover:from-primary/90 hover:to-blue-400/90">
Gabung Discord <Button
onClick={() => router.push('/signin')}
className="group relative w-full sm:w-auto bg-gradient-to-r from-primary to-blue-600 hover:from-primary/90 hover:to-blue-600/90 transition-all duration-300 font-bold text-white hover:text-white/90 shadow-lg cursor-pointer"
>
Login
</Button> </Button>
</nav> </nav>
</div> </div>
@@ -1,10 +1,6 @@
'use client'; 'use client';
import { import { Button, MoonIcon, SunIcon } from '@components';
Button,
MoonIcon,
SunIcon,
} from '@imphnen-frontend-service/shadcn-ui/atoms';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -29,7 +25,7 @@ export function SimpleThemeToggle() {
variant="outline" variant="outline"
size="icon" size="icon"
onClick={toggleTheme} onClick={toggleTheme}
className="focus-visible:ring-0" className="focus-visible:ring-0 cursor-pointer"
> >
{theme === 'dark' ? ( {theme === 'dark' ? (
<SunIcon className="h-[1.2rem] w-[1.2rem]" /> <SunIcon className="h-[1.2rem] w-[1.2rem]" />
@@ -1,190 +0,0 @@
'use client';
import { QuoteIcon } from '@imphnen-frontend-service/shadcn-ui/atoms';
import { motion, useInView } from 'framer-motion';
import Image from 'next/image';
import { useRef } from 'react';
export function Testimonials() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, amount: 0.2 });
const testimonials = [
{
quote:
'Saya yang tadinya tidak mengerti apa-apa tentang programming, sekarang bisa membuat website sendiri dengan mudah. Terima kasih IMPHNEN!',
name: 'Budi Santoso',
role: 'Web Developer Pemula',
avatar: '/placeholder.svg?height=64&width=64',
},
{
quote:
'Komunitas yang sangat supportif! Setiap pertanyaan selalu dijawab dengan cepat dan jelas. Diskusi programnya mudah dipahami.',
name: 'Anita Ratna',
role: 'Mobile App Developer',
avatar: '/placeholder.svg?height=64&width=64',
},
{
quote:
'Server Discord IMPHNEN adalah tempat belajar terbaik untuk programmer pemula seperti saya. Materinya lengkap dan komunitasnya sangat membantu!',
name: 'Dedi Permana',
role: 'Data Scientist',
avatar: '/placeholder.svg?height=64&width=64',
},
];
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { y: 20, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: { duration: 0.5 },
},
};
return (
<section
id="testimoni"
className="w-full py-20 md:py-32 bg-muted relative overflow-hidden"
>
{/* Background Elements */}
<div className="absolute inset-0 -z-10">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.1),transparent_50%)]" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom,rgba(96,165,250,0.1),transparent_50%)]" />
</div>
<div className="container px-4 md:px-6" ref={ref}>
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
<motion.div
className="space-y-2"
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 0.5 }}
>
<h2 className="text-3xl font-bold tracking-tighter md:text-4xl/tight lg:text-5xl">
Testimoni{' '}
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
Member
</span>
</h2>
<p className="max-w-[800px] mx-auto text-muted-foreground md:text-lg">
Apa kata mereka yang telah bergabung dengan komunitas IMPHNEN?
</p>
</motion.div>
</div>
<motion.div
className="grid gap-8 md:grid-cols-3"
variants={containerVariants}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
>
{testimonials.map((testimonial, index) => (
<motion.div
key={index}
className="group relative overflow-hidden rounded-xl border bg-background p-6 transition-all hover:shadow-lg"
variants={itemVariants}
>
<div className="absolute top-6 right-6 text-primary/20 group-hover:text-primary/40 transition-colors">
<QuoteIcon className="h-8 w-8" />
</div>
<div className="relative z-10">
<p className="mb-6 text-muted-foreground italic">
"{testimonial.quote}"
</p>
<div className="flex items-center gap-4">
<div className="relative h-12 w-12 overflow-hidden rounded-full border-2 border-primary/20">
<Image
src={testimonial.avatar || '/placeholder.svg'}
alt={testimonial.name}
width={48}
height={48}
className="object-cover"
/>
</div>
<div>
<h4 className="font-bold">{testimonial.name}</h4>
<p className="text-sm text-muted-foreground">
{testimonial.role}
</p>
</div>
</div>
</div>
<div className="absolute -bottom-1 -left-1 w-20 h-20 bg-gradient-to-tr from-primary/10 to-transparent rounded-tr-full opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
</motion.div>
))}
</motion.div>
{/* Testimonial Stats */}
<motion.div
className="mt-20 rounded-xl overflow-hidden border bg-background/50 backdrop-blur-sm p-8 md:p-12"
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<div className="grid md:grid-cols-2 gap-8 items-center">
<div>
<h3 className="text-2xl md:text-3xl font-bold mb-4">
Bergabunglah dengan{' '}
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
10,000+
</span>{' '}
programmer Indonesia lainnya
</h3>
<p className="text-muted-foreground">
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 className="grid grid-cols-2 gap-4">
<div className="rounded-lg border p-4 text-center">
<div className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
98%
</div>
<div className="text-sm text-muted-foreground">
Tingkat Kemalasan
</div>
</div>
<div className="rounded-lg border p-4 text-center">
<div className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
4.9/5
</div>
<div className="text-sm text-muted-foreground">
Rating Drama
</div>
</div>
<div className="rounded-lg border p-4 text-center">
<div className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
85%
</div>
<div className="text-sm text-muted-foreground">
Mendapat Pekerjaan
</div>
</div>
<div className="rounded-lg border p-4 text-center">
<div className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400">
24/7
</div>
<div className="text-sm text-muted-foreground">Yapping</div>
</div>
</div>
</div>
</motion.div>
</div>
</section>
);
}
+8 -2
View File
@@ -1,6 +1,8 @@
import '@/styles/globals.css';
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 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>
+3
View File
@@ -0,0 +1,3 @@
export default function Page() {
return <></>;
}
@@ -0,0 +1,6 @@
[
{ "value": "100K+", "label": "Member Aktif" },
{ "value": "50+", "label": "Event Bulanan" },
{ "value": "100+", "label": "Mentor Profesional" },
{ "value": "5K+", "label": "Diskusi Mingguan" }
]
+23
View File
@@ -0,0 +1,23 @@
[
{
"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"
}
]
+22
View File
@@ -0,0 +1,22 @@
[
{
"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."
}
]
+5
View File
@@ -0,0 +1,5 @@
[
{ "value": "180K+", "label": "Member" },
{ "value": "500+", "label": "Tutorial" },
{ "value": "24/7", "label": "Yapping" }
]
@@ -0,0 +1,30 @@
[
{
"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": "#"
}
]
@@ -0,0 +1,6 @@
[
{ "value": "98%", "label": "Tingkat Kemalasan" },
{ "value": "4.9/5", "label": "Rating Drama" },
{ "value": "85%", "label": "Mendapat Pekerjaan" },
{ "value": "24/7", "label": "Yapping" }
]
+20
View File
@@ -0,0 +1,20 @@
[
{
"quote": "Sebagai seseorang yang awalnya buta sama sekali tentang programming, kini saya bisa membuat website sendiri dengan percaya diri. IMPHNEN benar-benar jadi pintu gerbang saya ke dunia web development!",
"name": "Ega",
"role": "Web Developer",
"avatar": "https://avatars.githubusercontent.com/u/97678571?v=4"
},
{
"quote": "IMPHNEN bukan cuma komunitas, tapi juga jadi tempat saya berkembang sebagai backend engineer. Diskusi teknisnya berbobot, dan respon dari komunitas selalu cepat dan tepat sasaran.",
"name": "Maulana",
"role": "Backend Engineer",
"avatar": "https://avatars.githubusercontent.com/u/53475078?v=4"
},
{
"quote": "Sebagai fullstack engineer pemula, saya merasa sangat terbantu dengan komunitas Discord IMPHNEN. Materinya praktikal, pembahasannya jelas, dan member-nya selalu siap membantu.",
"name": "Rasyid",
"role": "Fullstack Engineer",
"avatar": "https://avatars.githubusercontent.com/u/49753444?v=4"
}
]
+11 -12
View File
@@ -6,15 +6,12 @@
"noEmit": true, "noEmit": true,
"emitDeclarationOnly": false, "emitDeclarationOnly": false,
"esModuleInterop": true, "esModuleInterop": true,
"target": "ES2022",
"module": "esnext", "module": "esnext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
@@ -23,7 +20,14 @@
{ {
"name": "next" "name": "next"
} }
] ],
"baseUrl": ".",
"paths": {
"@components": ["../../libs/shadcn-ui/src/atoms/index.ts"],
"@utils": ["../../libs/shadcn-ui/src/lib/index.ts"],
"@schemas": ["../../libs/service/src/schemas/index.ts"],
"@/*": ["src/*"]
}
}, },
"include": [ "include": [
"**/*.js", "**/*.js",
@@ -35,10 +39,5 @@
"next-env.d.ts", "next-env.d.ts",
".next/types/**/*.ts" ".next/types/**/*.ts"
], ],
"exclude": [ "exclude": ["node_modules", "jest.config.ts", "**/*.spec.ts", "**/*.test.ts"]
"node_modules",
"jest.config.ts",
"**/*.spec.ts",
"**/*.test.ts"
]
} }
-6
View File
@@ -6,7 +6,6 @@ import {
TRegisterRequest, TRegisterRequest,
TVerifyEmailRequest, TVerifyEmailRequest,
} from '../../types/auth'; } from '../../types/auth';
import { SessionToken, SessionUser } from '@imphnen-frontend-service/utils';
import { TResponseError, TResponseMessage } from '../../types/common'; import { TResponseError, TResponseMessage } from '../../types/common';
@@ -19,11 +18,6 @@ export const usePostLogin = (): UseMutationResult<
return useMutation({ return useMutation({
mutationKey: ['post-login'], mutationKey: ['post-login'],
mutationFn: async (payload) => await postLogin(payload), mutationFn: async (payload) => await postLogin(payload),
onSuccess: (res) => {
SessionUser.set(res.data.user);
SessionToken.set(res.data.token);
window.location.reload();
},
}); });
}; };
+7 -26
View File
@@ -16,7 +16,7 @@ export const authLoginSchema = z.object({
.min(1, 'Password tidak boleh kosong'), .min(1, 'Password tidak boleh kosong'),
}); });
export const stepOneRegisterSchema = z export const authRegisterSchema = z
.object({ .object({
email: z email: z
.string({ .string({
@@ -25,6 +25,12 @@ export const stepOneRegisterSchema = z
}) })
.min(1, 'Email tidak boleh kosong') .min(1, 'Email tidak boleh kosong')
.email('Email harus valid'), .email('Email harus valid'),
phone_number: z
.string({
required_error: 'Nomor telepon tidak boleh kosong',
})
.min(1, 'Nomor telepon tidak boleh kosong')
.max(15, 'Nomor telepon tidak boleh lebih dari 15 digit'),
fullname: z fullname: z
.string({ .string({
required_error: 'Nama tidak boleh kosong', required_error: 'Nama tidak boleh kosong',
@@ -51,28 +57,3 @@ export const stepOneRegisterSchema = z
message: 'Password dan Konfirmasi Password harus sama', message: 'Password dan Konfirmasi Password harus sama',
path: ['confirm_password'], path: ['confirm_password'],
}); });
const stepTwoRegisterSchema = z.object({
phone_number: z
.string({
required_error: 'Nomor telepon tidak boleh kosong',
invalid_type_error: 'Nomor telepon harus berupa string',
})
.min(1, 'Nomor telepon tidak boleh kosong')
.max(15, 'Nomor telepon tidak boleh lebih dari 15 karakter'),
referral_code: z
.string()
.min(1, 'Kode referral tidak boleh kosong')
.max(4, 'Kode referral tidak boleh lebih dari 4 karakter')
.optional(),
referred_by: z
.string()
.min(1, 'Kode referral tidak boleh kosong')
.max(50, 'Kode referral tidak boleh lebih dari 50 karakter')
.optional(),
student_type: z.string(),
});
export const authRegisterSchema = stepOneRegisterSchema.and(
stepTwoRegisterSchema
);
+17 -20
View File
@@ -1,29 +1,26 @@
import { z } from 'zod';
import { authLoginSchema, authRegisterSchema } from '../../schemas';
import { TResponseDetail, TResponseMessage } from '../common';
import { TUserItem } from '../users'; import { TUserItem } from '../users';
export type TLoginRequest = { export type TTokenItem = {
email: string; access_token: string;
password: string; refresh_token: string;
}; };
export type TLoginResponse = { export type TLoginItem = {
data: { token?: TTokenItem;
token: { user?: TUserItem;
access_token: string;
refresh_token: string;
};
user: TUserItem;
};
}; };
export type TRegisterRequest = { export type TLoginRequest = z.infer<typeof authLoginSchema>;
email: string; export type TLoginResponse = TResponseDetail<TLoginItem>;
fullname: string;
password: string; export type TRegisterRequest = z.infer<typeof authRegisterSchema>;
phone_number: string; export type TRegisterResponse = TResponseDetail<TResponseMessage>;
referral_code?: string;
referred_by?: string; export type TVerifyOtpRequest = {
student_type: string; otp: number;
confirm_password: string;
}; };
export type TVerifyEmailRequest = { export type TVerifyEmailRequest = {
+13 -1
View File
@@ -1,7 +1,19 @@
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
export type TResponse<T = unknown> = { export type TMetaResponse = {
page: number;
per_page: number;
total: number;
};
export type TResponseDetail<T = unknown> = {
data: T; data: T;
message: string;
};
export type TResponseList<T = unknown> = {
data: T[];
meta: TMetaResponse;
}; };
export type TResponseMessage = { export type TResponseMessage = {
+10 -2
View File
@@ -1,9 +1,17 @@
import { TPermissionItem } from '../permissions'; import { TPermissionItem } from '../permissions';
export type TRoleItem = { export type TRoleDetailItem = {
id: string; id: string;
name: string; name: string;
permissions: TPermissionItem[];
created_at: string;
updated_at: string;
};
export type TRolesListItem = {
id: string;
name: string;
permissions_count: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
permissions: TPermissionItem[];
}; };
+2 -8
View File
@@ -1,4 +1,4 @@
import { TRoleItem } from '../roles'; import { TRoleDetailItem } from '../roles';
export type TUserItem = { export type TUserItem = {
id: string; id: string;
@@ -7,13 +7,7 @@ export type TUserItem = {
email: string; email: string;
fullname: string; fullname: string;
gender: string; gender: string;
identity_number: string;
is_active: boolean; is_active: boolean;
is_profile_completed: boolean;
phone_number: string; phone_number: string;
referral_code: string; role: TRoleDetailItem;
referred_by: string;
religion: string;
student_type: string;
role: TRoleItem;
}; };
+1 -1
View File
@@ -1,9 +1,9 @@
'use client'; 'use client';
import { cn } from '@imphnen-frontend-service/utils';
import { Slot } from '@radix-ui/react-slot'; import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority'; import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react'; import * as React from 'react';
import { cn } from '../lib/cn';
const buttonVariants = cva( const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
+6
View File
@@ -0,0 +1,6 @@
import { ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export const cn = (...inputs: ClassValue[]) => {
return twMerge(clsx(inputs));
};
+1
View File
@@ -0,0 +1 @@
export * from './cn';
+1
View File
@@ -0,0 +1 @@
export * from './permissions';
+40
View File
@@ -0,0 +1,40 @@
export const PERMISSIONS = {
USERS: {
READ_LIST: 'Read List Users',
READ_DETAIL: 'Read Detail Users',
CREATE: 'Create Users',
UPDATE: 'Update Users',
DELETE: 'Delete Users',
ACTIVATE: 'Activate Users',
},
ROLES: {
READ_LIST: 'Read List Roles',
READ_DETAIL: 'Read Detail Roles',
CREATE: 'Create Roles',
UPDATE: 'Update Roles',
DELETE: 'Delete Roles',
},
PERMISSIONS: {
READ_LIST: 'Read List Permissions',
READ_DETAIL: 'Read Detail Permissions',
CREATE: 'Create Permissions',
UPDATE: 'Update Permissions',
DELETE: 'Delete Permissions',
},
GACHA_CLAIMS: {
CREATE: 'Create Gacha Claims',
READ_DETAIL: 'Read Detail Gacha Claims',
},
GACHA_ITEMS: {
READ_LIST: 'Read List Gacha Items',
READ_DETAIL: 'Read Detail Gacha Items',
CREATE: 'Create Gacha Items',
UPDATE: 'Update Gacha Items',
DELETE: 'Delete Gacha Items',
},
GACHA_ROLLS: {
READ_DETAIL: 'Read Detail Gacha Rolls',
CREATE: 'Create Gacha Rolls',
EXECUTE: 'Execute Gacha Rolls',
},
};
+36
View File
@@ -0,0 +1,36 @@
import Cookies from 'js-cookie';
type TTokenItem = {
access_token: string;
refresh_token: string;
};
const TOKEN_KEY = 'token';
type TStoredToken =
| {
token?: TTokenItem;
}
| undefined;
export const SessionToken = {
set: (val: TStoredToken) => {
Cookies.set(TOKEN_KEY, JSON.stringify(val), {
secure: true,
sameSite: 'Strict',
expires: 7,
});
},
get: (): TStoredToken => {
const token = Cookies.get(TOKEN_KEY);
if (!token) return undefined;
try {
return JSON.parse(token);
} catch {
return undefined;
}
},
remove: () => {
Cookies.remove(TOKEN_KEY);
},
};
+2 -1
View File
@@ -1,3 +1,4 @@
export * from './use-query-state'; export * from './use-query-state';
export * from './use-session'; export * from './use-auth-store';
export * from './use-modal-login'; export * from './use-modal-login';
export * from './use-session';
+47
View File
@@ -0,0 +1,47 @@
import { create } from 'zustand';
import { TLoginItem } from '@imphnen-frontend-service/service';
import { SessionToken } from '../cookies';
import { SessionUser } from '../local-storage';
export enum ESessionStatus {
Authenticated = 'authenticated',
Authenticating = 'authenticating',
Unauthenticated = 'unauthenticated',
}
type SessionState = {
isLoading: boolean;
session?: TLoginItem;
status?: ESessionStatus;
setLoading: (val: boolean) => void;
setSession: (payload: TLoginItem) => void;
clearSession: () => void;
};
export const useAuthStore = create<SessionState>((set) => {
const session = SessionToken.get();
const user = SessionUser.get();
const isAuthenticated = !!session;
return {
isLoading: false,
session: isAuthenticated ? { ...session, ...user } : undefined,
status: isAuthenticated
? ESessionStatus.Authenticated
: ESessionStatus.Unauthenticated,
setLoading: (val) => set({ isLoading: val }),
setSession: (data) => {
SessionToken.set({ token: data.token });
SessionUser.set(data.user);
set({
session: data,
status: ESessionStatus.Authenticated,
});
},
clearSession: () => {
SessionToken.remove();
SessionUser.remove();
set({ session: undefined, status: ESessionStatus.Unauthenticated });
},
};
});
+27 -12
View File
@@ -1,22 +1,37 @@
import { SessionToken, SessionUser } from '../local-storage'; import { TLoginRequest, usePostLogin } from '@imphnen-frontend-service/service';
import { useAuthStore } from './';
import { toast } from 'sonner';
import { useNavigate } from 'react-router';
export const useSession = () => { export const useSession = () => {
const session = { const navigate = useNavigate();
user: SessionUser.get(), const { mutate, isPending } = usePostLogin();
token: SessionToken.get(), const { setLoading, setSession, clearSession, session } = useAuthStore();
const signIn = (payload: TLoginRequest) => {
setLoading(true);
mutate(payload, {
onSuccess: (data) => {
toast.success('Login sukses');
setSession(data.data);
navigate(0);
},
onError: (err) => {
toast.error(
err?.response?.data?.message ??
'Terjadi Kesalahan yang tidak diketahui'
);
clearSession();
},
});
}; };
const isAuthenticated = !!session.token?.access_token;
const signOut = () => { const signOut = () => {
SessionUser.remove(); clearSession();
SessionToken.remove(); navigate(0);
window.location.reload();
}; };
return { return {
isAuthenticated,
session, session,
signIn,
signOut, signOut,
isLoading: isPending,
}; };
}; };
+3
View File
@@ -3,3 +3,6 @@ export * from './react-router';
export * from './tailwind-merge'; export * from './tailwind-merge';
export * from './hooks'; export * from './hooks';
export * from './local-storage'; export * from './local-storage';
export * from './cookies';
export * from './session';
export * from './constants';
+1 -26
View File
@@ -20,41 +20,16 @@ type TUserItem = {
email: string; email: string;
fullname: string; fullname: string;
gender: string; gender: string;
identity_number: string;
is_active: boolean; is_active: boolean;
is_profile_completed: boolean;
phone_number: string; phone_number: string;
referral_code: string;
referred_by: string;
religion: string;
student_type: string;
role: TRoleItem; role: TRoleItem;
}; };
export const SessionUser = { export const SessionUser = {
set: (val: TUserItem) => localStorage.setItem('users', JSON.stringify(val)), set: (val?: TUserItem) => localStorage.setItem('users', JSON.stringify(val)),
get: (): TUserItem | undefined => { get: (): TUserItem | undefined => {
const users = localStorage.getItem('users'); const users = localStorage.getItem('users');
return users ? JSON.parse(users) : undefined; return users ? JSON.parse(users) : undefined;
}, },
remove: () => localStorage.removeItem('users'), remove: () => localStorage.removeItem('users'),
}; };
export const SessionToken = {
set: (val: { access_token: string; refresh_token: string }) => {
localStorage.setItem('access_token', val.access_token);
localStorage.setItem('refresh_token', val.refresh_token);
},
get: ():
| { access_token?: string | null; refresh_token?: string | null }
| undefined => {
return {
access_token: localStorage.getItem('access_token'),
refresh_token: localStorage.getItem('refresh_token'),
};
},
remove: () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
},
};
@@ -1,15 +1,22 @@
import { lazy, LazyExoticComponent, ReactElement } from 'react'; import { lazy, LazyExoticComponent, ReactNode } from 'react';
import { ActionFunction, LoaderFunction, RouteObject } from 'react-router'; import { ActionFunction, LoaderFunction, RouteObject } from 'react-router';
type TPermissionItem = {
id: string;
name: string;
created_at: string;
updated_at: string;
};
interface PageModuleExports { interface PageModuleExports {
default: () => ReactElement; default: () => ReactNode;
loader?: LoaderFunction; loader?: LoaderFunction;
action?: ActionFunction; action?: ActionFunction;
permissions?: Array<string>; permissions?: Array<string>;
} }
interface LoadingModuleExports { interface LoadingModuleExports {
default: () => ReactElement; default: () => ReactNode;
} }
interface RouteHandle { interface RouteHandle {
@@ -38,7 +45,6 @@ export function convertPagesToRoute(
filePath, filePath,
loadingFiles loadingFiles
); );
const route = createRoute({ const route = createRoute({
PageComponent: page, PageComponent: page,
LoadingComponent: loadingComponent, LoadingComponent: loadingComponent,
@@ -51,6 +57,20 @@ export function convertPagesToRoute(
const result = (await importer()) as PageModuleExports; const result = (await importer()) as PageModuleExports;
return 'loader' in result ? result.loader?.(args) : null; return 'loader' in result ? result.loader?.(args) : null;
}, },
async guard() {
const result = (await importer()) as PageModuleExports;
const localStoragePermission = localStorage.getItem('permissions');
const permissions: TPermissionItem[] | undefined =
localStoragePermission
? JSON.parse(localStoragePermission)
: undefined;
return 'permissions' in result
? result.permissions?.every(
(permission) =>
permissions?.some((item) => permission === item.name) || false
) || false
: true;
},
}); });
routes = mergeRoutes(routes, route); routes = mergeRoutes(routes, route);
}); });
@@ -62,19 +82,14 @@ function findMatchingLoadingComponent(
loadingFiles: Record<string, () => Promise<unknown>> loadingFiles: Record<string, () => Promise<unknown>>
) { ) {
const loadingPath = filePath.replace(/(page|layout)\.tsx$/, 'loading.tsx'); const loadingPath = filePath.replace(/(page|layout)\.tsx$/, 'loading.tsx');
const groupMatch = filePath.match(/\([^/]+\//); const groupMatch = filePath.match(/\([^/]+\//);
const groupLoadingPath = groupMatch ? `/${groupMatch[0]}loading.tsx` : null; const groupLoadingPath = groupMatch ? `/${groupMatch[0]}loading.tsx` : null;
const globalLoadingPath = './app/loading.tsx'; const globalLoadingPath = './app/loading.tsx';
const loader = const loader =
loadingFiles[loadingPath] || loadingFiles[loadingPath] ||
(groupLoadingPath && loadingFiles[groupLoadingPath]) || (groupLoadingPath && loadingFiles[groupLoadingPath]) ||
loadingFiles[globalLoadingPath]; loadingFiles[globalLoadingPath];
if (!loader) return undefined; if (!loader) return undefined;
return lazy(loader as () => Promise<LoadingModuleExports>); return lazy(loader as () => Promise<LoadingModuleExports>);
} }
@@ -86,68 +101,110 @@ function mergeRoutes(
throw new Error( throw new Error(
`Paths do not match: "${target.path}" and "${source.path}"` `Paths do not match: "${target.path}" and "${source.path}"`
); );
target.children = target.children || [];
if (source.handle?.pageType === 'layout') {
return handleLayoutMerge(target, source);
}
if (source.handle?.pageType === 'page') {
return handlePageMerge(target, source);
}
if (source.children && source.children.length > 0) {
if (target.handle?.pageType === 'page') {
if (!target.children?.some((child) => child.index)) {
target.children = target.children || [];
target.children.unshift({
index: true,
element: target.element,
HydrateFallback: target.HydrateFallback,
action: target.action,
loader: target.loader,
handle: target.handle,
errorElement: target.errorElement,
});
}
delete target.element;
delete target.action;
delete target.loader;
delete target.handle;
}
mergeChildRoutes(target, source);
}
return target;
}
export function mergeChildRoutes(
target: ExtendedRouteObject,
source: ExtendedRouteObject
): void {
if (!source.children) return;
if (!target.children) { if (!target.children) {
target.children = []; target.children = [];
} }
source.children.forEach((sourceChild) => {
const matchingChild = target.children!.find(
(targetChild) => targetChild.path === sourceChild.path
);
if (source.handle?.pageType === 'layout') { if (matchingChild) {
if (!target.element) { mergeRoutes(matchingChild, sourceChild);
target.element = source.element; } else {
target.HydrateFallback = source.HydrateFallback; target.children!.push(sourceChild);
target.action = source.action;
target.loader = source.loader;
target.handle = source.handle;
target.errorElement = source.errorElement;
target.children = target.children ?? [];
} else if (target.handle?.pageType === 'page') {
target = swapTargetRouteAsIndexRouteAndUpdateWithRoute(target, source);
} }
return target; });
} }
if ( export function handleLayoutMerge(
source.handle?.pageType === 'page' && target: ExtendedRouteObject,
!target.children.some((child) => child.index) source: ExtendedRouteObject
) { ): ExtendedRouteObject {
target.children.unshift({ if (!target.element) {
index: true, Object.assign(target, {
element: source.element, element: source.element,
HydrateFallback: source.HydrateFallback, HydrateFallback: source.HydrateFallback,
action: source.action, action: source.action,
loader: source.loader, loader: source.loader,
handle: source.handle, handle: source.handle,
errorElement: source.errorElement,
}); });
return target; } else if (target.handle?.pageType === 'page') {
target = swapTargetRouteAsIndexRouteAndUpdateWithRoute(target, source);
} }
if (
target.handle?.pageType === 'layout' &&
source.handle?.pageType === 'page'
) {
target = addRouteAsIndexRouteForTargetRoute(target, source);
return target;
}
if (source.children) {
target.children = target.children ?? [];
source.children.forEach((sourceChild) => {
const matchingChild = target.children?.find(
(targetChild) => targetChild.path === sourceChild.path
);
if (matchingChild) mergeRoutes(matchingChild, sourceChild);
else target.children?.push(sourceChild);
});
}
return target; return target;
} }
function swapTargetRouteAsIndexRouteAndUpdateWithRoute( export function handlePageMerge(
target: ExtendedRouteObject, target: ExtendedRouteObject,
route: ExtendedRouteObject source: ExtendedRouteObject
): ExtendedRouteObject { ): ExtendedRouteObject {
target.children = target.children ?? []; if (!target.children) {
target.children = [];
}
if (
!target.children.some((child) => child.index) ||
target.handle?.pageType === 'layout'
) {
if (target.handle?.pageType === 'layout') {
addRouteAsIndexRouteForTargetRoute(target, source);
} else {
target.children.unshift({
index: true,
element: source.element,
HydrateFallback: source.HydrateFallback,
action: source.action,
loader: source.loader,
handle: source.handle,
errorElement: source.errorElement,
});
}
}
return target;
}
export function swapTargetRouteAsIndexRouteAndUpdateWithRoute(
target: ExtendedRouteObject,
layout: ExtendedRouteObject
): ExtendedRouteObject {
target.children = target.children || [];
target.children.push({ target.children.push({
index: true, index: true,
element: target.element, element: target.element,
@@ -157,38 +214,38 @@ function swapTargetRouteAsIndexRouteAndUpdateWithRoute(
handle: target.handle, handle: target.handle,
errorElement: target.errorElement, errorElement: target.errorElement,
}); });
Object.assign(target, {
target.element = route.element; element: layout.element,
target.HydrateFallback = route.HydrateFallback; HydrateFallback: layout.HydrateFallback,
target.action = route.action; action: layout.action,
target.loader = route.loader; loader: layout.loader,
target.handle = route.handle; handle: layout.handle,
target.errorElement = route.errorElement; errorElement: layout.errorElement,
});
return target; return target;
} }
function addRouteAsIndexRouteForTargetRoute( export function addRouteAsIndexRouteForTargetRoute(
target: ExtendedRouteObject, target: ExtendedRouteObject,
route: ExtendedRouteObject page: ExtendedRouteObject
): ExtendedRouteObject { ): ExtendedRouteObject {
target.children = target.children ?? []; target.children = target.children || [];
target.children.push({ target.children.push({
index: true, index: true,
element: route.element, element: page.element,
HydrateFallback: route.HydrateFallback, HydrateFallback: page.HydrateFallback,
action: route.action, action: page.action,
loader: route.loader, loader: page.loader,
handle: route.handle, handle: page.handle,
errorElement: route.errorElement, errorElement: page.errorElement,
}); });
return target; return target;
} }
function createRoute(args: { function createRoute(args: {
segments: string[]; segments: string[];
PageComponent: LazyExoticComponent<() => ReactElement>; PageComponent: LazyExoticComponent<() => ReactNode>;
LoadingComponent?: LazyExoticComponent<() => ReactElement>; LoadingComponent?: LazyExoticComponent<() => ReactNode>;
loader?: LoaderFunction; loader?: LoaderFunction;
action?: ActionFunction; action?: ActionFunction;
guard?: () => Promise<boolean>; guard?: () => Promise<boolean>;
@@ -196,21 +253,24 @@ function createRoute(args: {
const [current, ...rest] = args.segments; const [current, ...rest] = args.segments;
const [cleanPath, pageType] = current.split(separator); const [cleanPath, pageType] = current.split(separator);
const route: ExtendedRouteObject = { path: cleanPath }; const route: ExtendedRouteObject = { path: cleanPath };
if (pageType === 'page' || pageType === 'layout') { if (pageType === 'page' || pageType === 'layout') {
route.element = <args.PageComponent />; route.element = <args.PageComponent />;
route.HydrateFallback = route.HydrateFallback =
args.LoadingComponent ?? (() => <div>Loading...</div>); args.LoadingComponent ?? (() => <div>Loading...</div>);
route.action = args.action; route.action = args.action;
route.loader = async (...props) => { route.loader = async (...props) => {
if (!(await args.guard?.())) {
throw new Response('Forbidden', {
status: 403,
statusText: 'Forbidden',
});
}
return args.loader?.(...props); return args.loader?.(...props);
}; };
route.handle = { pageType: pageType as 'layout' | 'page' }; route.handle = { pageType: pageType as 'layout' | 'page' };
} }
if (rest.length > 0) { if (rest.length > 0) {
const nextSegment = rest[0].split(separator)[0]; const nextSegment = rest[0].split(separator)[0];
if (nextSegment === 'update' || nextSegment === 'edit') { if (nextSegment === 'update' || nextSegment === 'edit') {
return { return {
path: `${cleanPath}/${nextSegment}`, path: `${cleanPath}/${nextSegment}`,
@@ -221,20 +281,16 @@ function createRoute(args: {
handle: { pageType: pageType as 'layout' | 'page' }, handle: { pageType: pageType as 'layout' | 'page' },
}; };
} }
const childRoute = createRoute({ ...args, segments: rest }); const childRoute = createRoute({ ...args, segments: rest });
if (!route.children) { if (!route.children) {
route.children = []; route.children = [];
} }
if (cleanPath.startsWith(':')) { if (cleanPath.startsWith(':')) {
route.children.unshift(childRoute); route.children.unshift(childRoute);
} else { } else {
route.children.push(childRoute); route.children.push(childRoute);
} }
} }
return route; return route;
} }
@@ -258,7 +314,6 @@ export function getRouteSegmentsFromFilePath(
if (segment.startsWith('[')) return getParamFromSegment(segment); if (segment.startsWith('[')) return getParamFromSegment(segment);
return segment; return segment;
}); });
return getRouteSegments(segments[0], segments, transformer); return getRouteSegments(segments[0], segments, transformer);
} }
@@ -306,7 +361,7 @@ export function addErrorElementToRoutes(
(_, prevSegment) => prevSegment (_, prevSegment) => prevSegment
); );
const ErrorBoundary = lazy( const ErrorBoundary = lazy(
importer as () => Promise<{ default: () => ReactElement }> importer as () => Promise<{ default: () => ReactNode }>
); );
setRoute(segments, routes, (route) => { setRoute(segments, routes, (route) => {
route.errorElement = <ErrorBoundary />; route.errorElement = <ErrorBoundary />;
@@ -325,7 +380,7 @@ export function add404PageToRoutesChildren(
(_, prevSegment) => prevSegment (_, prevSegment) => prevSegment
); );
const NotFound = lazy( const NotFound = lazy(
importer as () => Promise<{ default: () => ReactElement }> importer as () => Promise<{ default: () => ReactNode }>
); );
setRoute(segments, routes, (route) => { setRoute(segments, routes, (route) => {
if (route.children) { if (route.children) {
@@ -340,9 +395,7 @@ export function add404PageToRoutesChildren(
action: tempRoute.action, action: tempRoute.action,
loader: tempRoute.loader, loader: tempRoute.loader,
}); });
route.children.push({ path: '*', element: <NotFound /> }); route.children.push({ path: '*', element: <NotFound /> });
delete route.element; delete route.element;
delete route.action; delete route.action;
delete route.loader; delete route.loader;
@@ -352,7 +405,7 @@ export function add404PageToRoutesChildren(
}); });
} }
function set404NonPage(routes: RouteObject, notFoundElement: ReactElement) { function set404NonPage(routes: RouteObject, notFoundElement: ReactNode) {
if ( if (
routes.path && routes.path &&
routes.children?.length && routes.children?.length &&
@@ -377,7 +430,6 @@ function setRoute(
segments.forEach((_segment, i) => { segments.forEach((_segment, i) => {
const isLastSegment = i === segments.length - 1; const isLastSegment = i === segments.length - 1;
if (isLastSegment) return (temp = updater(temp)); if (isLastSegment) return (temp = updater(temp));
if (!isLastSegment) { if (!isLastSegment) {
const nextSegment = segments[i + 1]; const nextSegment = segments[i + 1];
const index = temp.children?.findIndex( const index = temp.children?.findIndex(
+27
View File
@@ -0,0 +1,27 @@
import { FC, PropsWithChildren, ReactNode, useMemo } from 'react';
import { useSession } from '../../hooks/use-session';
type TProps = PropsWithChildren<{
permissions: Array<string>;
fallback?: ReactNode;
}>;
export const Guard: FC<TProps> = (props): ReactNode => {
const { session } = useSession();
const permissionKeys = useMemo(() => {
return session?.user?.role?.permissions.map((perm) => perm.name) ?? [];
}, [session?.user?.role]);
const allowed = useMemo(() => {
return props.permissions.every((permission) =>
permissionKeys.includes(permission)
);
}, [permissionKeys, props.permissions]);
if (allowed) {
return props.children;
}
return props.fallback ?? null;
};
+1
View File
@@ -0,0 +1 @@
export * from './guard';
+1102 -100
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",
@@ -26,6 +26,7 @@
"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",
"@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-slot": "^1.2.0",
"@tanstack/react-query": "^5.74.4", "@tanstack/react-query": "^5.74.4",
"@tanstack/react-store": "^0.7.0", "@tanstack/react-store": "^0.7.0",
@@ -34,6 +35,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.9.2", "framer-motion": "^12.9.2",
"graphql": "^16.11.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"next": "~15.2.4", "next": "~15.2.4",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -42,9 +44,11 @@
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router-dom": "^7.3.0", "react-router-dom": "^7.3.0",
"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",
-1
View File
@@ -16,7 +16,6 @@
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@imphnen-frontend-service/service": ["libs/service/src/index.ts"], "@imphnen-frontend-service/service": ["libs/service/src/index.ts"],
"@imphnen-frontend-service/shadcn-ui/atoms": ["libs/shadcn-ui/src/atoms/index.ts"],
"@imphnen-frontend-service/ui/atoms": ["libs/ui/src/atoms/index.ts"], "@imphnen-frontend-service/ui/atoms": ["libs/ui/src/atoms/index.ts"],
"@imphnen-frontend-service/ui/molecules": [ "@imphnen-frontend-service/ui/molecules": [
"libs/ui/src/molecules/index.ts" "libs/ui/src/molecules/index.ts"
+8
View File
@@ -0,0 +1,8 @@
{
"routes": [
{
"src": "/[^.]+",
"dest": "/"
}
]
}