feat: migrate all 5 Vite apps from react-router to TanStack Router

Migrated backoffice, hackathon, dimentorin, gacha, qrcampaign, and
infra from custom react-router file-based routing to TanStack Router
file-based routing following the tanstack-frontend-best-practice
convention.

Key changes per app:
- New routes/ directory with __root.tsx, _public.tsx, _authenticated.tsx
- Auth guards via beforeLoad (replaces old middleware.ts)
- createFileRoute pattern for all page components
- TanStackRouterVite plugin in vite.config for auto route generation
- _components/_hooks folders colocated with routes (ignored by router)
- routeTree.gen.ts auto-generated on dev/build

Convention:
- _public/* routes redirect to dashboard if authenticated
- _authenticated/* routes redirect to /auth/login if not authenticated
- $param for dynamic segments (was [param] in old convention)
- _layout suffix for pathless layout routes

Removed:
- Old src/app/ directories from all apps
- Old src/middleware.ts files
- Custom convertPagesToRoute utility (no longer needed)
- react-router dependency usage (kept in package.json for shared libs)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
maulanasdqn
2026-04-11 17:04:14 +07:00
co-authored by Claude Opus 4.6
parent 86d9e759a6
commit 4240e8eb51
291 changed files with 3889 additions and 4585 deletions
@@ -1,20 +0,0 @@
import { FC, ReactElement } from 'react';
import { HeroSection } from './_components/hero-section';
import { WhatWeOfferSection } from './_components/what-we-offer-section';
import { TestimonialSection } from './_components/testimonial-section';
import { FAQSection } from './_components/faq-section';
import { CTASection } from './_components/cta-section';
export const Components: FC = (): ReactElement => {
return (
<>
<HeroSection />
<WhatWeOfferSection />
<TestimonialSection />
<FAQSection />
<CTASection />
</>
);
};
export default Components;
@@ -1,15 +0,0 @@
import { Outlet } from "react-router-dom";
import { Header } from "./_components/header";
import { Footer } from "./_components/footer";
export default function Layout() {
return (
<div className="flex min-h-screen flex-col bg-primary-50">
<Header />
<main className="flex-1">
<Outlet />
</main>
<Footer />
</div>
);
}
@@ -1,14 +0,0 @@
import { ReactElement } from "react";
import { Contribute } from "./_components/contribute";
import { WorkWithUs } from "./_components/work-with-us";
import { Collaborate } from "./_components/collaborate";
export default function Components(): ReactElement {
return (
<main>
<Contribute />
<WorkWithUs />
<Collaborate />
</main>
)
}
@@ -1,12 +0,0 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
export const AppLayout: FC = (): ReactElement => {
return (
<main className="bg-primary-50 min-h-screen">
<Outlet />
</main>
);
};
export default AppLayout;
@@ -1,12 +0,0 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
export const AppLayout: FC = (): ReactElement => {
return (
<main className="bg-primary-50 min-h-screen">
<Outlet />
</main>
);
};
export default AppLayout;
@@ -1,12 +0,0 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
export const AppLayout: FC = (): ReactElement => {
return (
<main className="bg-primary-50 min-h-screen">
<Outlet />
</main>
);
};
export default AppLayout;
@@ -1,80 +0,0 @@
import { FC, ReactElement, useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useGoogleCallback, useAuthStore } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
export const GoogleCallbackPage: FC = (): ReactElement => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { setSession, clearSession } = useAuthStore();
const { mutate: googleCallback } = useGoogleCallback();
useEffect(() => {
const handleCallback = async () => {
const code = searchParams.get('code');
const state = searchParams.get('state');
const error = searchParams.get('error');
if (error) {
toast.error('Google login dibatalkan atau terjadi kesalahan');
navigate('/auth/login');
return;
}
if (!code || !state) {
toast.error('Parameter login Google tidak valid');
navigate('/auth/login');
return;
}
try {
googleCallback(
{ code, state },
{
onSuccess: (response) => {
if (response.token && response.user) {
setSession({
token: response.token,
user: response.user,
});
toast.success('Login Google berhasil!');
navigate('/dashboard');
} else {
throw new Error('Response data tidak valid');
}
},
onError: (error) => {
console.error('Google OAuth callback error:', error);
toast.error('Login Google gagal');
clearSession();
navigate('/auth/login');
},
}
);
} catch (error) {
console.error('Google OAuth callback error:', error);
toast.error('Login Google gagal');
clearSession();
navigate('/auth/login');
}
};
handleCallback();
}, [searchParams, navigate, setSession, clearSession, googleCallback]);
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-500 mx-auto mb-4"></div>
<h2 className="text-2xl font-semibold text-primary-500 mb-2">
Menyelesaikan Login Google...
</h2>
<p className="text-gray-600">
Mohon tunggu sebentar, kami sedang memproses login Anda.
</p>
</div>
</div>
);
};
export default GoogleCallbackPage;
@@ -1,12 +0,0 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
export const AppLayout: FC = (): ReactElement => {
return (
<main className="bg-primary-50 min-h-screen">
<Outlet />
</main>
);
};
export default AppLayout;
@@ -1,12 +0,0 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
export const AppLayout: FC = (): ReactElement => {
return (
<main className="bg-primary-50 min-h-screen">
<Outlet />
</main>
);
};
export default AppLayout;
@@ -1,12 +0,0 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
export const AppLayout: FC = (): ReactElement => {
return (
<main className="bg-primary-50 min-h-screen">
<Outlet />
</main>
);
};
export default AppLayout;
@@ -1,12 +0,0 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
export const AppLayout: FC = (): ReactElement => {
return (
<main className="bg-primary-50 min-h-screen">
<Outlet />
</main>
);
};
export default AppLayout;
@@ -1,12 +0,0 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
export const AppLayout: FC = (): ReactElement => {
return (
<main className="bg-primary-50 min-h-screen">
<Outlet />
</main>
);
};
export default AppLayout;
@@ -1,12 +0,0 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
export const AppLayout: FC = (): ReactElement => {
return (
<main className="bg-primary-50 min-h-screen">
<Outlet />
</main>
);
};
export default AppLayout;
@@ -1,12 +0,0 @@
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
export const AppLayout: FC = (): ReactElement => {
return (
<main className="bg-primary-50 min-h-screen">
<Outlet />
</main>
);
};
export default AppLayout;
-10
View File
@@ -1,10 +0,0 @@
import { Outlet, ScrollRestoration } from "react-router-dom";
export default function RootLayout() {
return (
<>
<Outlet />
<ScrollRestoration />
</>
)
}
+16 -32
View File
@@ -1,37 +1,22 @@
import { createRoot } from 'react-dom/client';
import { middleware } from './middleware';
import { StrictMode } from 'react';
import { createBrowserRouter, RouteObject, RouterProvider } from 'react-router';
import {
add404PageToRoutesChildren,
addErrorElementToRoutes,
convertPagesToRoute,
ModalLoginProvider,
QueryProvider,
} from '@imphnen-frontend-service/utils';
import { Toaster } from 'sonner';
import './index.css';
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { ModalLoginProvider, QueryProvider } from '@imphnen-frontend-service/utils'
import { Toaster } from 'sonner'
import { routeTree } from './routeTree.gen'
import './index.css'
const files = import.meta.glob('./app/**/*(page|layout).tsx');
const errorFiles = import.meta.glob('./app/**/*error.tsx');
const notFoundFiles = import.meta.glob('./app/**/*404.tsx');
const loadingFiles = import.meta.glob('./app/**/*loading.tsx');
const router = createRouter({ routeTree })
const routes = convertPagesToRoute(files, loadingFiles) as RouteObject;
addErrorElementToRoutes(errorFiles, routes);
add404PageToRoutesChildren(notFoundFiles, routes);
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
const router = createBrowserRouter([
{
...routes,
loader: middleware,
shouldRevalidate: () => true,
},
]);
const rootElement = document.getElementById('root')
const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Failed to find the root element');
if (!rootElement) throw new Error('Failed to find the root element')
createRoot(rootElement).render(
<StrictMode>
@@ -42,5 +27,4 @@ createRoot(rootElement).render(
</ModalLoginProvider>
</QueryProvider>
</StrictMode>
);
// force deploy
)
-118
View File
@@ -1,118 +0,0 @@
import {
PERMISSIONS,
SessionToken,
SessionUser,
} from '@imphnen-frontend-service/service';
import { LoaderFunctionArgs, redirect } from 'react-router';
const mappingPublicRoutes = [
'/',
'/auth/login',
'/auth/forgot',
'/auth/forgot/otp',
'/auth/register',
'/auth/register/otp',
'/auth/register/success',
'/auth/new-password',
'/auth/register-mentor',
'/auth/register-mentor/pending',
'/auth/register-mentor/success',
'/auth/google-callback',
'/auth/google-oauth-popup',
'/resources',
];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],
},
];
const mappingPublicPrefixRoutes = [
'/mentoring',
'/articles',
]
export const middleware = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const pathname = url.pathname;
const session = SessionUser.get();
const session_token = SessionToken.get();
const token = session_token?.token?.access_token;
const userPermissions =
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
if (mappingPublicPrefixRoutes.some((prefix) => pathname.startsWith(prefix))) {
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;
};
+25
View File
@@ -0,0 +1,25 @@
/* prettier-ignore-start */
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file is auto-generated by TanStack Router
// Import Routes
import { Route as rootRoute } from './routes/__root'
// Create/Update Routes
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
interface FileRoutesByPath {}
}
// Create and export the route tree
export const routeTree = rootRoute
+10
View File
@@ -0,0 +1,10 @@
import { createRootRoute, Outlet, ScrollRestoration } from '@tanstack/react-router'
export const Route = createRootRoute({
component: () => (
<>
<Outlet />
<ScrollRestoration />
</>
),
})
@@ -0,0 +1,16 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { SessionToken } from '@imphnen-frontend-service/service'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: () => {
const session = SessionToken.get()
if (!session?.token?.access_token) {
throw redirect({ to: '/auth/login' })
}
},
component: AuthenticatedLayout,
})
function AuthenticatedLayout() {
return <Outlet />
}
@@ -1,21 +1,20 @@
import { Outlet, Link, useLocation, useNavigate } from 'react-router';
import { useAuthStore } from '@imphnen-frontend-service/service';
import { Icon } from '@iconify/react';
import { useEffect } from 'react';
import { createFileRoute, Outlet, Link, useLocation, useNavigate } from '@tanstack/react-router'
import { useAuthStore } from '@imphnen-frontend-service/service'
import { Icon } from '@iconify/react'
const navItems = [
{ path: '/dashboard', label: 'Dashboard', icon: 'mdi:view-dashboard' },
{ path: '/dashboard/settings', label: 'Settings', icon: 'mdi:cog' },
];
]
export default function DashboardLayout() {
const { session, clearSession } = useAuthStore();
const location = useLocation();
const navigate = useNavigate();
export const Route = createFileRoute('/_authenticated/dashboard')({
component: DashboardLayout,
})
useEffect(() => {
if (!session?.token) navigate('/auth/login');
}, [session, navigate]);
function DashboardLayout() {
const { session, clearSession } = useAuthStore()
const location = useLocation()
const navigate = useNavigate()
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
@@ -48,7 +47,7 @@ export default function DashboardLayout() {
{session?.user?.fullname || session?.user?.email}
</span>
<button
onClick={() => { clearSession(); navigate('/auth/login'); }}
onClick={() => { clearSession(); navigate({ to: '/auth/login' }) }}
className="text-sm text-red-500 hover:text-red-600 cursor-pointer flex items-center gap-1"
>
<Icon icon="mdi:logout" width="18" />
@@ -62,5 +61,5 @@ export default function DashboardLayout() {
<Outlet />
</main>
</div>
);
)
}
@@ -1,28 +1,32 @@
import { Link } from 'react-router';
import { createFileRoute, Link } from '@tanstack/react-router'
import {
useAuthStore,
useSessionQuery,
useMySessions,
useMentorMe,
} from '@imphnen-frontend-service/service';
import { Icon } from '@iconify/react';
} from '@imphnen-frontend-service/service'
import { Icon } from '@iconify/react'
export default function DashboardPage() {
const { session } = useAuthStore();
const { data: meData } = useSessionQuery(['mentor', 'sessions']);
const { data: sessionsData } = useMySessions();
const { data: mentorData } = useMentorMe();
export const Route = createFileRoute('/_authenticated/dashboard/')({
component: DashboardIndexPage,
})
const user = session?.user;
const mentor = meData?.user?.mentor;
const sessions = sessionsData?.data || [];
function DashboardIndexPage() {
const { session } = useAuthStore()
const { data: meData } = useSessionQuery(['mentor', 'sessions'])
const { data: sessionsData } = useMySessions()
const { data: mentorData } = useMentorMe()
const user = session?.user
const mentor = meData?.user?.mentor
const sessions = sessionsData?.data || []
const upcomingSessions = sessions.filter(
(s: { status: string }) => s.status === 'confirmed' || s.status === 'pending'
);
)
const completedSessions = sessions.filter(
(s: { status: string }) => s.status === 'completed'
);
)
return (
<div>
@@ -169,5 +173,5 @@ export default function DashboardPage() {
</div>
</div>
</div>
);
)
}
@@ -1,12 +1,13 @@
import { useState } from 'react';
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import {
useAuthStore,
useSessionQuery,
} from '@imphnen-frontend-service/service';
import { Icon } from '@iconify/react';
import { toast } from 'sonner';
} from '@imphnen-frontend-service/service'
import { Icon } from '@iconify/react'
import { toast } from 'sonner'
type SettingsSection = 'account' | 'privacy' | 'preferences' | 'faq';
type SettingsSection = 'account' | 'privacy' | 'preferences' | 'faq'
const faqItems = [
{ q: 'How do I book a mentoring session?', a: 'Browse available mentors, select one, and click "Book Session" to schedule a meeting.' },
@@ -14,22 +15,26 @@ const faqItems = [
{ q: 'Can I cancel a session?', a: 'Yes, you can cancel a pending or confirmed session from your dashboard before the scheduled time.' },
{ q: 'How do I update my profile?', a: 'Go to Settings > Account Details to update your personal information.' },
{ q: 'Is my data secure?', a: 'Yes, we use encryption and secure protocols to protect your data. See our Privacy Policy for details.' },
];
]
export default function SettingsPage() {
const { session } = useAuthStore();
const { data: meData } = useSessionQuery();
const [activeSection, setActiveSection] = useState<SettingsSection>('account');
const [expandedFaq, setExpandedFaq] = useState<number | null>(null);
export const Route = createFileRoute('/_authenticated/dashboard/settings')({
component: SettingsPage,
})
const user = session?.user;
function SettingsPage() {
const { session } = useAuthStore()
const { data: meData } = useSessionQuery()
const [activeSection, setActiveSection] = useState<SettingsSection>('account')
const [expandedFaq, setExpandedFaq] = useState<number | null>(null)
const user = session?.user
const sections = [
{ id: 'account' as const, label: 'Account Details', icon: 'mdi:account-circle' },
{ id: 'privacy' as const, label: 'Privacy & Security', icon: 'mdi:shield-lock' },
{ id: 'preferences' as const, label: 'Preferences', icon: 'mdi:tune' },
{ id: 'faq' as const, label: 'FAQ & Support', icon: 'mdi:help-circle' },
];
]
return (
<div>
@@ -181,5 +186,5 @@ export default function SettingsPage() {
</div>
</div>
</div>
);
)
}
@@ -1,6 +1,5 @@
import { useCallback } from 'react';
import { toast } from 'sonner';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '@imphnen-frontend-service/service';
export interface GoogleLoginResponse {
@@ -38,7 +37,6 @@ export interface GoogleLoginResponse {
}
export const useGoogleLogin = () => {
const navigate = useNavigate();
const { setSession } = useAuthStore();
const handleGoogleLogin = useCallback(async () => {
@@ -105,7 +103,7 @@ export const useGoogleLogin = () => {
});
toast.success('Login berhasil!');
navigate(0);
window.location.reload();
} else {
toast.error('Data login tidak lengkap');
}
@@ -129,7 +127,7 @@ export const useGoogleLogin = () => {
console.error('Google login error:', error);
toast.error('Terjadi kesalahan saat login dengan Google');
}
}, [navigate, setSession]);
}, [setSession]);
return {
handleGoogleLogin,
+20
View File
@@ -0,0 +1,20 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { SessionToken } from '@imphnen-frontend-service/service'
export const Route = createFileRoute('/_public')({
beforeLoad: () => {
const session = SessionToken.get()
if (session?.token?.access_token) {
throw redirect({ to: '/dashboard' })
}
},
component: PublicLayout,
})
function PublicLayout() {
return (
<main className="bg-primary-50 min-h-screen">
<Outlet />
</main>
)
}
@@ -1,9 +1,14 @@
import { FC, ReactElement } from 'react';
import { RegisterResetBanner } from "@imphnen-frontend-service/ui/organisms";
import { ForgotStep } from "@imphnen-frontend-service/ui/molecules";
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { createFileRoute } from '@tanstack/react-router'
import { FC, ReactElement } from 'react'
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms'
import { ForgotStep } from '@imphnen-frontend-service/ui/molecules'
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
export const Components: FC = (): ReactElement => {
export const Route = createFileRoute('/_public/auth/forgot')({
component: ForgotPasswordPage,
})
function ForgotPasswordPage(): ReactElement {
return (
<div className='flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]'>
<div className='bg-white min-w-[1120px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6'>
@@ -18,7 +23,5 @@ export const Components: FC = (): ReactElement => {
</div>
</div>
</div>
);
};
export default Components;
)
}
@@ -1,9 +1,14 @@
import { FC, ReactElement } from 'react';
import { RegisterResetBanner } from "@imphnen-frontend-service/ui/organisms";
import { ForgotStep, OtpForm } from "@imphnen-frontend-service/ui/molecules";
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { createFileRoute } from '@tanstack/react-router'
import { FC, ReactElement } from 'react'
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms'
import { ForgotStep, OtpForm } from '@imphnen-frontend-service/ui/molecules'
import { Button } from '@imphnen-frontend-service/ui/atoms'
export const Components: FC = (): ReactElement => {
export const Route = createFileRoute('/_public/auth/forgot/otp')({
component: ForgotOtpPage,
})
function ForgotOtpPage(): ReactElement {
return (
<div className='flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]'>
<div className='bg-white min-w-[1120px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6'>
@@ -17,7 +22,5 @@ export const Components: FC = (): ReactElement => {
</div>
</div>
</div>
);
};
export default Components;
)
}
@@ -1,9 +1,14 @@
import { FC, ReactElement } from 'react';
import { RegisterResetBanner } from "@imphnen-frontend-service/ui/organisms";
import { ForgotStep } from "@imphnen-frontend-service/ui/molecules";
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { createFileRoute } from '@tanstack/react-router'
import { FC, ReactElement } from 'react'
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms'
import { ForgotStep } from '@imphnen-frontend-service/ui/molecules'
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
export const Components: FC = (): ReactElement => {
export const Route = createFileRoute('/_public/auth/forgot/summon')({
component: ForgotSummonPage,
})
function ForgotSummonPage(): ReactElement {
return (
<div className='flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]'>
<div className='bg-white min-w-[1120px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6'>
@@ -20,15 +25,13 @@ export const Components: FC = (): ReactElement => {
</div>
<div className='mb-4 text-gray-700 flex flex-col gap-3 text-sm'>
<h6><span role='img' aria-label='emoji'>💡</span> Tips dari kami:</h6>
<h6><span role='img' aria-label='emoji'> </span> Buat kombinasi huruf besar, kecil, angka, dan simbol untuk kekuatan maksimal!</h6>
<h6><span role='img' aria-label='emoji'></span> Pastikan kamu ingat password-mu atau simpan di tempat aman~</h6>
<h6><span role='img' aria-label='emoji'> </span> Buat kombinasi huruf besar, kecil, angka, dan simbol untuk kekuatan maksimal!</h6>
<h6><span role='img' aria-label='emoji'></span> Pastikan kamu ingat password-mu atau simpan di tempat aman~</h6>
<h6>Masukkan password baru dan bersiaplah untuk kembali bertualang!</h6>
</div>
<Button className='mt-5'>Summon Password Baru!</Button>
</div>
</div>
</div>
);
};
export default Components;
)
}
@@ -0,0 +1,83 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { FC, ReactElement, useEffect } from 'react'
import { useGoogleCallback, useAuthStore } from '@imphnen-frontend-service/service'
import { toast } from 'sonner'
export const Route = createFileRoute('/_public/auth/google-callback')({
validateSearch: (search: Record<string, unknown>) => ({
code: (search.code as string) || '',
state: (search.state as string) || '',
error: (search.error as string) || '',
}),
component: GoogleCallbackPage,
})
function GoogleCallbackPage(): ReactElement {
const { code, state, error: errorParam } = Route.useSearch()
const navigate = useNavigate()
const { setSession, clearSession } = useAuthStore()
const { mutate: googleCallback } = useGoogleCallback()
useEffect(() => {
const handleCallback = async () => {
if (errorParam) {
toast.error('Google login dibatalkan atau terjadi kesalahan')
navigate({ to: '/auth/login' })
return
}
if (!code || !state) {
toast.error('Parameter login Google tidak valid')
navigate({ to: '/auth/login' })
return
}
try {
googleCallback(
{ code, state },
{
onSuccess: (response) => {
if (response.token && response.user) {
setSession({
token: response.token,
user: response.user,
})
toast.success('Login Google berhasil!')
navigate({ to: '/dashboard' })
} else {
throw new Error('Response data tidak valid')
}
},
onError: (error) => {
console.error('Google OAuth callback error:', error)
toast.error('Login Google gagal')
clearSession()
navigate({ to: '/auth/login' })
},
}
)
} catch (error) {
console.error('Google OAuth callback error:', error)
toast.error('Login Google gagal')
clearSession()
navigate({ to: '/auth/login' })
}
}
handleCallback()
}, [code, state, errorParam, navigate, setSession, clearSession, googleCallback])
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-500 mx-auto mb-4"></div>
<h2 className="text-2xl font-semibold text-primary-500 mb-2">
Menyelesaikan Login Google...
</h2>
<p className="text-gray-600">
Mohon tunggu sebentar, kami sedang memproses login Anda.
</p>
</div>
</div>
)
}
@@ -1,35 +1,40 @@
import { FC, ReactElement, useEffect } from 'react';
import { createFileRoute } from '@tanstack/react-router'
import { FC, ReactElement, useEffect } from 'react'
let globalIsProcessed = false;
let globalIsProcessed = false
export const GoogleOAuthPopupPage: FC = (): ReactElement => {
export const Route = createFileRoute('/_public/auth/google-oauth-popup')({
component: GoogleOAuthPopupPage,
})
function GoogleOAuthPopupPage(): ReactElement {
useEffect(() => {
if (globalIsProcessed) {
return;
return
}
const callBackend = async (code: string, state: string) => {
if (globalIsProcessed) {
return;
return
}
globalIsProcessed = true;
globalIsProcessed = true
try {
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:4099';
let callbackUrl;
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:4099'
let callbackUrl
if (baseUrl.endsWith('/v1')) {
callbackUrl = `${baseUrl}/auth/google/callback`;
callbackUrl = `${baseUrl}/auth/google/callback`
} else {
callbackUrl = `${baseUrl}/v1/auth/google/callback`;
callbackUrl = `${baseUrl}/v1/auth/google/callback`
}
const url = new URL(callbackUrl);
url.searchParams.append('code', code);
url.searchParams.append('state', state);
url.searchParams.append('redirect_uri', `${window.location.origin}/auth/google-oauth-popup`);
const url = new URL(callbackUrl)
url.searchParams.append('code', code)
url.searchParams.append('state', state)
url.searchParams.append('redirect_uri', `${window.location.origin}/auth/google-oauth-popup`)
const response = await fetch(url.toString(), {
method: 'GET',
@@ -39,14 +44,14 @@ export const GoogleOAuthPopupPage: FC = (): ReactElement => {
},
mode: 'cors',
credentials: 'omit',
});
})
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${response.statusText} - ${errorText}`);
const errorText = await response.text()
throw new Error(`HTTP ${response.status}: ${response.statusText} - ${errorText}`)
}
const data = await response.json();
const data = await response.json()
window.opener?.postMessage(
{
@@ -54,11 +59,11 @@ export const GoogleOAuthPopupPage: FC = (): ReactElement => {
payload: data,
},
window.location.origin
);
window.close();
)
window.close()
} catch (error) {
globalIsProcessed = false;
globalIsProcessed = false
window.opener?.postMessage(
{
@@ -66,41 +71,41 @@ export const GoogleOAuthPopupPage: FC = (): ReactElement => {
error: `Failed to process OAuth callback: ${error instanceof Error ? error.message : String(error)}`,
},
window.location.origin
);
window.close();
)
window.close()
}
};
}
const handleOAuthResponse = () => {
const isPopup = window.opener && window.opener !== window;
const isPopup = window.opener && window.opener !== window
const detectJsonResponse = () => {
try {
const bodyText = document.body.innerText || document.body.textContent || '';
const trimmedText = bodyText.trim();
const bodyText = document.body.innerText || document.body.textContent || ''
const trimmedText = bodyText.trim()
if (trimmedText.startsWith('{') && trimmedText.endsWith('}')) {
const parsedJson = JSON.parse(trimmedText);
const parsedJson = JSON.parse(trimmedText)
if (parsedJson && typeof parsedJson === 'object') {
const hasAccessToken = parsedJson.access_token || parsedJson.token || parsedJson.accessToken;
const hasAccessToken = parsedJson.access_token || parsedJson.token || parsedJson.accessToken
if (hasAccessToken) {
return parsedJson;
return parsedJson
}
}
}
} catch {
return null;
return null
}
return null;
};
return null
}
const checkOAuthParams = () => {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const error = urlParams.get('error');
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get('code')
const state = urlParams.get('state')
const error = urlParams.get('error')
if (error) {
if (isPopup) {
@@ -110,23 +115,23 @@ export const GoogleOAuthPopupPage: FC = (): ReactElement => {
error: error,
},
window.location.origin
);
window.close();
)
window.close()
}
return true;
return true
}
if (code && state) {
if (isPopup) {
callBackend(code, state);
callBackend(code, state)
}
return true;
return true
}
return false;
};
return false
}
const immediateJson = detectJsonResponse();
const immediateJson = detectJsonResponse()
if (immediateJson && isPopup) {
window.opener?.postMessage(
{
@@ -134,21 +139,21 @@ export const GoogleOAuthPopupPage: FC = (): ReactElement => {
payload: immediateJson,
},
window.location.origin
);
window.close();
return;
)
window.close()
return
}
if (checkOAuthParams()) {
return;
return
}
let attempts = 0;
const maxAttempts = 50;
let attempts = 0
const maxAttempts = 50
const checkForJson = () => {
attempts++;
const jsonResponse = detectJsonResponse();
attempts++
const jsonResponse = detectJsonResponse()
if (jsonResponse && isPopup) {
window.opener?.postMessage(
@@ -157,13 +162,13 @@ export const GoogleOAuthPopupPage: FC = (): ReactElement => {
payload: jsonResponse,
},
window.location.origin
);
window.close();
return;
)
window.close()
return
}
if (attempts < maxAttempts) {
setTimeout(checkForJson, 500);
setTimeout(checkForJson, 500)
} else if (isPopup) {
window.opener?.postMessage(
{
@@ -171,16 +176,16 @@ export const GoogleOAuthPopupPage: FC = (): ReactElement => {
error: 'Timeout waiting for response',
},
window.location.origin
);
window.close();
)
window.close()
}
};
}
setTimeout(checkForJson, 1000);
};
setTimeout(checkForJson, 1000)
}
setTimeout(handleOAuthResponse, 100);
}, []);
setTimeout(handleOAuthResponse, 100)
}, [])
return (
<div className="flex items-center justify-center min-h-screen">
@@ -194,7 +199,5 @@ export const GoogleOAuthPopupPage: FC = (): ReactElement => {
</p>
</div>
</div>
);
};
export default GoogleOAuthPopupPage;
)
}
@@ -1,68 +1,72 @@
import { useState, useEffect } from 'react';
import { useGitHubAuth, useLogin, authLoginSchema, TLoginRequest } from '@imphnen-frontend-service/service';
import { GithubOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useState, useEffect } from 'react'
import { useGitHubAuth, useLogin, authLoginSchema, TLoginRequest } from '@imphnen-frontend-service/service'
import { GithubOutlined } from '@ant-design/icons'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner'
import { Icon } from '@iconify/react'
export default function LoginPage() {
const navigate = useNavigate();
const { signInWithGitHub } = useGitHubAuth();
const loginMutation = useLogin();
const [isGithubLoading, setIsGithubLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
export const Route = createFileRoute('/_public/auth/login')({
component: LoginPage,
})
function LoginPage() {
const navigate = useNavigate()
const { signInWithGitHub } = useGitHubAuth()
const loginMutation = useLogin()
const [isGithubLoading, setIsGithubLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showPassword, setShowPassword] = useState(false)
const { register, handleSubmit, formState: { errors, isValid } } = useForm<TLoginRequest>({
resolver: zodResolver(authLoginSchema),
mode: 'onChange',
defaultValues: { email: '', password: '' },
});
})
useEffect(() => {
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
const urlParams = new URLSearchParams(globalThis.location.search);
const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
const type = hashParams.get('type') || urlParams.get('type');
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1))
const urlParams = new URLSearchParams(globalThis.location.search)
const accessToken = hashParams.get('access_token') || urlParams.get('access_token')
const type = hashParams.get('type') || urlParams.get('type')
if (accessToken && (type === 'recovery' || type === 'magiclink' || !type)) {
toast.info('Redirecting to password reset...');
navigate('/auth/reset-password?access_token=' + accessToken);
toast.info('Redirecting to password reset...')
navigate({ to: '/auth/login', search: { access_token: accessToken } })
}
}, [navigate]);
}, [navigate])
const onSubmit = handleSubmit(async (data) => {
setError(null);
setError(null)
try {
await loginMutation.mutateAsync(data);
toast.success('Login successful!');
navigate('/dashboard');
await loginMutation.mutateAsync(data)
toast.success('Login successful!')
navigate({ to: '/dashboard' })
} catch (err) {
setError((err as Error).message || 'Login failed');
setError((err as Error).message || 'Login failed')
}
});
})
const handleGithubLogin = async () => {
try {
setIsGithubLoading(true);
const result = await signInWithGitHub();
if (result?.url) globalThis.location.href = result.url;
else { setIsGithubLoading(false); setError('Failed to get GitHub OAuth URL'); }
setIsGithubLoading(true)
const result = await signInWithGitHub()
if (result?.url) globalThis.location.href = result.url
else { setIsGithubLoading(false); setError('Failed to get GitHub OAuth URL') }
} catch (err) {
setError((err as Error).message || 'GitHub login failed');
setIsGithubLoading(false);
setError((err as Error).message || 'GitHub login failed')
setIsGithubLoading(false)
}
};
}
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<div className="flex items-center justify-between mb-6">
<button onClick={() => navigate('/')} className="cursor-pointer text-primary-500 hover:text-primary-600 text-base font-sans flex items-center">
<Link to="/" className="cursor-pointer text-primary-500 hover:text-primary-600 text-base font-sans flex items-center">
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />
Back to Homepage
</button>
</Link>
</div>
<div className="text-center mb-8">
@@ -87,7 +91,7 @@ export default function LoginPage() {
<div>
<div className="flex items-center justify-between mb-1">
<label htmlFor="password" className="block text-sm font-medium text-gray-700">Password</label>
<Link to="/auth/forgot-password" className="text-sm text-primary-600 hover:text-primary-700">Forgot password?</Link>
<Link to="/auth/forgot" className="text-sm text-primary-600 hover:text-primary-700">Forgot password?</Link>
</div>
<div className="relative">
<input id="password" type={showPassword ? 'text' : 'password'} {...register('password')} placeholder="••••••••" disabled={loginMutation.isPending}
@@ -122,12 +126,12 @@ export default function LoginPage() {
</p>
<div className="mt-6 text-center">
<p className="text-gray-600 text-sm">Don't have an account? <Link to="/auth/signup" className="text-primary-600 hover:text-primary-700 font-semibold">Sign up</Link></p>
<p className="text-gray-600 text-sm">Don't have an account? <Link to="/auth/register" className="text-primary-600 hover:text-primary-700 font-semibold">Sign up</Link></p>
</div>
<div className="mt-6 text-center">
<p className="text-gray-500 text-xs">By signing in, you agree to our Terms of Service and Privacy Policy</p>
</div>
</div>
</div>
);
)
}
@@ -1,10 +1,15 @@
import { FC, ReactElement, useState } from 'react';
import { ControlledInputField, RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
import { Button, Select } from '@imphnen-frontend-service/ui/atoms';
import { ArrowLeftOutlined, ArrowRightOutlined } from '@ant-design/icons';
import { InputField, RegisterMentorStep, SelectField } from '@imphnen-frontend-service/ui/molecules';
import { createFileRoute } from '@tanstack/react-router'
import { FC, ReactElement, useState } from 'react'
import { ControlledInputField, RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms'
import { Button, Select } from '@imphnen-frontend-service/ui/atoms'
import { ArrowLeftOutlined, ArrowRightOutlined } from '@ant-design/icons'
import { InputField, RegisterMentorStep, SelectField } from '@imphnen-frontend-service/ui/molecules'
export const Components: FC = (): ReactElement => {
export const Route = createFileRoute('/_public/auth/register-mentor')({
component: RegisterMentorPage,
})
function RegisterMentorPage(): ReactElement {
const [ step, setStep ] = useState(1)
return (
@@ -118,7 +123,7 @@ export const Components: FC = (): ReactElement => {
<option value="Other">Other</option>
</SelectField>
<InputField
label="Jika Mengisi “Other”, Tulis Topik yang Kamu Kuasai"
label={`Jika Mengisi \u201COther\u201D, Tulis Topik yang Kamu Kuasai`}
size="lg"
className="w-full"
placeholder="Tolong isi jika tidak ada di pilihan senpai"
@@ -169,7 +174,7 @@ export const Components: FC = (): ReactElement => {
/>
<InputField
type='number'
label="Honorarium yang Diharapkan per Sesi (Online) Durasi rata-rata sesi: 2,53 jam"
label="Honorarium yang Diharapkan per Sesi (Online) Durasi rata-rata sesi: 2,5\u20133 jam"
size="lg"
className="w-full"
placeholder="Isi Nominalnya dong senpaiii!!!"
@@ -236,7 +241,5 @@ export const Components: FC = (): ReactElement => {
</div>
</div>
</div>
);
};
export default Components;
)
}
@@ -1,9 +1,14 @@
import { FC, ReactElement, useEffect } from 'react';
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { ArrowRightOutlined } from '@ant-design/icons';
import { createFileRoute } from '@tanstack/react-router'
import { FC, ReactElement } from 'react'
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { ArrowRightOutlined } from '@ant-design/icons'
export const Components: FC = (): ReactElement => {
export const Route = createFileRoute('/_public/auth/register-mentor/pending')({
component: RegisterMentorPendingPage,
})
function RegisterMentorPendingPage(): ReactElement {
return (
<div className="flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]">
<div className="bg-white xl:min-w-[1220px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6">
@@ -19,7 +24,7 @@ export const Components: FC = (): ReactElement => {
<svg className="mt-10" width="148" height="148" viewBox="0 0 148 148" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="74" cy="74" r="73.6348" fill="#42CDF8"/>
<rect x="23.5078" y="24.8463" width="99.646" height="99.646" rx="49.823" fill="#0185D0"/>
<circle cx="73.3284" cy="74.6689" r="33.11" transform="rotate(150 73.3284 74.6689)" stroke="#F0F8FF" stroke-width="1.1825"/>
<circle cx="73.3284" cy="74.6689" r="33.11" transform="rotate(150 73.3284 74.6689)" stroke="#F0F8FF" strokeWidth="1.1825"/>
<path d="M44.6436 91.2314C42.1053 92.6969 41.2086 95.9711 43.0096 98.2836C46.1026 102.255 49.9569 105.594 54.3651 108.095C60.3178 111.473 67.0644 113.199 73.9077 113.096C80.7511 112.993 87.4427 111.065 93.2912 107.51C97.6222 104.878 101.374 101.424 104.347 97.3614C106.077 94.9958 105.083 91.75 102.501 90.3615C99.9201 88.9729 96.7369 89.9826 94.884 92.2537C92.8925 94.6949 90.4923 96.7901 87.7782 98.4398C83.545 101.013 78.7015 102.409 73.7482 102.483C68.7949 102.558 63.9116 101.308 59.6029 98.8634C56.8404 97.2961 54.3784 95.2739 52.3144 92.8938C50.3941 90.6794 47.182 89.7658 44.6436 91.2314Z" fill="#F0F8FF"/>
</svg>
<h4 className="mt-10 text-primary-500 font-medium text-xl text-center">
@@ -35,7 +40,5 @@ export const Components: FC = (): ReactElement => {
</div>
</div>
</div>
);
};
export default Components;
)
}
@@ -1,9 +1,14 @@
import { FC, ReactElement, useEffect } from 'react';
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { ArrowRightOutlined } from '@ant-design/icons';
import { createFileRoute } from '@tanstack/react-router'
import { FC, ReactElement } from 'react'
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { ArrowRightOutlined } from '@ant-design/icons'
export const Components: FC = (): ReactElement => {
export const Route = createFileRoute('/_public/auth/register-mentor/success')({
component: RegisterMentorSuccessPage,
})
function RegisterMentorSuccessPage(): ReactElement {
return (
<div className="flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]">
<div className="bg-white xl:min-w-[1220px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6">
@@ -51,7 +56,5 @@ export const Components: FC = (): ReactElement => {
</div>
</div>
</div>
);
};
export default Components;
)
}
@@ -1,11 +1,16 @@
import { FC, ReactElement } from 'react';
import { ControlledInputField, RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { useRegisterHook } from '../../_hooks/use-register';
import { createFileRoute } from '@tanstack/react-router'
import { FC, ReactElement } from 'react'
import { ControlledInputField, RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { useRegisterHook } from '../../_hooks/use-register'
export const Components: FC = (): ReactElement => {
const { form, onSubmit, isLoading } = useRegisterHook();
export const Route = createFileRoute('/_public/auth/register')({
component: RegisterPage,
})
function RegisterPage(): ReactElement {
const { form, onSubmit, isLoading } = useRegisterHook()
return (
<div className="flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]">
@@ -54,7 +59,7 @@ export const Components: FC = (): ReactElement => {
placeholder="Contoh : 088877665544"
control={form.control}
name={'phone_number'}
/>
/>
</div>
<ControlledInputField
label="Password"
@@ -63,7 +68,7 @@ export const Components: FC = (): ReactElement => {
type="password"
placeholder="Buat password sekeren jurus ultimate-mu!"
control={form.control}
name={'password'}
name={'password'}
/>
<h6 className="text-sm text-gray-500 mt-1">
"Senpai~! Pastikan password-mu sekuat pertahanan kastil!"
@@ -107,7 +112,5 @@ export const Components: FC = (): ReactElement => {
</div>
</div>
</div>
);
};
export default Components;
)
}
@@ -1,15 +1,22 @@
import { FC, ReactElement, useEffect, useState } from 'react';
import { ControlledInputField, RegisterResetBanner } from "@imphnen-frontend-service/ui/organisms";
import { useOtpHook } from '../../../_hooks/use-otp';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useSearchParams } from 'react-router-dom';
import { useResendOtpHook } from '../../../_hooks/use-resend-otp';
import { createFileRoute } from '@tanstack/react-router'
import { FC, ReactElement, useEffect, useState } from 'react'
import { ControlledInputField, RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms'
import { useOtpHook } from '../../../_hooks/use-otp'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { useResendOtpHook } from '../../../_hooks/use-resend-otp'
export const Components: FC = (): ReactElement => {
export const Route = createFileRoute('/_public/auth/register/otp')({
validateSearch: (search: Record<string, unknown>) => ({
email: (search.email as string) || '',
}),
component: RegisterOtpPage,
})
function RegisterOtpPage(): ReactElement {
const { form, onSubmit, isLoading } = useOtpHook()
const [ searchParams ] = useSearchParams()
const [ disabled, setDisabled] = useState(true);
const [ timeLeft, setTimeLeft ] = useState(5 * 60);
const { email } = Route.useSearch()
const [disabled, setDisabled] = useState(true)
const [timeLeft, setTimeLeft] = useState(5 * 60)
const { resendOTP } = useResendOtpHook()
useEffect(() => {
@@ -17,22 +24,22 @@ export const Components: FC = (): ReactElement => {
const interval = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 1) {
clearInterval(interval);
setDisabled(false);
return 0;
clearInterval(interval)
setDisabled(false)
return 0
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
return prev - 1
})
}, 1000)
return () => clearInterval(interval)
}
}, [disabled]);
}, [disabled])
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
};
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
return (
<div className='flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]'>
@@ -40,7 +47,7 @@ export const Components: FC = (): ReactElement => {
<RegisterResetBanner />
<div className='border-2 border-primary-500/50 w-[596px] rounded-lg py-[70px] px-[48px] flex flex-col justify-center'>
<h3 className='mt-5 text-3xl font-semibold text-primary-500'>Verifikasi Email</h3>
<h5 className='mt-2 text-xl font-medium text-primary-500'>Yeay~! Pesan dari dunia lain sudah dikirimkan ke {searchParams.get("email")}</h5>
<h5 className='mt-2 text-xl font-medium text-primary-500'>Yeay~! Pesan dari dunia lain sudah dikirimkan ke {email}</h5>
<form onSubmit={onSubmit} className='mt-7'>
<ControlledInputField
label="OTP"
@@ -51,10 +58,10 @@ export const Components: FC = (): ReactElement => {
name={'otp'}
maxLength={6}
control={form.control}
/>
/>
<Button type="button" className="mt-2" disabled={disabled} onClick={
() => {
resendOTP({email: searchParams.get("email") || ""})
resendOTP({email: email || ""})
setDisabled(true)
setTimeLeft(5 * 60)
}
@@ -64,7 +71,5 @@ export const Components: FC = (): ReactElement => {
</div>
</div>
</div>
);
};
export default Components;
)
}
@@ -1,9 +1,14 @@
import { FC, ReactElement, useEffect } from 'react';
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { ArrowRightOutlined } from '@ant-design/icons';
import { createFileRoute } from '@tanstack/react-router'
import { FC, ReactElement, useEffect } from 'react'
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { ArrowRightOutlined } from '@ant-design/icons'
export const Components: FC = (): ReactElement => {
export const Route = createFileRoute('/_public/auth/register/success')({
component: RegisterSuccessPage,
})
function RegisterSuccessPage(): ReactElement {
useEffect(() => {
setTimeout(() => {
document.location.href = "/auth/login"
@@ -57,7 +62,5 @@ export const Components: FC = (): ReactElement => {
</div>
</div>
</div>
);
};
export default Components;
)
}
+19
View File
@@ -0,0 +1,19 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
import { Header } from './_site/_components/header'
import { Footer } from './_site/_components/footer'
export const Route = createFileRoute('/_site')({
component: SiteLayout,
})
function SiteLayout() {
return (
<div className="flex min-h-screen flex-col bg-primary-50">
<Header />
<main className="flex-1">
<Outlet />
</main>
<Footer />
</div>
)
}
@@ -1,7 +1,7 @@
import { DiscordOutlined, GithubOutlined, InstagramOutlined } from '@ant-design/icons';
import { cn, For } from '@imphnen-frontend-service/utils';
import React, { FC, useRef } from 'react';
import { Link } from 'react-router-dom';
import { Link } from '@tanstack/react-router';
import { motion, useInView, Variants } from 'framer-motion';
import { SteamIcon } from '../../_components/icons';
@@ -3,7 +3,7 @@ import { Button } from "@imphnen-frontend-service/ui/atoms";
import { cn, For, Show } from "@imphnen-frontend-service/utils";
import { motion, useMotionValueEvent, useScroll, Variants } from "framer-motion";
import { FC, useMemo, useState } from "react";
import { Link, NavLink, useLocation } from "react-router-dom";
import { Link, useLocation } from "@tanstack/react-router";
const MENUS: { label: string; href: string }[] = [
{ label: "Home", href: "/" },
@@ -73,7 +73,7 @@ export const Header: FC = () => {
isActive(menu.href) && "text-primary-500 hover:text-primary-500"
)}
>
<NavLink to={menu.href}>{menu.label}</NavLink>
<Link to={menu.href}>{menu.label}</Link>
</Button>
)}
</For>
@@ -1,13 +1,18 @@
import { ReactElement, useRef, useState } from "react";
import { ArticleCard } from "./_components/card/article";
import { cn, For } from "@imphnen-frontend-service/utils";
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { motion, useInView, Variants } from "framer-motion";
import { createFileRoute } from '@tanstack/react-router'
import { ReactElement, useRef, useState } from 'react'
import { ArticleCard } from './articles_/_components/card/article'
import { cn, For } from '@imphnen-frontend-service/utils'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { motion, useInView, Variants } from 'framer-motion'
const CATEGORIES = ['UI/UX Design', 'Software/Web Dev', 'Data & AI', 'Cloud & DevOps', 'Cybersecurity', 'IT & Network', 'Project Management', 'QA & Testing'] as const
type Category = typeof CATEGORIES[number]
export default function Components(): ReactElement {
export const Route = createFileRoute('/_site/articles')({
component: ArticlesPage,
})
function ArticlesPage(): ReactElement {
const [activeTab, setActiveTab] = useState<Category>('UI/UX Design')
const ref = useRef(null)
@@ -1,9 +1,14 @@
import { Icon } from "@iconify/react";
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { cn } from "@imphnen-frontend-service/utils";
import { motion } from "framer-motion";
import { createFileRoute } from '@tanstack/react-router'
import { Icon } from '@iconify/react'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { cn } from '@imphnen-frontend-service/utils'
import { motion } from 'framer-motion'
export default function DetailArticle() {
export const Route = createFileRoute('/_site/articles/$slug')({
component: DetailArticlePage,
})
function DetailArticlePage() {
return (
<main>
<motion.section
@@ -1,7 +1,7 @@
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { cn, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react"
import { Link } from "react-router-dom"
import { Link } from "@tanstack/react-router"
export type ArticleCardProps = {
variant?: "default" | "recent" | "featured"
@@ -1,30 +1,35 @@
import { FC, ReactElement, useRef, useState } from 'react';
import { BannerSection } from './_components/banner-section';
import { Topics } from './_components/topics';
import { Input } from '@imphnen-frontend-service/ui/atoms';
import { SearchOutlined } from '@ant-design/icons';
import { For } from '@imphnen-frontend-service/utils';
import { MentorCard } from './_components/mentor-card';
import { Pagination } from '@imphnen-frontend-service/ui/molecules';
import { getCoreRowModel, getPaginationRowModel, PaginationState, useReactTable } from '@tanstack/react-table';
import { motion, useInView, Variants } from 'framer-motion';
import { useMentorList } from '@imphnen-frontend-service/service';
import { createFileRoute } from '@tanstack/react-router'
import { FC, ReactElement, useRef, useState } from 'react'
import { BannerSection } from './mentoring_/_components/banner-section'
import { Topics } from './mentoring_/_components/topics'
import { Input } from '@imphnen-frontend-service/ui/atoms'
import { SearchOutlined } from '@ant-design/icons'
import { For } from '@imphnen-frontend-service/utils'
import { MentorCard } from './mentoring_/_components/mentor-card'
import { Pagination } from '@imphnen-frontend-service/ui/molecules'
import { getCoreRowModel, getPaginationRowModel, PaginationState, useReactTable } from '@tanstack/react-table'
import { motion, useInView, Variants } from 'framer-motion'
import { useMentorList } from '@imphnen-frontend-service/service'
export const Components: FC = (): ReactElement => {
const [search, setSearch] = useState('');
export const Route = createFileRoute('/_site/mentoring')({
component: MentoringPage,
})
function MentoringPage(): ReactElement {
const [search, setSearch] = useState('')
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 8,
});
})
const { data: mentorData, isLoading } = useMentorList({
page: pagination.pageIndex + 1,
per_page: pagination.pageSize,
search: search || undefined,
});
})
const mentors = mentorData?.data ?? [];
const totalItems = mentorData?.meta?.total ?? 0;
const mentors = mentorData?.data ?? []
const totalItems = mentorData?.meta?.total ?? 0
const table = useReactTable({
data: mentors,
@@ -33,11 +38,11 @@ export const Components: FC = (): ReactElement => {
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: (updater) => {
setPagination(updater);
setPagination(updater)
},
pageCount: Math.ceil(totalItems / pagination.pageSize) || 1,
manualPagination: true,
});
})
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
@@ -101,8 +106,8 @@ export const Components: FC = (): ReactElement => {
className="relative min-w-full w-full"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPagination((p) => ({ ...p, pageIndex: 0 }));
setSearch(e.target.value)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}}
/>
<SearchOutlined className="absolute right-2.5 top-1/2 -translate-y-1/2 text-primary-500 size-2.5 cursor-text md:me-12 lg:me-0" />
@@ -134,5 +139,3 @@ export const Components: FC = (): ReactElement => {
</main>
)
}
export default Components
@@ -1,19 +1,23 @@
import { createFileRoute } from '@tanstack/react-router'
import { FC, useState } from 'react'
import { useParams } from 'react-router-dom'
import { ProfileSection } from './_components/sections/profile'
import { StatisticsSection } from './_components/sections/senpai-statistics'
import { TopicsSection } from './_components/sections/topics'
import { ExperienceSection } from './_components/sections/experience'
import { EducationSection } from './_components/sections/education'
import { SenpaiScheduleSection } from './_components/sections/senpai-schedule'
import { ProfileSection } from './$id/_components/sections/profile'
import { StatisticsSection } from './$id/_components/sections/senpai-statistics'
import { TopicsSection } from './$id/_components/sections/topics'
import { ExperienceSection } from './$id/_components/sections/experience'
import { EducationSection } from './$id/_components/sections/education'
import { SenpaiScheduleSection } from './$id/_components/sections/senpai-schedule'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { AppointmentModal } from './_components/modals/appointment'
import { AppointmentModal } from './$id/_components/modals/appointment'
import { useMentorById } from '@imphnen-frontend-service/service'
export const Components: FC = () => {
export const Route = createFileRoute('/_site/mentoring/$id')({
component: MentorDetailPage,
})
function MentorDetailPage() {
const [open, setOpen] = useState(false)
const params = useParams()
const mentorId = params?.id ?? ''
const { id } = Route.useParams()
const mentorId = id ?? ''
const { data: mentor, isLoading } = useMentorById(mentorId)
if (isLoading) {
@@ -63,5 +67,3 @@ export const Components: FC = () => {
</main>
)
}
export default Components
@@ -1,6 +1,6 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { FC } from 'react';
import { Link } from 'react-router-dom';
import { Link } from '@tanstack/react-router';
import type { MentorDetailResponseDto } from '@imphnen-frontend-service/service';
interface MentorCardProps {
@@ -1,37 +1,40 @@
'use client';
import { createFileRoute } from '@tanstack/react-router'
import { FC, ReactElement, useState } from 'react'
import { ProfileForm, ProfileSidebar, ProfileHeader } from './profile_/_components'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { NotificationModal, NotificationType } from './profile_/_components/modals/notification-modal'
import { ProfileProvider, useProfile } from './profile_/_components/contexts/profile-context'
import { EditProfileModal } from './profile_/_components/modals/edit-profile-modal'
import { FC, ReactElement, useState } from 'react';
import { ProfileForm, ProfileSidebar, ProfileHeader } from './_components';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { NotificationModal, NotificationType } from './_components/modals/notification-modal';
import { ProfileProvider, useProfile } from './_components/contexts/profile-context';
import { EditProfileModal } from './_components/modals/edit-profile-modal';
export const Route = createFileRoute('/_site/profile')({
component: ProfilePage,
})
export const Components: FC = (): ReactElement => {
function ProfilePage(): ReactElement {
return (
<ProfileProvider profileType="user">
<ProfileContent />
</ProfileProvider>
);
};
)
}
const ProfileContent: FC = (): ReactElement => {
const { isLoading, error } = useProfile();
const { isLoading, error } = useProfile()
const [notification, setNotification] = useState<{
isOpen: boolean;
type: 'success' | 'error';
title: string;
message?: string;
isOpen: boolean
type: 'success' | 'error'
title: string
message?: string
}>({
isOpen: false,
type: 'success',
title: '',
message: ''
});
})
const [isEditProfileModalOpen, setIsEditProfileModalOpen] = useState(false);
const [isEditProfileModalOpen, setIsEditProfileModalOpen] = useState(false)
const showNotification = (type: NotificationType['type'], title: string, message?: string) => {
setNotification({
@@ -39,20 +42,20 @@ const ProfileContent: FC = (): ReactElement => {
type,
title,
message
});
};
})
}
const hideNotification = () => {
setNotification(prev => ({ ...prev, isOpen: false }));
};
setNotification(prev => ({ ...prev, isOpen: false }))
}
const openEditProfileModal = () => {
setIsEditProfileModalOpen(true);
};
setIsEditProfileModalOpen(true)
}
const closeEditProfileModal = () => {
setIsEditProfileModalOpen(false);
};
setIsEditProfileModalOpen(false)
}
if (isLoading) {
return (
@@ -62,7 +65,7 @@ const ProfileContent: FC = (): ReactElement => {
<p className="mt-4 text-gray-600">Loading profile...</p>
</div>
</main>
);
)
}
if (error) {
@@ -73,7 +76,7 @@ const ProfileContent: FC = (): ReactElement => {
<p className="text-gray-600 mt-2">Please try again later.</p>
</div>
</main>
);
)
}
return (
@@ -126,7 +129,5 @@ const ProfileContent: FC = (): ReactElement => {
showNotification={showNotification}
/>
</main>
);
};
export default Components;
)
}
@@ -1,17 +1,18 @@
'use client';
import { createFileRoute } from '@tanstack/react-router'
import { FC, ReactElement, useState } from 'react'
import { ProfileForm, ProfileSidebar, ProfileHeader } from './_components'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { NotificationModal, NotificationType } from './_components/modals/notification-modal'
import { ProfileProvider, useProfile } from './_components/contexts/profile-context'
import { EditProfileModal } from './_components/modals/edit-profile-modal'
import { FC, ReactElement, useState } from 'react';
import { useParams } from 'react-router-dom';
import { ProfileForm, ProfileSidebar, ProfileHeader } from '../_components';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { NotificationModal, NotificationType } from '../_components/modals/notification-modal';
import { ProfileProvider, useProfile } from '../_components/contexts/profile-context';
import { EditProfileModal } from '../_components/modals/edit-profile-modal';
export const Route = createFileRoute('/_site/profile/$id')({
component: ProfileByIdPage,
})
const ProfileByIdPage: FC = (): ReactElement => {
const params = useParams();
const id = (params && params.id) ? params.id as string : undefined;
function ProfileByIdPage(): ReactElement {
const { id } = Route.useParams()
if (!id) {
return (
@@ -20,39 +21,39 @@ const ProfileByIdPage: FC = (): ReactElement => {
<p className="text-red-600 text-lg">Profile ID not found.</p>
</div>
</main>
);
)
}
return (
<ProfileProvider profileId={id} profileType="user">
<ProfileByIdContent />
</ProfileProvider>
);
};
)
}
const ProfileByIdContent: FC = (): ReactElement => {
const { profileData, isLoading, error, profileType } = useProfile();
const { profileData, isLoading, error, profileType } = useProfile()
const [notification, setNotification] = useState<{
isOpen: boolean;
type: 'success' | 'error';
title: string;
message?: string;
isOpen: boolean
type: 'success' | 'error'
title: string
message?: string
}>({
isOpen: false,
type: 'success',
title: '',
message: ''
});
})
const [isEditProfileModalOpen, setIsEditProfileModalOpen] = useState(false);
const [isEditProfileModalOpen, setIsEditProfileModalOpen] = useState(false)
const getProfileTitle = () => {
if (profileData?.fullname) {
return `${profileData.fullname}'s Profile`;
return `${profileData.fullname}'s Profile`
}
return profileType === 'user' ? 'User Profile' : 'Mentor Profile';
};
return profileType === 'user' ? 'User Profile' : 'Mentor Profile'
}
const showNotification = (type: NotificationType['type'], title: string, message?: string) => {
setNotification({
@@ -60,22 +61,22 @@ const ProfileByIdContent: FC = (): ReactElement => {
type,
title,
message
});
};
})
}
const hideNotification = () => {
setNotification(prev => ({ ...prev, isOpen: false }));
};
setNotification(prev => ({ ...prev, isOpen: false }))
}
const openEditProfileModal = () => {
setIsEditProfileModalOpen(true);
};
setIsEditProfileModalOpen(true)
}
const closeEditProfileModal = () => {
setIsEditProfileModalOpen(false);
};
setIsEditProfileModalOpen(false)
}
const isViewOnly = true;
const isViewOnly = true
if (isLoading) {
return (
@@ -85,7 +86,7 @@ const ProfileByIdContent: FC = (): ReactElement => {
<p className="mt-4 text-gray-600">Loading profile...</p>
</div>
</main>
);
)
}
if (error) {
@@ -96,7 +97,7 @@ const ProfileByIdContent: FC = (): ReactElement => {
<p className="text-gray-600 mt-2">Profile not found or you don't have permission to view it.</p>
</div>
</main>
);
)
}
return (
@@ -158,7 +159,5 @@ const ProfileByIdContent: FC = (): ReactElement => {
/>
)}
</main>
);
};
export default ProfileByIdPage;
)
}
@@ -1,7 +1,7 @@
'use client';
import React, { createContext, useContext, useMemo, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { useParams } from '@tanstack/react-router';
import { useQueryClient } from '@tanstack/react-query';
import {
useAuthStore,
@@ -65,7 +65,7 @@ export const ProfileProvider: React.FC<ProfileProviderProps> = ({
profileId,
profileType: forcedProfileType
}) => {
const params = useParams();
const params = useParams({ strict: false });
const { session } = useAuthStore();
const queryClient = useQueryClient();

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