fix: update action versions in Nix build workflow (#78)

This commit is contained in:
Asep Haryana Saputra
2026-04-13 14:28:10 +07:00
committed by GitHub
parent 06c69cb3c5
commit 426b71f341
35 changed files with 2613 additions and 445 deletions
+8 -1
View File
@@ -72,4 +72,11 @@ out
result
.claude/worktrees
.claude/settings.local.json
.claude/settings.local.json
# Screenshots and Test Reports
**/screenshots
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache
+1
View File
@@ -1 +1,2 @@
VITE_API_URL=
VITE_GITHUB_CLIENT_ID=
+3 -1
View File
@@ -1 +1,3 @@
VITE_API_URL=
VITE_API_URL=
VITE_DISABLE_AUTH=false
VITE_GITHUB_CLIENT_ID=
+141
View File
@@ -0,0 +1,141 @@
import { test, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
/**
* Test configuration for dashboard screenshot capture
*/
interface DashboardTestCase {
persona: 'user' | 'mentor';
viewport: 'desktop' | 'mobile';
roleName: string;
waitForText: string;
screenshotFileName: string;
}
/**
* Mock authentication token structure
*/
const mockToken = {
access_token: 'mock_access_token_' + Math.random().toString(36).substring(7),
refresh_token: 'mock_refresh_token_' + Math.random().toString(36).substring(7),
};
/**
* Create a mock user object based on persona
*/
function createMockUser(persona: 'user' | 'mentor') {
const basUser = {
id: 'mock-user-id',
email: 'test@imphnen.com',
fullname: 'Test User',
is_active: true,
role: {
id: 'role-' + persona,
name: persona === 'mentor' ? 'Mentor Role' : 'User Role',
permissions: [],
},
};
return basUser;
}
/**
* Dashboard test cases
*/
const testCases: DashboardTestCase[] = [
{
persona: 'user',
viewport: 'desktop',
roleName: 'User Role',
waitForText: 'Roadmaps',
screenshotFileName: 'dashboard_user_mock_desktop.png',
},
{
persona: 'mentor',
viewport: 'desktop',
roleName: 'Mentor Role',
waitForText: 'Analytics',
screenshotFileName: 'dashboard_mentor_mock_desktop.png',
},
{
persona: 'user',
viewport: 'mobile',
roleName: 'User Role',
waitForText: 'Roadmaps',
screenshotFileName: 'dashboard_user_mock_mobile.png',
},
{
persona: 'mentor',
viewport: 'mobile',
roleName: 'Mentor Role',
waitForText: 'Analytics',
screenshotFileName: 'dashboard_mentor_mock_mobile.png',
},
];
test.describe('Dashboard Screenshot Capture', () => {
const screenshotDir = path.resolve(__dirname, '../screenshots');
test.beforeAll(() => {
if (!fs.existsSync(screenshotDir)) {
fs.mkdirSync(screenshotDir, { recursive: true });
}
});
/**
* Run test for each dashboard variant
*/
for (const testCase of testCases) {
test(`capture ${testCase.persona} ${testCase.viewport} dashboard screenshot`, async ({
page,
context,
}) => {
// Set viewport size based on device type
const viewportSize = testCase.viewport === 'desktop' ? { width: 1280, height: 832 } : { width: 375, height: 667 };
await page.setViewportSize(viewportSize);
// Set authentication cookie with mock token
const tokenCookie = {
name: 'token',
value: JSON.stringify({ token: mockToken }),
url: 'http://localhost:3000',
secure: false,
httpOnly: false,
sameSite: 'Strict' as const,
};
await context.addCookies([tokenCookie]);
// Set user in localStorage
const mockUser = createMockUser(testCase.persona);
await page.goto('http://localhost:3000', { waitUntil: 'domcontentloaded' });
await page.evaluate(
({ user }) => {
localStorage.setItem('users', JSON.stringify(user));
},
{ user: mockUser }
);
// Navigate to dashboard with persona parameter for explicit override
const dashboardUrl = `http://localhost:3000/dashboard?persona=${testCase.persona}`;
await page.goto(dashboardUrl, { waitUntil: 'networkidle', timeout: 30000 });
// Wait for stable dashboard content
await page.waitForSelector(
`text="${testCase.waitForText}"`,
{ timeout: 10000 }
);
// Additional wait for content to render
await page.waitForTimeout(1000);
// Capture screenshot
const screenshotPath = path.join(screenshotDir, testCase.screenshotFileName);
await page.screenshot({
path: screenshotPath,
fullPage: true,
});
console.log(`✓ Captured: ${testCase.screenshotFileName}`);
});
}
});
+83
View File
@@ -0,0 +1,83 @@
import { test, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
/**
* Dynamically extract all routes from the TanStack Router generated file.
*/
function getDynamicRoutes(): string[] {
const routeTreePath = path.resolve(__dirname, '../src/routeTree.gen.ts');
if (!fs.existsSync(routeTreePath)) {
console.warn('Route tree file not found, falling back to basic routes');
return ['/'];
}
const content = fs.readFileSync(routeTreePath, 'utf-8');
// Extract the FileRoutesByFullPath interface block
const interfaceMatch = content.match(/export interface FileRoutesByFullPath \{([\s\S]*?)\}/);
if (!interfaceMatch) return ['/'];
const block = interfaceMatch[1];
// Extract all strings in single quotes
const routeMatches = block.match(/'([^']+)'/g);
if (!routeMatches) return ['/'];
return routeMatches.map(m => {
let route = m.replace(/'/g, '');
// Normalize trailing slashes (Optional cleanup)
if (route !== '/' && route.endsWith('/')) {
route = route.slice(0, -1);
}
// Replace dynamic parameters with sample values
return route
.replace(/\$slug/g, 'sample-article')
.replace(/\$id/g, 'sample-id')
.replace(/\$taskId/g, 'sample-task');
});
}
test.describe('Automated Route Discovery & Screenshots', () => {
const baseURL = process.env.BASE_URL || 'http://localhost:3000';
const screenshotDir = path.resolve(__dirname, '../screenshots');
const routes = [...new Set(getDynamicRoutes())]; // Unique routes
test.beforeAll(() => {
if (!fs.existsSync(screenshotDir)) {
fs.mkdirSync(screenshotDir, { recursive: true });
}
});
console.log(`Discovered ${routes.length} routes for processing.`);
for (const route of routes) {
test(`capture screenshot: ${route}`, async ({ page }) => {
console.log(`Processing: ${baseURL}${route}`);
try {
await page.goto(`${baseURL}${route}`, {
waitUntil: 'networkidle',
timeout: 30000
});
// Wait for rendering
await page.waitForTimeout(1500);
const fileName = route.replace(/\//g, '_').replace(/^_/, '').replace(/[:$]/g, '') || 'home';
const screenshotPath = path.join(screenshotDir, `${fileName}.png`);
await page.screenshot({
path: screenshotPath,
fullPage: true
});
console.log(`Success: ${fileName}.png`);
} catch (error) {
console.error(`Failed to capture ${route}:`, error.message);
}
});
}
});
+43
View File
@@ -0,0 +1,43 @@
import { defineConfig, devices } from '@playwright/test';
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'npx nx dev dimentorin',
url: 'http://localhost:3000',
reuseExistingServer: true,
cwd: '../../', // Run from root where Nx is available
timeout: 120000,
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+20
View File
@@ -87,6 +87,22 @@
--text-p2: 1.23rem; /* ~19.68px */
--text-label1: 0.98rem; /* ~15.74px */
--text-label2: 0.78rem; /* ~12.59px */
/* Custom Colors for CSS Hex Exact Matches - IMPHNEN Dimentorin Brand */
--color-bg-light-blue: #f7fbff; /* Primary dashboard background */
--color-primary-accent: #23a1eb; /* Primary interactive color (buttons, links, active states) */
--color-text-dark: #1a1a1a; /* Ultra-dark text (auth titles, headers) */
--color-text-secondary: #6d6d6d; /* Secondary text (subtitles, descriptions) */
--color-text-label: #454545; /* Form labels and descriptive labels */
--color-text-muted: #888888; /* Muted text (nav items, metric labels) */
--color-border-light: #bce1fb; /* Light blue borders (inputs, tabs) */
--color-placeholder: #b0b0b0; /* Input placeholder text, disabled backgrounds */
--color-bg-hover: #f6f6f6; /* Hover state backgrounds (nav items) */
--color-divider-blue: #81cbf8; /* Divider lines and gradient fills */
--color-border-subtle: #e5e5e5; /* Very light borders (buttons) */
--color-bg-secondary: #f8f8f8; /* Secondary backgrounds (alt button) */
--color-bg-placeholder: #d9d9d9; /* Placeholder backgrounds (avatars, images) */
--color-text-tab-default: #4f4f4f; /* Tab button text color */
}
@layer base {
@@ -124,4 +140,8 @@
.scrollbar-hide::-webkit-scrollbar {
@apply hidden;
}
.shadow-auth {
box-shadow: 0 10px 40px rgba(35, 161, 235, 0.1);
}
}
+99 -19
View File
@@ -18,7 +18,7 @@ import { Route as SiteProfileRouteImport } from './routes/_site/profile'
import { Route as SiteMentoringRouteImport } from './routes/_site/mentoring'
import { Route as SiteArticlesRouteImport } from './routes/_site/articles'
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard'
import { Route as AuthenticatedDashboardIndexRouteImport } from './routes/_authenticated/dashboard_/index'
import { Route as AuthenticatedDashboardIndexRouteImport } from './routes/_authenticated/dashboard/index'
import { Route as SiteProfileIdRouteImport } from './routes/_site/profile_/$id'
import { Route as SiteMentoringIdRouteImport } from './routes/_site/mentoring_/$id'
import { Route as SiteArticlesSlugRouteImport } from './routes/_site/articles_/$slug'
@@ -29,6 +29,9 @@ import { Route as PublicAuthGoogleOauthPopupRouteImport } from './routes/_public
import { Route as PublicAuthGoogleCallbackRouteImport } from './routes/_public/auth/google-callback'
import { Route as PublicAuthForgotRouteImport } from './routes/_public/auth/forgot'
import { Route as AuthenticatedDashboardSettingsRouteImport } from './routes/_authenticated/dashboard_/settings'
import { Route as AuthenticatedDashboardRoadmapDiscoveryRouteImport } from './routes/_authenticated/dashboard/roadmap-discovery'
import { Route as AuthenticatedDashboardMentoringRouteImport } from './routes/_authenticated/dashboard/mentoring'
import { Route as AuthenticatedDashboardLearningPathRouteImport } from './routes/_authenticated/dashboard/learning-path'
import { Route as PublicAuthRegisterSuccessRouteImport } from './routes/_public/auth/register_/success'
import { Route as PublicAuthRegisterOtpRouteImport } from './routes/_public/auth/register_/otp'
import { Route as PublicAuthRegisterMentorSuccessRouteImport } from './routes/_public/auth/register-mentor_/success'
@@ -80,9 +83,9 @@ const AuthenticatedDashboardRoute = AuthenticatedDashboardRouteImport.update({
} as any)
const AuthenticatedDashboardIndexRoute =
AuthenticatedDashboardIndexRouteImport.update({
id: '/dashboard_/',
path: '/dashboard/',
getParentRoute: () => AuthenticatedRoute,
id: '/',
path: '/',
getParentRoute: () => AuthenticatedDashboardRoute,
} as any)
const SiteProfileIdRoute = SiteProfileIdRouteImport.update({
id: '/profile_/$id',
@@ -138,6 +141,24 @@ const AuthenticatedDashboardSettingsRoute =
path: '/dashboard/settings',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedDashboardRoadmapDiscoveryRoute =
AuthenticatedDashboardRoadmapDiscoveryRouteImport.update({
id: '/roadmap-discovery',
path: '/roadmap-discovery',
getParentRoute: () => AuthenticatedDashboardRoute,
} as any)
const AuthenticatedDashboardMentoringRoute =
AuthenticatedDashboardMentoringRouteImport.update({
id: '/mentoring',
path: '/mentoring',
getParentRoute: () => AuthenticatedDashboardRoute,
} as any)
const AuthenticatedDashboardLearningPathRoute =
AuthenticatedDashboardLearningPathRouteImport.update({
id: '/learning-path',
path: '/learning-path',
getParentRoute: () => AuthenticatedDashboardRoute,
} as any)
const PublicAuthRegisterSuccessRoute =
PublicAuthRegisterSuccessRouteImport.update({
id: '/auth/register_/success',
@@ -174,11 +195,14 @@ const PublicAuthForgotOtpRoute = PublicAuthForgotOtpRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/dashboard': typeof AuthenticatedDashboardRoute
'/dashboard': typeof AuthenticatedDashboardRouteWithChildren
'/articles': typeof SiteArticlesRoute
'/mentoring': typeof SiteMentoringRoute
'/profile': typeof SiteProfileRoute
'/resources': typeof SiteResourcesRoute
'/dashboard/learning-path': typeof AuthenticatedDashboardLearningPathRoute
'/dashboard/mentoring': typeof AuthenticatedDashboardMentoringRoute
'/dashboard/roadmap-discovery': typeof AuthenticatedDashboardRoadmapDiscoveryRoute
'/dashboard/settings': typeof AuthenticatedDashboardSettingsRoute
'/auth/forgot': typeof PublicAuthForgotRoute
'/auth/google-callback': typeof PublicAuthGoogleCallbackRoute
@@ -199,11 +223,13 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/dashboard': typeof AuthenticatedDashboardIndexRoute
'/articles': typeof SiteArticlesRoute
'/mentoring': typeof SiteMentoringRoute
'/profile': typeof SiteProfileRoute
'/resources': typeof SiteResourcesRoute
'/dashboard/learning-path': typeof AuthenticatedDashboardLearningPathRoute
'/dashboard/mentoring': typeof AuthenticatedDashboardMentoringRoute
'/dashboard/roadmap-discovery': typeof AuthenticatedDashboardRoadmapDiscoveryRoute
'/dashboard/settings': typeof AuthenticatedDashboardSettingsRoute
'/auth/forgot': typeof PublicAuthForgotRoute
'/auth/google-callback': typeof PublicAuthGoogleCallbackRoute
@@ -214,6 +240,7 @@ export interface FileRoutesByTo {
'/articles/$slug': typeof SiteArticlesSlugRoute
'/mentoring/$id': typeof SiteMentoringIdRoute
'/profile/$id': typeof SiteProfileIdRoute
'/dashboard': typeof AuthenticatedDashboardIndexRoute
'/auth/forgot/otp': typeof PublicAuthForgotOtpRoute
'/auth/forgot/summon': typeof PublicAuthForgotSummonRoute
'/auth/register-mentor/pending': typeof PublicAuthRegisterMentorPendingRoute
@@ -227,11 +254,14 @@ export interface FileRoutesById {
'/_authenticated': typeof AuthenticatedRouteWithChildren
'/_public': typeof PublicRouteWithChildren
'/_site': typeof SiteRouteWithChildren
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
'/_authenticated/dashboard': typeof AuthenticatedDashboardRouteWithChildren
'/_site/articles': typeof SiteArticlesRoute
'/_site/mentoring': typeof SiteMentoringRoute
'/_site/profile': typeof SiteProfileRoute
'/_site/resources': typeof SiteResourcesRoute
'/_authenticated/dashboard/learning-path': typeof AuthenticatedDashboardLearningPathRoute
'/_authenticated/dashboard/mentoring': typeof AuthenticatedDashboardMentoringRoute
'/_authenticated/dashboard/roadmap-discovery': typeof AuthenticatedDashboardRoadmapDiscoveryRoute
'/_authenticated/dashboard_/settings': typeof AuthenticatedDashboardSettingsRoute
'/_public/auth/forgot': typeof PublicAuthForgotRoute
'/_public/auth/google-callback': typeof PublicAuthGoogleCallbackRoute
@@ -242,7 +272,7 @@ export interface FileRoutesById {
'/_site/articles_/$slug': typeof SiteArticlesSlugRoute
'/_site/mentoring_/$id': typeof SiteMentoringIdRoute
'/_site/profile_/$id': typeof SiteProfileIdRoute
'/_authenticated/dashboard_/': typeof AuthenticatedDashboardIndexRoute
'/_authenticated/dashboard/': typeof AuthenticatedDashboardIndexRoute
'/_public/auth/forgot_/otp': typeof PublicAuthForgotOtpRoute
'/_public/auth/forgot_/summon': typeof PublicAuthForgotSummonRoute
'/_public/auth/register-mentor_/pending': typeof PublicAuthRegisterMentorPendingRoute
@@ -259,6 +289,9 @@ export interface FileRouteTypes {
| '/mentoring'
| '/profile'
| '/resources'
| '/dashboard/learning-path'
| '/dashboard/mentoring'
| '/dashboard/roadmap-discovery'
| '/dashboard/settings'
| '/auth/forgot'
| '/auth/google-callback'
@@ -279,11 +312,13 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/dashboard'
| '/articles'
| '/mentoring'
| '/profile'
| '/resources'
| '/dashboard/learning-path'
| '/dashboard/mentoring'
| '/dashboard/roadmap-discovery'
| '/dashboard/settings'
| '/auth/forgot'
| '/auth/google-callback'
@@ -294,6 +329,7 @@ export interface FileRouteTypes {
| '/articles/$slug'
| '/mentoring/$id'
| '/profile/$id'
| '/dashboard'
| '/auth/forgot/otp'
| '/auth/forgot/summon'
| '/auth/register-mentor/pending'
@@ -311,6 +347,9 @@ export interface FileRouteTypes {
| '/_site/mentoring'
| '/_site/profile'
| '/_site/resources'
| '/_authenticated/dashboard/learning-path'
| '/_authenticated/dashboard/mentoring'
| '/_authenticated/dashboard/roadmap-discovery'
| '/_authenticated/dashboard_/settings'
| '/_public/auth/forgot'
| '/_public/auth/google-callback'
@@ -321,7 +360,7 @@ export interface FileRouteTypes {
| '/_site/articles_/$slug'
| '/_site/mentoring_/$id'
| '/_site/profile_/$id'
| '/_authenticated/dashboard_/'
| '/_authenticated/dashboard/'
| '/_public/auth/forgot_/otp'
| '/_public/auth/forgot_/summon'
| '/_public/auth/register-mentor_/pending'
@@ -402,12 +441,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedDashboardRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/dashboard_/': {
id: '/_authenticated/dashboard_/'
path: '/dashboard'
'/_authenticated/dashboard/': {
id: '/_authenticated/dashboard/'
path: '/'
fullPath: '/dashboard/'
preLoaderRoute: typeof AuthenticatedDashboardIndexRouteImport
parentRoute: typeof AuthenticatedRoute
parentRoute: typeof AuthenticatedDashboardRoute
}
'/_site/profile_/$id': {
id: '/_site/profile_/$id'
@@ -479,6 +518,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedDashboardSettingsRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/dashboard/roadmap-discovery': {
id: '/_authenticated/dashboard/roadmap-discovery'
path: '/roadmap-discovery'
fullPath: '/dashboard/roadmap-discovery'
preLoaderRoute: typeof AuthenticatedDashboardRoadmapDiscoveryRouteImport
parentRoute: typeof AuthenticatedDashboardRoute
}
'/_authenticated/dashboard/mentoring': {
id: '/_authenticated/dashboard/mentoring'
path: '/mentoring'
fullPath: '/dashboard/mentoring'
preLoaderRoute: typeof AuthenticatedDashboardMentoringRouteImport
parentRoute: typeof AuthenticatedDashboardRoute
}
'/_authenticated/dashboard/learning-path': {
id: '/_authenticated/dashboard/learning-path'
path: '/learning-path'
fullPath: '/dashboard/learning-path'
preLoaderRoute: typeof AuthenticatedDashboardLearningPathRouteImport
parentRoute: typeof AuthenticatedDashboardRoute
}
'/_public/auth/register_/success': {
id: '/_public/auth/register_/success'
path: '/auth/register/success'
@@ -524,16 +584,36 @@ declare module '@tanstack/react-router' {
}
}
interface AuthenticatedRouteChildren {
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
AuthenticatedDashboardSettingsRoute: typeof AuthenticatedDashboardSettingsRoute
interface AuthenticatedDashboardRouteChildren {
AuthenticatedDashboardLearningPathRoute: typeof AuthenticatedDashboardLearningPathRoute
AuthenticatedDashboardMentoringRoute: typeof AuthenticatedDashboardMentoringRoute
AuthenticatedDashboardRoadmapDiscoveryRoute: typeof AuthenticatedDashboardRoadmapDiscoveryRoute
AuthenticatedDashboardIndexRoute: typeof AuthenticatedDashboardIndexRoute
}
const AuthenticatedDashboardRouteChildren: AuthenticatedDashboardRouteChildren =
{
AuthenticatedDashboardLearningPathRoute:
AuthenticatedDashboardLearningPathRoute,
AuthenticatedDashboardMentoringRoute: AuthenticatedDashboardMentoringRoute,
AuthenticatedDashboardRoadmapDiscoveryRoute:
AuthenticatedDashboardRoadmapDiscoveryRoute,
AuthenticatedDashboardIndexRoute: AuthenticatedDashboardIndexRoute,
}
const AuthenticatedDashboardRouteWithChildren =
AuthenticatedDashboardRoute._addFileChildren(
AuthenticatedDashboardRouteChildren,
)
interface AuthenticatedRouteChildren {
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRouteWithChildren
AuthenticatedDashboardSettingsRoute: typeof AuthenticatedDashboardSettingsRoute
}
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
AuthenticatedDashboardRoute: AuthenticatedDashboardRouteWithChildren,
AuthenticatedDashboardSettingsRoute: AuthenticatedDashboardSettingsRoute,
AuthenticatedDashboardIndexRoute: AuthenticatedDashboardIndexRoute,
}
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
@@ -3,6 +3,13 @@ import { SessionToken } from '@imphnen-frontend-service/service'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: () => {
// Development-only bypass explicitly requested via environment flags
const bypassAuth = import.meta.env.MODE === 'development' && import.meta.env.VITE_BYPASS_AUTH_MIDDLEWARE === 'true'
if (bypassAuth) {
return
}
const session = SessionToken.get()
if (!session?.token?.access_token) {
throw redirect({ to: '/auth/login' })
@@ -1,65 +1,182 @@
import { createFileRoute, Outlet, Link, useLocation, useNavigate } from '@tanstack/react-router'
import { useAuthStore } from '@imphnen-frontend-service/service'
import { Icon } from '@iconify/react'
import { resolvePersona } from './dashboard/_data/persona-resolver'
const navItems = [
{ path: '/dashboard', label: 'Dashboard', icon: 'mdi:view-dashboard' },
{ path: '/dashboard/settings', label: 'Settings', icon: 'mdi:cog' },
]
interface NavItem {
path?: string;
label: string;
icon: string;
isLink?: boolean;
}
/**
* Get navigation items based on persona.
* Only items with isLink=true will use Link component; others render as non-navigating buttons.
*/
function getNavItems(persona: 'user' | 'mentor'): NavItem[] {
if (persona === 'mentor') {
return [
{ path: '/dashboard', label: 'Dashboard', icon: 'mdi:view-dashboard-outline', isLink: true },
{ path: '/dashboard/mentoring-setup', label: 'Mentoring Setup', icon: 'mdi:cog-outline', isLink: false },
{ path: '/dashboard/list-mentee', label: 'List Mentee', icon: 'mdi:account-group-outline', isLink: false },
{ path: '/dashboard/feedback', label: 'Feedback', icon: 'mdi:message-reply-text-outline', isLink: false },
];
}
return [
{ path: '/dashboard', label: 'Dashboard', icon: 'mdi:view-dashboard-outline', isLink: true },
{ path: '/dashboard/roadmap-discovery', label: 'Roadmap Discovery', icon: 'mdi:map-marker-path', isLink: true },
{ path: '/dashboard/learning-path', label: 'Learning Path', icon: 'mdi:book-open-page-variant-outline', isLink: true },
{ path: '/dashboard/mentoring', label: 'Mentoring', icon: 'mdi:video-outline', isLink: true },
];
}
export const Route = createFileRoute('/_authenticated/dashboard')({
component: DashboardLayout,
})
/**
* Header component for the dashboard, containing the app brand and user profile.
*/
function HeaderDashboard({ persona, user, onLogout }: { persona: 'user' | 'mentor', user: any, onLogout: () => void }) {
return (
<header className="h-[58px] w-[972px] mx-auto mt-[52px] mb-[62px] bg-white rounded-sm shadow-sm flex items-center justify-between px-5">
<Link to="/dashboard" className="text-[19px] font-semibold text-primary-accent">
Dimentorin.dev
</Link>
<div className="flex items-center gap-4">
<button className="w-7 h-7 rounded-sm bg-white text-neutral-600 flex items-center justify-center cursor-pointer">
<Icon icon="lucide:bell" width="16" />
</button>
<button className="w-7 h-7 rounded-sm bg-white text-neutral-600 flex items-center justify-center cursor-pointer">
<Icon icon="lucide:search" width="16" />
</button>
<button className="h-[42px] flex items-center gap-3 pl-2.5 cursor-pointer border-none bg-transparent">
<div className="flex flex-col items-end text-right">
<span className="text-xs font-medium text-neutral-600">{user?.fullname || 'User'}</span>
<span className="text-[10px] font-medium text-neutral-600">{persona === 'mentor' ? 'Mentor' : 'Mentee'}</span>
</div>
<div
className="w-7 h-7 rounded-full bg-bg-placeholder bg-cover bg-center"
style={user?.avatar ? { backgroundImage: `url(${user.avatar})` } : {}}
/>
</button>
</div>
</header>
);
}
/**
* Dashboard layout shell with persona-aware sidebar navigation.
* Renders navigation and outlet for nested routes.
*/
function DashboardLayout() {
const { session, clearSession } = useAuthStore()
const location = useLocation()
const navigate = useNavigate()
const searchParams = new URLSearchParams(location.search);
const persona = resolvePersona(session?.user, searchParams);
const navItems = getNavItems(persona);
const handleLogout = () => {
clearSession();
navigate({ to: '/auth/login' });
};
const isNavItemActive = (path?: string) => {
if (!path) return false;
// Dashboard should only be active on the dashboard index route.
if (path === '/dashboard') {
return location.pathname === '/dashboard' || location.pathname === '/dashboard/';
}
return location.pathname.startsWith(path);
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<nav className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-6">
<Link to="/" className="text-xl font-bold text-primary-600 dark:text-primary-400">
Dimentorin
</Link>
<div className="hidden md:flex gap-1">
{navItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={`px-3 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-colors ${
location.pathname === item.path
? 'bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
<Icon icon={item.icon} width="18" />
{item.label}
</Link>
))}
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:block">
{session?.user?.fullname || session?.user?.email}
</span>
<button
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" />
<span className="hidden sm:inline">Logout</span>
</button>
</div>
<div className="min-h-screen bg-bg-light-blue flex">
{/* Sidebar Navigation */}
<aside className="w-[228px] bg-white flex flex-col sticky top-0 h-screen z-100">
{/* Logo */}
<div className="pt-[60px] px-6 pb-8">
<div className="h-12 flex items-center justify-center">
<img
src="/logos/logo.svg"
alt="Dimentorin"
style={{ width: '128px', height: '48px', objectFit: 'contain' }}
/>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Outlet />
</main>
{/* Navigation Items */}
<nav className="flex-1 px-6 flex flex-col gap-2">
{navItems.map((item) => {
const isActive = item.isLink ? isNavItemActive(item.path) : false;
const baseClasses = 'h-8 px-3 rounded-sm flex items-center gap-3 cursor-pointer text-xs font-medium leading-[1.3] text-text-muted transition-all duration-200 hover:bg-bg-hover';
const activeClasses = isActive ? 'bg-primary-accent text-white' : '';
if (item.isLink && item.path) {
return (
<Link
key={item.path}
to={item.path}
className={`${baseClasses} ${activeClasses}`}
>
<Icon
icon={item.icon}
width="16"
className={isActive ? 'text-white' : 'text-text-muted'}
style={isActive ? { color: '#ffffff' } : {}}
/>
{item.label}
</Link>
);
}
return (
<button
key={item.label}
className={baseClasses}
type="button"
disabled
>
<Icon icon={item.icon} width="16" />
{item.label}
</button>
);
})}
</nav>
{/* Sidebar Footer */}
<div className="flex flex-col pt-4 px-6 pb-[34px]">
<div className="h-px bg-border-light mb-4" />
<button
onClick={handleLogout}
className="h-8 px-3 rounded-sm flex items-center gap-3 cursor-pointer text-xs font-medium leading-[1.3] text-text-muted transition-all duration-200 hover:bg-bg-hover"
>
<Icon icon="mdi:logout" width="16" />
Log Out
</button>
</div>
</aside>
{/* Main Content Area */}
<div className="flex-1 flex flex-col min-w-0">
<HeaderDashboard
persona={persona}
user={session?.user}
onLogout={handleLogout}
/>
<main className="w-[1052px] mx-auto px-10 pt-6 pb-10">
<Outlet />
</main>
</div>
</div>
)
}
@@ -0,0 +1,284 @@
import { describe, it, expect } from 'vitest';
import {
getUserMockDashboardData,
getMentorMockDashboardData,
mockUserDashboardData,
mockMentorDashboardData,
} from './dashboard-mock';
import { resolvePersona, parseSearchParams } from '../persona-resolver';
import type { TUserItem } from '@imphnen-frontend-service/service';
describe('Dashboard Mock Data', () => {
describe('getUserMockDashboardData', () => {
it('should return user dashboard data with correct structure', () => {
const data = getUserMockDashboardData();
expect(data).toHaveProperty('mentoringSessions');
expect(data).toHaveProperty('articleSubmitted');
expect(data).toHaveProperty('articlePublished');
expect(data).toHaveProperty('roadmap');
expect(data).toHaveProperty('articles');
});
it('should include numeric metrics', () => {
const data = getUserMockDashboardData();
expect(typeof data.mentoringSessions).toBe('number');
expect(typeof data.articleSubmitted).toBe('number');
expect(typeof data.articlePublished).toBe('number');
});
it('should include roadmap items with required fields', () => {
const data = getUserMockDashboardData();
expect(data.roadmap.length).toBeGreaterThan(0);
data.roadmap.forEach((item) => {
expect(item).toHaveProperty('id');
expect(item).toHaveProperty('name');
expect(item).toHaveProperty('completionPercentage');
expect(item).toHaveProperty('durationDays');
expect(typeof item.completionPercentage).toBe('number');
});
});
it('should include articles with required fields', () => {
const data = getUserMockDashboardData();
expect(data.articles.length).toBeGreaterThan(0);
data.articles.forEach((article) => {
expect(article).toHaveProperty('id');
expect(article).toHaveProperty('no');
expect(article).toHaveProperty('judul');
expect(article).toHaveProperty('materi');
expect(article).toHaveProperty('status');
expect(article).toHaveProperty('submitDate');
});
});
it('should have sample row matching spec', () => {
const data = getUserMockDashboardData();
const firstArticle = data.articles[0];
expect(firstArticle.judul).toContain('How to install linux dist');
expect(firstArticle.materi).toBe('Day 1');
});
});
describe('getMentorMockDashboardData', () => {
it('should return mentor dashboard data with correct structure', () => {
const data = getMentorMockDashboardData();
expect(data).toHaveProperty('rating');
expect(data).toHaveProperty('sessionComplete');
expect(data).toHaveProperty('menteeImpacted');
expect(data).toHaveProperty('totalFeedback');
expect(data).toHaveProperty('topics');
expect(data).toHaveProperty('mentoringSetup');
expect(data).toHaveProperty('payments');
});
it('should include numeric metrics in valid ranges', () => {
const data = getMentorMockDashboardData();
expect(data.rating).toBeGreaterThanOrEqual(0);
expect(data.rating).toBeLessThanOrEqual(5);
expect(data.sessionComplete).toBeGreaterThanOrEqual(0);
expect(data.menteeImpacted).toBeGreaterThanOrEqual(0);
expect(data.totalFeedback).toBeGreaterThanOrEqual(0);
});
it('should include topic chips', () => {
const data = getMentorMockDashboardData();
expect(data.topics.length).toBeGreaterThan(0);
data.topics.forEach((topic) => {
expect(topic).toHaveProperty('id');
expect(topic).toHaveProperty('label');
expect(typeof topic.label).toBe('string');
});
});
it('should include mentoring setup config', () => {
const data = getMentorMockDashboardData();
const setup = data.mentoringSetup;
expect(setup).toHaveProperty('sessionRate');
expect(setup).toHaveProperty('availability');
expect(setup).toHaveProperty('expertise');
expect(setup).toHaveProperty('experienceLevel');
expect(setup).toHaveProperty('status');
expect(['Incomplete', 'Complete']).toContain(setup.status);
});
it('should include payments with required fields', () => {
const data = getMentorMockDashboardData();
expect(data.payments.length).toBeGreaterThan(0);
data.payments.forEach((payment) => {
expect(payment).toHaveProperty('id');
expect(payment).toHaveProperty('no');
expect(payment).toHaveProperty('tanggalMentoring');
expect(payment).toHaveProperty('sesi');
expect(payment).toHaveProperty('namaMentee');
expect(payment).toHaveProperty('jumlah');
});
});
it('should have sample payment row matching spec', () => {
const data = getMentorMockDashboardData();
const firstPayment = data.payments[0];
expect(firstPayment.no).toBe(1);
expect(firstPayment.tanggalMentoring).toBe('28-01-2025');
expect(firstPayment.sesi).toBe('Senin, 19:00 - 19:45');
expect(firstPayment.namaMentee).toBe('Firdaus Wijaya');
expect(firstPayment.jumlah).toBe('Rp.100.000');
});
it('should sort payments by date descending (newest first)', () => {
const data = getMentorMockDashboardData();
for (let i = 0; i < data.payments.length - 1; i++) {
const current = new Date(data.payments[i].tanggalMentoring.split('-').reverse().join('-'));
const next = new Date(data.payments[i + 1].tanggalMentoring.split('-').reverse().join('-'));
expect(current.getTime()).toBeGreaterThanOrEqual(next.getTime());
}
});
});
describe('Mock data completeness', () => {
it('user dashboard should have consistent data', () => {
const data = mockUserDashboardData;
expect(data.articles.length).toBeGreaterThan(0);
expect(data.roadmap.length).toBeGreaterThan(0);
});
it('mentor dashboard should have consistent data', () => {
const data = mockMentorDashboardData;
expect(data.payments.length).toBeGreaterThan(0);
expect(data.topics.length).toBeGreaterThan(0);
});
});
});
describe('Persona Resolver', () => {
describe('resolvePersona', () => {
it('should respect query parameter override to mentor', () => {
const user: TUserItem = {
id: '1',
email: 'user@example.com',
fullname: 'Test User',
is_active: true,
role: { id: 'role-1', name: 'user', permissions: [] },
};
const searchParams = new URLSearchParams('persona=mentor');
const persona = resolvePersona(user, searchParams);
expect(persona).toBe('mentor');
});
it('should respect query parameter override to user', () => {
const user: TUserItem = {
id: '1',
email: 'mentor@example.com',
fullname: 'Test Mentor',
is_active: true,
role: { id: 'role-2', name: 'mentor', permissions: [] },
};
const searchParams = new URLSearchParams('persona=user');
const persona = resolvePersona(user, searchParams);
expect(persona).toBe('user');
});
it('should derive mentor from role name', () => {
const user: TUserItem = {
id: '1',
email: 'mentor@example.com',
fullname: 'Test Mentor',
is_active: true,
role: { id: 'role-2', name: 'mentor', permissions: [] },
};
const persona = resolvePersona(user);
expect(persona).toBe('mentor');
});
it('should handle case-insensitive role name', () => {
const user: TUserItem = {
id: '1',
email: 'mentor@example.com',
fullname: 'Test Mentor',
is_active: true,
role: { id: 'role-2', name: 'MENTOR', permissions: [] },
};
const persona = resolvePersona(user);
expect(persona).toBe('mentor');
});
it('should return user for non-mentor role', () => {
const user: TUserItem = {
id: '1',
email: 'user@example.com',
fullname: 'Test User',
is_active: true,
role: { id: 'role-1', name: 'user', permissions: [] },
};
const persona = resolvePersona(user);
expect(persona).toBe('user');
});
it('should default to user when user is undefined', () => {
const persona = resolvePersona(undefined);
expect(persona).toBe('user');
});
it('should ignore invalid query parameters', () => {
const user: TUserItem = {
id: '1',
email: 'user@example.com',
fullname: 'Test User',
is_active: true,
role: { id: 'role-1', name: 'user', permissions: [] },
};
const searchParams = new URLSearchParams('persona=invalid');
const persona = resolvePersona(user, searchParams);
expect(persona).toBe('user');
});
it('should ignore missing query parameter', () => {
const user: TUserItem = {
id: '1',
email: 'user@example.com',
fullname: 'Test User',
is_active: true,
role: { id: 'role-1', name: 'user', permissions: [] },
};
const searchParams = new URLSearchParams('other=value');
const persona = resolvePersona(user, searchParams);
expect(persona).toBe('user');
});
});
describe('parseSearchParams', () => {
it('should parse query string correctly', () => {
const result = parseSearchParams('?persona=mentor');
expect(result.get('persona')).toBe('mentor');
});
it('should handle multiple parameters', () => {
const result = parseSearchParams('?persona=user&other=value');
expect(result.get('persona')).toBe('user');
expect(result.get('other')).toBe('value');
});
it('should handle empty string', () => {
const result = parseSearchParams('');
expect(result.get('persona')).toBeNull();
});
});
});
@@ -0,0 +1,174 @@
import type {
UserDashboardData,
MentorDashboardData,
MentorPaymentRecord,
MentorTopicChip,
} from './types';
/**
* Mock data for user dashboard.
* Used during development before API integration.
*/
export const mockUserDashboardData: UserDashboardData = {
mentoringSessions: 0,
articleSubmitted: 0,
articlePublished: 0,
roadmap: [
{
id: 'roadmap-1',
name: 'Front End Basic',
completionPercentage: 50,
durationDays: 30,
},
{
id: 'roadmap-2',
name: 'React Fundamentals',
completionPercentage: 25,
durationDays: 21,
},
{
id: 'roadmap-3',
name: 'TypeScript Essentials',
completionPercentage: 10,
durationDays: 14,
},
],
articles: [
{
id: 'article-1',
no: 1,
judul: 'How to install linux dist',
materi: 'Day 1',
status: 'Published',
submitDate: '2025-04-10',
},
{
id: 'article-2',
no: 2,
judul: 'Mastering CSS Grid Layout',
materi: 'Day 2',
status: 'Published',
submitDate: '2025-04-08',
},
{
id: 'article-3',
no: 3,
judul: 'React Hooks Deep Dive',
materi: 'Day 5',
status: 'Submitted',
submitDate: '2025-04-05',
},
{
id: 'article-4',
no: 4,
judul: 'Understanding Async/Await',
materi: 'Day 3',
status: 'Draft',
submitDate: '2025-04-03',
},
{
id: 'article-5',
no: 5,
judul: 'TypeScript Advanced Types',
materi: 'Day 7',
status: 'Rejected',
submitDate: '2025-04-01',
},
],
};
/**
* Mock mentor topics for chips display.
*/
const mockMentorTopics: MentorTopicChip[] = [
{ id: 'topic-1', label: 'Basic IT' },
{ id: 'topic-2', label: 'Career & Self Development' },
{ id: 'topic-3', label: 'PM & IT Tools' },
{ id: 'topic-4', label: 'Programming' },
{ id: 'topic-5', label: 'Industry Insight' },
{ id: 'topic-6', label: 'AI Tips' },
{ id: 'topic-7', label: 'Data & Database' },
];
/**
* Mock payments history for mentor dashboard.
* Sorted by tanggalMentoring in descending order (newest first).
*/
const mockMentorPayments: MentorPaymentRecord[] = [
{
id: 'payment-1',
no: 1,
tanggalMentoring: '28-01-2025',
sesi: 'Senin, 19:00 - 19:45',
namaMentee: 'Firdaus Wijaya',
jumlah: 'Rp.100.000',
},
{
id: 'payment-2',
no: 2,
tanggalMentoring: '27-01-2025',
sesi: 'Minggu, 14:00 - 14:45',
namaMentee: 'Ahmad Rizki',
jumlah: 'Rp.100.000',
},
{
id: 'payment-3',
no: 3,
tanggalMentoring: '25-01-2025',
sesi: 'Jumat, 19:00 - 19:45',
namaMentee: 'Siti Nurhaliza',
jumlah: 'Rp.150.000',
},
{
id: 'payment-4',
no: 4,
tanggalMentoring: '24-01-2025',
sesi: 'Kamis, 18:00 - 18:45',
namaMentee: 'Budi Santoso',
jumlah: 'Rp.100.000',
},
{
id: 'payment-5',
no: 5,
tanggalMentoring: '22-01-2025',
sesi: 'Selasa, 19:00 - 19:45',
namaMentee: 'Eka Suryanto',
jumlah: 'Rp.120.000',
},
];
/**
* Mock data for mentor dashboard.
* Used during development before API integration.
*/
export const mockMentorDashboardData: MentorDashboardData = {
rating: 0,
sessionComplete: 0,
menteeImpacted: 0,
totalFeedback: 0,
topics: mockMentorTopics,
mentoringSetup: {
sessionRate: 'Rp.100.000/sesi',
availability: 'Senin-Jumat, 19:00-21:00',
expertise: ['JavaScript', 'React', 'TypeScript', 'Backend', 'System Design'],
experienceLevel: 'Senior',
status: 'Complete',
},
payments: mockMentorPayments,
};
/**
* Get mock user dashboard data.
* Can be extended to support filtering/pagination.
*/
export function getUserMockDashboardData(): UserDashboardData {
return mockUserDashboardData;
}
/**
* Get mock mentor dashboard data.
* Can be extended to support filtering/pagination.
*/
export function getMentorMockDashboardData(): MentorDashboardData {
return mockMentorDashboardData;
}
@@ -0,0 +1,57 @@
/** User Dashboard Mock Data Types */
export interface UserArticle {
id: string;
no: number;
judul: string;
materi: string;
status: 'Draft' | 'Submitted' | 'Published' | 'Rejected';
submitDate: string;
}
export interface UserRoadmapItem {
id: string;
name: string;
completionPercentage: number;
durationDays: number;
}
export interface UserDashboardData {
mentoringSessions: number;
articleSubmitted: number;
articlePublished: number;
roadmap: UserRoadmapItem[];
articles: UserArticle[];
}
/** Mentor Dashboard Mock Data Types */
export interface MentorPaymentRecord {
id: string;
no: number;
tanggalMentoring: string; // DD-MM-YYYY
sesi: string; // Hari, HH:MM - HH:MM
namaMentee: string;
jumlah: string; // Rp.###.###
}
export interface MentorMentoringSetup {
sessionRate: string; // e.g., "Rp.100.000/sesi"
availability: string; // e.g., "Senin-Jumat, 19:00-21:00"
expertise: string[];
experienceLevel: string; // e.g., "Senior"
status: 'Incomplete' | 'Complete';
}
export interface MentorTopicChip {
id: string;
label: string;
}
export interface MentorDashboardData {
rating: number; // 0-5
sessionComplete: number;
menteeImpacted: number;
totalFeedback: number;
topics: MentorTopicChip[];
mentoringSetup: MentorMentoringSetup;
payments: MentorPaymentRecord[];
}
@@ -0,0 +1,53 @@
import type { TUserItem } from '@imphnen-frontend-service/service';
/**
* Persona type for dashboard rendering.
*/
export type Persona = 'user' | 'mentor';
/**
* Resolves the persona for the dashboard based on authentication and query parameters.
*
* Resolution priority:
* 1. Query parameter `?persona=user|mentor` (explicit override)
* 2. User role name contains 'mentor' (case-insensitive)
* 3. Fallback to 'user' persona
*
* @param user - The authenticated user object
* @param searchParams - URL search parameters
* @returns The resolved persona
*/
export function resolvePersona(
user: TUserItem | undefined,
searchParams?: URLSearchParams
): Persona {
// Check for explicit persona query parameter
if (searchParams) {
const param = searchParams.get('persona');
if (param === 'mentor' || param === 'user') {
return param;
}
}
// Derive from user role name
if (user?.role?.name) {
const roleName = user.role.name.toLocaleLowerCase();
if (roleName.includes('mentor')) {
return 'mentor';
}
}
// Default to user persona
return 'user';
}
/**
* Parses search parameters from a URL search string.
* Utility for testing and usage without URLSearchParams API.
*
* @param search - URL search string (e.g., "?persona=mentor")
* @returns URLSearchParams instance
*/
export function parseSearchParams(search: string): URLSearchParams {
return new URLSearchParams(search);
}
@@ -0,0 +1,28 @@
import { createFileRoute, useLocation } from '@tanstack/react-router'
import { useAuthStore } from '@imphnen-frontend-service/service'
import { resolvePersona } from '../dashboard/_data/persona-resolver'
import { UserDashboard } from '../dashboard_/_components/user/user-dashboard'
import { MentorDashboard } from '../dashboard_/_components/mentor/mentor-dashboard'
export const Route = createFileRoute('/_authenticated/dashboard/')({
component: DashboardIndexPage,
})
/**
* Dashboard Index Page
* Routes to either user or mentor dashboard based on persona resolution.
* Persona resolved via query parameter or user role.
*/
function DashboardIndexPage() {
const { session } = useAuthStore()
const location = useLocation()
const searchParams = new URLSearchParams(location.search);
const persona = resolvePersona(session?.user, searchParams);
return (
<div>
{persona === 'mentor' ? <MentorDashboard /> : <UserDashboard />}
</div>
);
}
@@ -0,0 +1,157 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
export const Route = createFileRoute('/_authenticated/dashboard/learning-path')({
component: LearningPathPage,
})
function LearningPathPage() {
const [activeTab, setActiveTab] = useState<'roadmap' | 'article'>('roadmap')
const [isSubmitArticlePopupOpen, setIsSubmitArticlePopupOpen] = useState(false)
return (
<section className="w-[972px]">
<div className="mb-6 inline-flex items-center gap-2 rounded-sm bg-white p-1 shadow-sm">
<button
type="button"
onClick={() => setActiveTab('roadmap')}
className={`h-8 px-4 rounded-sm text-xs font-semibold transition-colors cursor-pointer ${
activeTab === 'roadmap' ? 'bg-primary-accent text-white' : 'text-text-label hover:bg-primary-50'
}`}
>
Roadmap
</button>
<button
type="button"
onClick={() => setActiveTab('article')}
className={`h-8 px-4 rounded-sm text-xs font-semibold transition-colors cursor-pointer ${
activeTab === 'article' ? 'bg-primary-accent text-white' : 'text-text-label hover:bg-primary-50'
}`}
>
Article
</button>
</div>
{activeTab === 'roadmap' && (
<div className="w-full bg-white rounded-sm shadow-sm p-8">
<h2 className="text-[19px] font-semibold text-text-label mb-2">Roadmap Kamu</h2>
<p className="text-[15px] font-medium text-primary-accent mb-6">Front-end Basic</p>
<div className="space-y-4">
<article className="border border-border-light rounded-sm p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[15px] font-semibold text-text-label">Day 1 - Materi A</h3>
<span className="text-xs text-text-muted">1 / 3 diselesaikan</span>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between rounded-sm bg-primary-50 px-3 py-2">
<span className="text-xs text-text-label">1. Submateri 1</span>
<span className="text-[10px] font-semibold text-success-600">Done</span>
</div>
<div className="flex items-center justify-between rounded-sm bg-primary-50 px-3 py-2">
<span className="text-xs text-text-label">2. Submateri 2</span>
<span className="text-[10px] font-semibold text-primary-accent">To do</span>
</div>
<div className="flex items-center justify-between rounded-sm bg-primary-50 px-3 py-2">
<span className="text-xs text-text-label">3. Tugas : Membuat Artikel</span>
<span className="text-[10px] font-semibold text-primary-accent">To do</span>
</div>
</div>
</article>
{['Day 2 - Materi B', 'Day 3 - Materi C', 'Day 4 - Materi D', 'Day 5 - Materi E'].map((day) => (
<article key={day} className="border border-border-light rounded-sm p-4 flex items-center justify-between">
<h3 className="text-[15px] font-semibold text-text-label">{day}</h3>
<span className="text-xs text-text-muted">Selesaikan materi sebelumnya</span>
</article>
))}
</div>
</div>
)}
{activeTab === 'article' && (
<div className="w-full bg-white rounded-sm shadow-sm p-6">
<div className="mb-4">
<input
type="text"
placeholder="Cari berdasarkan nama item"
className="w-[320px] h-[34px] border border-border-light rounded-sm px-3 text-[15px] text-text-label placeholder:text-placeholder focus:outline-none focus:border-primary-accent"
/>
</div>
<div className="overflow-hidden rounded-sm border border-border-light">
<table className="w-full border-collapse">
<thead>
<tr className="bg-primary-50">
<th className="text-left text-xs font-semibold text-text-label px-4 py-3">No.</th>
<th className="text-left text-xs font-semibold text-text-label px-4 py-3">Judul Artikel</th>
<th className="text-left text-xs font-semibold text-text-label px-4 py-3">Materi</th>
<th className="text-left text-xs font-semibold text-text-label px-4 py-3">Status</th>
<th className="text-left text-xs font-semibold text-text-label px-4 py-3">Submit Date</th>
<th className="text-left text-xs font-semibold text-text-label px-4 py-3">Action</th>
</tr>
</thead>
<tbody>
<tr className="border-t border-neutral-100">
<td className="text-xs text-text-muted px-4 py-3">1</td>
<td className="text-xs text-text-muted px-4 py-3">How to install linux dist..</td>
<td className="text-xs text-text-muted px-4 py-3">Day 1</td>
<td className="px-4 py-3"><span className="text-[10px] font-semibold text-success-600">Done</span></td>
<td className="text-xs text-text-muted px-4 py-3">22 Maret 2025, 20:30 WIB</td>
<td className="px-4 py-3">
<button className="h-7 px-2 rounded-sm border border-border-light text-text-label text-[10px] font-semibold cursor-pointer">View</button>
</td>
</tr>
<tr className="border-t border-neutral-100">
<td className="text-xs text-text-muted px-4 py-3">2.</td>
<td className="text-xs text-text-muted px-4 py-3">How to install linux dist..</td>
<td className="text-xs text-text-muted px-4 py-3">Day 2</td>
<td className="px-4 py-3"><span className="text-[10px] font-semibold text-primary-accent">On Progress</span></td>
<td className="text-xs text-text-muted px-4 py-3">-</td>
<td className="px-4 py-3">
<button
onClick={() => setIsSubmitArticlePopupOpen(true)}
className="h-7 px-2 rounded-sm border border-primary-accent text-primary-accent text-[10px] font-semibold cursor-pointer"
>
Submit
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
)}
{isSubmitArticlePopupOpen && (
<div className="fixed inset-0 z-50 bg-black/30 flex items-center justify-center p-4">
<div className="w-[400px] h-[288px] rounded-[8px] bg-white px-10 py-10">
<div className="w-[320px] mx-auto text-center">
<h3 className="text-[23px] font-semibold text-[#23a1eb]">Apakah Kamu Sudah Yakin?</h3>
<p className="text-[15px] text-[#888888] mt-8">
Pastikan isi artikel sudah sesuai dengan ketentuan^^, artikel yang sudah disubmit tidak dapat diedit
</p>
</div>
<div className="mt-8 flex items-center gap-4">
<button
onClick={() => setIsSubmitArticlePopupOpen(false)}
className="w-[152px] h-[34px] rounded-sm bg-white text-[#23a1eb] text-[15px] font-semibold cursor-pointer"
>
Nanti Deh
</button>
<button
onClick={() => setIsSubmitArticlePopupOpen(false)}
className="w-[152px] h-[34px] rounded-sm bg-[#23a1eb] text-[#f6f6f6] text-[15px] font-semibold cursor-pointer"
>
Sumbit
</button>
</div>
</div>
</div>
)}
</section>
)
}
@@ -0,0 +1,339 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
export const Route = createFileRoute('/_authenticated/dashboard/mentoring')({
component: MentoringPage,
})
function MentoringPage() {
const [activeModal, setActiveModal] = useState<null | 'detail' | 'contact' | 'feedback-1' | 'feedback-2'>(null)
const mentoringRows = useMemo(
() =>
Array.from({ length: 12 }, (_, idx) => ({
no: idx + 1,
mentorName: 'Muhammad Firdaus Oi...',
topic: 'Basic IT, Industry Ins...',
sessionTime: '22 Maret 2025, 20:00 - 20:30 WIB',
status: idx % 3 === 0 ? 'Done' : 'To do',
})),
[],
)
const rowsPerPage = 10
const [currentPage, setCurrentPage] = useState(1)
const totalPages = Math.max(1, Math.ceil(mentoringRows.length / rowsPerPage))
const pagedRows = useMemo(() => {
const start = (currentPage - 1) * rowsPerPage
return mentoringRows.slice(start, start + rowsPerPage)
}, [currentPage, mentoringRows])
const goToPage = (page: number) => {
if (page < 1 || page > totalPages) return
setCurrentPage(page)
}
return (
<section className="w-[972px]">
<div className="w-full bg-white rounded-sm shadow-sm p-6">
<div className="mb-4">
<input
type="text"
placeholder="Cari berdasarkan nama item"
className="w-[320px] h-[34px] border border-border-light rounded-sm px-3 text-[15px] text-text-label placeholder:text-placeholder focus:outline-none focus:border-primary-accent"
/>
</div>
<div className="overflow-hidden rounded-sm border border-border-light">
<table className="w-full border-collapse">
<thead>
<tr className="bg-primary-50">
<th className="text-left text-xs font-semibold text-text-label px-4 py-3">No.</th>
<th className="text-left text-xs font-semibold text-text-label px-4 py-3">Nama Mentor</th>
<th className="text-left text-xs font-semibold text-text-label px-4 py-3">Topik</th>
<th className="text-left text-xs font-semibold text-text-label px-4 py-3">Sesi Mentoring</th>
<th className="text-left text-xs font-semibold text-text-label px-4 py-3">Status</th>
<th className="text-left text-xs font-semibold text-text-label px-4 py-3">Action</th>
</tr>
</thead>
<tbody>
{pagedRows.map((row) => (
<tr key={row.no} className={`border-t border-neutral-100 ${row.no % 2 === 0 ? 'bg-primary-50' : 'bg-white'}`}>
<td className="text-xs text-text-muted px-4 py-3">{row.no}</td>
<td className="text-xs text-text-muted px-4 py-3">{row.mentorName}</td>
<td className="text-xs text-text-muted px-4 py-3">{row.topic}</td>
<td className="text-xs text-text-muted px-4 py-3">{row.sessionTime}</td>
<td className="px-4 py-3">
<span className={`text-[10px] font-semibold ${row.status === 'Done' ? 'text-success-600' : 'text-primary-accent'}`}>
{row.status}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{row.status === 'Done' ? (
<button
onClick={() => setActiveModal('feedback-1')}
className="h-7 w-[175px] rounded-sm bg-[#23a1eb] text-[#f6f6f6] text-[10px] font-medium cursor-pointer"
>
Kirim Feedback
</button>
) : (
<>
<button
onClick={() => setActiveModal('detail')}
className="h-7 w-[84px] rounded-sm bg-[#23a1eb] text-[#f6f6f6] text-[10px] font-medium cursor-pointer"
>
Cek Detail
</button>
<button
onClick={() => setActiveModal('contact')}
className="h-7 w-[84px] rounded-sm bg-[#ffe8da] text-[#ff5242] text-[10px] font-medium cursor-pointer"
>
Cancel
</button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-4 flex items-center justify-between">
<p className="text-xs text-text-muted">
Menampilkan {(currentPage - 1) * rowsPerPage + 1} - {Math.min(currentPage * rowsPerPage, mentoringRows.length)} dari {mentoringRows.length}
</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
className="h-7 px-2 rounded-sm border border-border-light text-[10px] font-semibold text-text-label cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
{Array.from({ length: totalPages }, (_, idx) => {
const page = idx + 1
const isActive = page === currentPage
return (
<button
key={page}
type="button"
onClick={() => goToPage(page)}
className={`h-7 min-w-7 px-2 rounded-sm border text-[10px] font-semibold cursor-pointer ${
isActive
? 'border-primary-accent bg-primary-accent text-white'
: 'border-border-light text-text-label'
}`}
>
{page}
</button>
)
})}
<button
type="button"
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
className="h-7 px-2 rounded-sm border border-border-light text-[10px] font-semibold text-text-label cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
</div>
{activeModal !== null && (
<div className="fixed inset-0 z-50 bg-black/30 flex items-center justify-center p-4">
{activeModal === 'detail' && (
<div className="w-[800px] h-[732px] rounded-[8px] bg-white p-12 overflow-auto">
<div className="flex items-center justify-between mb-8">
<h2 className="text-[23px] font-semibold text-[#23a1eb]">Detail Sesi Mentoring</h2>
<button className="text-[#888888] text-xl cursor-pointer" onClick={() => setActiveModal(null)}>
x
</button>
</div>
<div className="grid grid-cols-[306px_1fr] gap-8">
<div>
<h3 className="text-[23px] font-semibold text-[#23a1eb] mb-8">Your Senpai</h3>
<div className="text-center">
<img src="/image/mascot-character.webp" alt="Mentor" className="w-[180px] h-[217px] object-cover mx-auto mb-8" />
<p className="text-[19px] font-semibold text-[#454545] leading-tight">
Muhammad
<br />
Firdaus Oi Oi Oi, S.H., M.H.
</p>
<p className="text-[15px] text-[#6d6d6d] mt-2">UI Designer at Oray orayan Studios</p>
</div>
</div>
<div>
<p className="text-[15px] font-medium text-[#454545] mb-3">Topics</p>
<div className="flex items-center gap-2 mb-6">
<span className="h-7 px-4 rounded-full bg-primary-50 text-[10px] font-medium text-[#6d6d6d] inline-flex items-center">
Industry Insight
</span>
<span className="h-7 px-4 rounded-full bg-primary-50 text-[10px] font-medium text-[#6d6d6d] inline-flex items-center">
Basic IT
</span>
</div>
<div className="space-y-4 mb-4">
<div>
<p className="text-[15px] font-medium text-[#454545] mb-2">Tanggal</p>
<input readOnly value="22 Maret 2025" className="w-full h-[34px] rounded-sm border border-[#d1d1d1] px-5 text-[15px] text-[#6d6d6d]" />
</div>
<div>
<p className="text-[15px] font-medium text-[#454545] mb-2">Waktu</p>
<input readOnly value="20:00 - 20:30" className="w-full h-[34px] rounded-sm border border-[#d1d1d1] px-5 text-[15px] text-[#6d6d6d]" />
</div>
<div>
<p className="text-[15px] font-medium text-[#454545] mb-2">Lokasi</p>
<input readOnly value="Online" className="w-full h-[34px] rounded-sm border border-[#d1d1d1] px-5 text-[15px] text-[#6d6d6d]" />
</div>
</div>
<div className="mb-6">
<p className="text-[15px] font-medium text-[#454545] mb-2">Pertanyaan Untuk Senpai</p>
<textarea
readOnly
value={'Hi [Nama Mentor], Saya [Nama Kamu] & saya berharap dapat memiliki sesi mentoring dengan Anda.\n\nSaat ini, saya tertarik untuk mengejar __. Tujuan saya untuk sesi ini adalah __.\n\nSaya ingin tahu secara khusus tentang ___.\n1. Pertanyaan Anda\n2. ...\n3. ...'}
className="w-full h-[156px] rounded-sm border border-[#d1d1d1] px-3 py-2 text-xs text-[#6d6d6d] resize-none"
/>
</div>
<button
onClick={() => setActiveModal('contact')}
className="w-full h-[34px] rounded-sm bg-[#23a1eb] text-[#f6f6f6] text-[15px] font-semibold cursor-pointer"
>
Hubungi Senpai
</button>
</div>
</div>
</div>
)}
{activeModal === 'contact' && (
<div className="w-[400px] h-[242px] rounded-[8px] bg-white px-10 py-10">
<div className="w-[320px] mx-auto text-center">
<h3 className="text-[23px] font-semibold text-[#23a1eb]">Hubungi Senpai Sekarang ??</h3>
<p className="text-[15px] text-[#888888] mt-8">
Tekan tombol di bawah ini untuk terhubung langsung ke WhatsApp senpai kamu^^
</p>
</div>
<div className="mt-8 flex items-center gap-4">
<button className="w-[152px] h-[34px] rounded-sm bg-[#23a1eb] text-[#f6f6f6] text-[15px] font-semibold cursor-pointer">
Hubungi Senpai
</button>
<button
onClick={() => setActiveModal(null)}
className="w-[152px] h-[34px] rounded-sm bg-[#ffe8da] text-[#ff5242] text-[15px] font-semibold cursor-pointer"
>
Nanti Deh
</button>
</div>
</div>
)}
{activeModal === 'feedback-1' && (
<div className="w-[520px] h-[582px] rounded-[8px] bg-white p-8 overflow-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-[19px] font-semibold text-[#454545]">Feedback Mentor</h3>
<span className="text-[15px] font-medium text-[#6d6d6d]">1 dari 2</span>
</div>
<p className="text-[15px] font-medium text-[#454545] mb-3">Seberapa puas kamu dengan sesi mentoring ini?</p>
<div className="grid grid-cols-5 gap-2 mb-3">
{[1, 2, 3, 4, 5].map((n) => (
<button key={n} className="h-10 rounded-sm bg-primary-50 text-[15px] font-medium text-[#81cbf8] cursor-pointer">{n}</button>
))}
</div>
<p className="text-[10px] text-[#888888] mb-6">1 = Sangat Tidak Puas, 5 = Sangat Puas</p>
<p className="text-[15px] font-medium text-[#454545] mb-3">Seberapa membantu jawaban mentor untuk kebutuhanmu?</p>
<div className="space-y-3 mb-6">
{['Tidak Membantu', 'Kurang Membantu', 'Cukup Membantu', 'Membantu', 'Sangat Membantu'].map((item) => (
<label key={item} className="flex items-center gap-2 text-xs text-[#6d6d6d]">
<input type="radio" name="helpful" />
{item}
</label>
))}
</div>
<p className="text-[15px] font-medium text-[#454545] mb-3">Apakah kamu akan merekomendasikan platform ini ke rekan mu?</p>
<div className="space-y-3 mb-8">
{['Ya', 'Tidak', 'Mungkin'].map((item) => (
<label key={item} className="flex items-center gap-2 text-xs text-[#6d6d6d]">
<input type="radio" name="recommend" />
{item}
</label>
))}
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setActiveModal(null)}
className="h-[34px] w-[92px] rounded-sm bg-white text-[#23a1eb] text-[15px] font-semibold border border-transparent cursor-pointer"
>
Batal
</button>
<button
onClick={() => setActiveModal('feedback-2')}
className="h-[34px] w-[92px] rounded-sm bg-[#23a1eb] text-[#f6f6f6] text-[15px] font-semibold cursor-pointer"
>
Lanjut
</button>
</div>
</div>
)}
{activeModal === 'feedback-2' && (
<div className="w-[520px] h-[582px] rounded-[8px] bg-white p-8 overflow-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-[19px] font-semibold text-[#454545]">Feedback Mentor</h3>
<span className="text-[15px] font-medium text-[#6d6d6d]">2 dari 2</span>
</div>
<div className="space-y-5 mb-8">
<div>
<p className="text-[15px] font-medium text-[#454545] mb-2">Apa yang kamu suka dari sesi ini?</p>
<textarea className="w-full h-[76px] rounded-sm border border-[#d1d1d1] p-3 text-[15px] text-[#b0b0b0] resize-none" defaultValue="Hal apa yang menurutmu paling membantu atau berkesan dari sesi tadi?" />
</div>
<div>
<p className="text-[15px] font-medium text-[#454545] mb-2">Apa yang bisa ditingkatkan oleh mentor?</p>
<textarea className="w-full h-[76px] rounded-sm border border-[#d1d1d1] p-3 text-[15px] text-[#b0b0b0] resize-none" defaultValue="Ada saran atau masukan yang bisa membantu mentor lebih baik di sesi berikutnya?" />
</div>
<div>
<p className="text-[15px] font-medium text-[#454545] mb-2">Testimoni atau ucapan terima kasih untuk mentor</p>
<textarea className="w-full h-[76px] rounded-sm border border-[#d1d1d1] p-3 text-[15px] text-[#b0b0b0] resize-none" defaultValue="Kamu juga bisa tulis pesan singkat atau ucapan ke mentor di sini (opsional)" />
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setActiveModal('feedback-1')}
className="h-[34px] w-[100px] rounded-sm bg-white text-[#23a1eb] text-[15px] font-semibold cursor-pointer"
>
Kembali
</button>
<button
onClick={() => setActiveModal(null)}
className="h-[34px] w-[100px] rounded-sm bg-[#23a1eb] text-[#f6f6f6] text-[15px] font-semibold cursor-pointer"
>
Kirim
</button>
</div>
</div>
)}
</div>
)}
</section>
)
}
@@ -0,0 +1,60 @@
import { createFileRoute } from '@tanstack/react-router'
import { Icon } from '@iconify/react'
export const Route = createFileRoute('/_authenticated/dashboard/roadmap-discovery')({
component: RoadmapDiscoveryPage,
})
function RoadmapDiscoveryPage() {
return (
<section className="w-[972px] h-[438px]">
<div className="w-full h-full">
<div className="w-[421px] mx-auto text-center">
<h2 className="text-[23px] font-normal leading-[1.2] text-[#6d6d6d]">Welcome to Roadmap Discovery</h2>
<h1 className="text-[46px] font-bold leading-[1.2] text-[#5d5d5d] mt-[12px]">Start your Journey</h1>
</div>
<div className="w-[528px] h-[146px] mx-auto mt-10 flex items-start">
<img
src="/image/mascot-1.png"
alt="Mascot"
className="w-[146px] h-[146px] object-cover"
/>
<div className="w-[381px] h-[78px] bg-white mt-0">
<p className="text-[19px] leading-[1.2] text-[#6d6d6d] px-5 py-4">
Lagi pengen belajar apa? Ketik aja di sini,
<br />
biar AI bantuin bikin roadmap-nya.
</p>
</div>
</div>
<div className="w-[732px] h-[43px] mx-auto mt-10">
<div className="w-full h-[34px] flex items-center gap-4">
<input
type="text"
placeholder="Mau belajar roadmap apa?"
className="w-[468px] h-[34px] border border-[#d1d1d1] rounded-sm px-3 text-[15px] text-[#6d6d6d] placeholder:text-[#b0b0b0] focus:outline-none focus:border-[#23a1eb]"
/>
<select
className="w-[248px] h-[34px] border border-[#d1d1d1] rounded-sm px-3 text-[15px] text-[#6d6d6d] bg-white focus:outline-none focus:border-[#23a1eb]"
defaultValue="Tingkat Belajar"
>
<option disabled>Tingkat Belajar</option>
<option>Pemula</option>
<option>Menengah</option>
<option>Lanjutan</option>
</select>
</div>
<button className="w-[140px] h-[34px] mt-[49px] mx-auto flex items-center justify-center gap-2 bg-[#23a1eb] text-[#f6f6f6] rounded-sm text-[15px] font-semibold hover:bg-[#1d8fd3] transition-colors cursor-pointer">
<Icon icon="mdi:star-four-points" width="12" />
Generate
</button>
</div>
</div>
</section>
)
}
@@ -0,0 +1,154 @@
'use client';
import { useState } from 'react';
import { getMentorMockDashboardData } from '../../../dashboard/_data/mock/dashboard-mock';
/**
* Mentor Dashboard Component
* Displays mentor's welcome card, overview metrics, and analytics charts.
*/
export function MentorDashboard() {
const data = getMentorMockDashboardData();
const [activeTab, setActiveTab] = useState<'overviews' | 'analytics'>('overviews');
const gradientStyle = {
background: 'linear-gradient(135deg, #ffffff 30.3%, rgba(255, 255, 255, 0) 100%), #f0f8ff',
borderRadius: '8px',
};
// Topic chart data with exact bar widths from Figma specs
const topicBars = [
{ label: 'Basic IT', width: 48, value: 100 },
{ label: 'Career & Self...', width: 139, value: 100 },
{ label: 'PM & IT Tools', width: 81, value: 100 },
{ label: 'Programming', width: 124, value: 100 },
{ label: 'Industry Insight', width: 140, value: 100 },
{ label: 'AI Tips', width: 141, value: 100 },
{ label: 'Data & Database', width: 141, value: 100 },
];
// Session time preference chart data with exact heights
const sessionBars = [
{ label: '17:00 - 17:45', height: 48, value: 100 },
{ label: '19:00 - 19:45', height: 128, value: 100 },
{ label: '20:00 - 19:45', height: 81, value: 100 },
{ label: '20:00 - 19:45', height: 81, value: 100 },
{ label: '20:00 - 19:45', height: 81, value: 100 },
];
return (
<div>
{/* Welcome Card */}
<div className="relative w-[972px] h-[130px] bg-white rounded-lg p-5 mb-8 overflow-hidden shadow-sm">
<div style={gradientStyle} className="absolute inset-0 z-0" />
<div className="relative z-10 flex flex-col justify-between">
<div>
<h1 className="text-[23px] font-semibold leading-[27.6px] text-primary-accent m-0">Selamat Datang di Dimentorin.dev</h1>
<p className="text-base font-normal leading-[18px] text-text-muted mt-2 m-0">
Senpai~ saatnya kamu bantu para junior menaklukkan dunia IT!
<br />
Pantau jadwal mentoring-mu, cek progress mentee, dan bagikan ilmu terbaikmu lewat sesi 1-on-1 yang impactful~
</p>
</div>
</div>
<img
src="/image/mascot-character.webp"
alt="Mascot"
className="absolute right-[-20px] top-[-40px] w-[371px] h-[212px] z-[1] pointer-events-none"
/>
</div>
{/* Tabs */}
<div className="flex gap-6 mb-4">
<button
className={`h-[28px] px-3 bg-white border border-border-light rounded-sm text-xs font-medium leading-[15.6px] text-text-tab-default cursor-pointer transition-all ${
activeTab === 'overviews' ? 'border-border-light bg-white' : ''
}`}
onClick={() => setActiveTab('overviews')}
>
Overviews
</button>
<button
className={`h-[28px] px-3 bg-white border border-border-light rounded-sm text-xs font-medium leading-[15.6px] text-text-tab-default cursor-pointer transition-all ${
activeTab === 'analytics' ? 'border-border-light bg-white' : ''
}`}
onClick={() => setActiveTab('analytics')}
>
Analytics
</button>
</div>
{/* Tab Content */}
<div style={{ marginTop: '32px' }}>
{activeTab === 'overviews' && (
<div className="grid gap-4 mb-8 grid-cols-[repeat(4,231px)]">
<div className="w-[231px] h-[100px] bg-white rounded-sm p-5 flex flex-col justify-end gap-1 shadow-sm">
<div className="text-[19px] font-semibold text-primary-accent">{data.rating}</div>
<div className="text-base text-text-muted">Your Rating</div>
</div>
<div className="w-[231px] h-[100px] bg-white rounded-sm p-5 flex flex-col justify-end gap-1 shadow-sm">
<div className="text-[19px] font-semibold text-primary-accent">{data.sessionComplete}</div>
<div className="text-base text-text-muted">Session Complete</div>
</div>
<div className="w-[231px] h-[100px] bg-white rounded-sm p-5 flex flex-col justify-end gap-1 shadow-sm">
<div className="text-[19px] font-semibold text-primary-accent">{data.menteeImpacted}</div>
<div className="text-base text-text-muted">Mentee Impacted</div>
</div>
<div className="w-[231px] h-[100px] bg-white rounded-sm p-5 flex flex-col justify-end gap-1 shadow-sm">
<div className="text-[19px] font-semibold text-primary-accent">{data.totalFeedback}</div>
<div className="text-base text-text-muted">Total Feedback</div>
</div>
</div>
)}
{activeTab === 'analytics' && (
<div className="grid grid-cols-[repeat(2,478px)] gap-4 mb-8">
{/* Topics Chart */}
<div className="w-[478px] h-[264px] bg-white rounded-sm py-4 px-5 shadow-sm">
<h3 className="text-[15px] font-medium text-text-muted mb-6 m-0">Topics</h3>
<div className="flex flex-col gap-[12.5px]">
{topicBars.map((topic, idx) => (
<div key={idx} className="flex items-center gap-4">
<div className="w-[90px] text-xs font-semibold text-text-muted whitespace-nowrap">{topic.label}</div>
<div className="flex-1 flex items-center gap-3">
<div
className="h-[10px] bg-primary-accent rounded-sm"
style={{ width: `${topic.width}px` }}
/>
<span className="text-xs font-medium text-text-muted">{topic.value}</span>
</div>
</div>
))}
</div>
</div>
{/* Session Time Preference Chart */}
<div className="w-[478px] h-[264px] bg-white rounded-sm py-4 px-5 shadow-sm">
<h3 className="text-[15px] font-medium text-text-muted mb-6 m-0">Session Time Preference</h3>
<div className="flex flex-col h-[189px] justify-between">
<div className="flex items-end justify-between h-[159px] px-6">
{sessionBars.map((session, idx) => (
<div key={idx} className="flex flex-col items-center gap-3">
<span className="text-xs font-medium text-text-muted">{session.value}</span>
<div
className="w-[10px] bg-primary-accent rounded-sm"
style={{ height: `${session.height}px` }}
/>
</div>
))}
</div>
<div className="flex justify-between">
{sessionBars.map((session, idx) => (
<div key={idx} className="w-[75px] text-center text-xs font-semibold text-text-muted">
{session.label}
</div>
))}
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,59 @@
import { Icon } from '@iconify/react';
export interface OverviewCard {
title: string;
value: string | number;
icon: string;
color: 'blue' | 'green' | 'purple' | 'orange';
}
interface OverviewCardsProps {
cards: OverviewCard[];
}
/**
* Overview Cards component for dashboard statistics.
* Displays a grid of metric cards with icons and values.
*/
export function OverviewCards({ cards }: OverviewCardsProps) {
const colorClasses: Record<string, { bg: string; text: string }> = {
blue: {
bg: 'bg-blue-100 dark:bg-blue-900/30',
text: 'text-blue-600 dark:text-blue-400',
},
green: {
bg: 'bg-green-100 dark:bg-green-900/30',
text: 'text-green-600 dark:text-green-400',
},
purple: {
bg: 'bg-purple-100 dark:bg-purple-900/30',
text: 'text-purple-600 dark:text-purple-400',
},
orange: {
bg: 'bg-orange-100 dark:bg-orange-900/30',
text: 'text-orange-600 dark:text-orange-400',
},
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{cards.map((card) => {
const colors = colorClasses[card.color];
return (
<div
key={card.title}
className="bg-white dark:bg-gray-900 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-gray-700"
>
<div className="flex items-center gap-3 mb-3">
<div className={`w-10 h-10 rounded-lg ${colors.bg} flex items-center justify-center`}>
<Icon icon={card.icon} className={`${colors.text} text-xl`} />
</div>
<span className="text-sm text-gray-600 dark:text-gray-400">{card.title}</span>
</div>
<p className="text-3xl font-bold text-gray-900 dark:text-white">{card.value}</p>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,117 @@
'use client';
import { getUserMockDashboardData } from '../../../dashboard/_data/mock/dashboard-mock';
/**
* User Dashboard Component
* Displays user's welcome card, roadmap discovery, and learning progress.
*/
export function UserDashboard() {
const data = getUserMockDashboardData();
const gradientStyle = {
background: 'linear-gradient(135deg, #ffffff 30.3%, rgba(255, 255, 255, 0) 100%), #f0f8ff',
borderRadius: '8px',
};
return (
<div>
{/* Welcome Card */}
<div className="relative w-[972px] h-[164px] bg-white rounded-lg px-6 py-5 mb-8 overflow-hidden shadow-sm">
<div style={gradientStyle} className="absolute inset-0 z-0" />
<div className="relative z-10 h-full flex flex-col justify-between">
<div>
<h1 className="text-[23px] font-semibold leading-[27.6px] text-primary-accent m-0">Selamat Datang di Dimentorin.dev</h1>
<p className="text-base font-normal leading-[22px] text-text-muted mt-3 m-0 max-w-[620px]">
Yuk, mulai petualanganmu di menu Skill Discovery untuk dapatkan
<br />
roadmap 30 hari yang direkomendasikan AI khusus buat kamu~
</p>
</div>
<button className="w-[178px] h-[30px] mt-3 bg-primary-accent text-white rounded text-xs font-semibold leading-[14.4px] hover:bg-[#1e8cd1] transition-colors">Temukan Roadmapmu^^</button>
</div>
<img
src="/image/mascot-character.webp"
alt="Mascot"
className="absolute right-[-16px] top-[-28px] w-[371px] h-[212px] z-[1] pointer-events-none"
/>
</div>
<div className="grid gap-4 mb-8 grid-cols-[repeat(3,313.33px)]">
<div className="w-[313.33px] h-[100px] bg-white rounded-sm p-5 flex flex-col justify-end gap-1 shadow-sm">
<div className="text-[19px] font-semibold text-primary-accent">{data.mentoringSessions}</div>
<div className="text-base text-text-muted">Mentoring Session</div>
</div>
<div className="w-[313.33px] h-[100px] bg-white rounded-sm p-5 flex flex-col justify-end gap-1 shadow-sm">
<div className="text-[19px] font-semibold text-primary-accent">{data.articleSubmitted}</div>
<div className="text-base text-text-muted">Article Submitted</div>
</div>
<div className="w-[313.33px] h-[100px] bg-white rounded-sm p-5 flex flex-col justify-end gap-1 shadow-sm">
<div className="text-[19px] font-semibold text-primary-accent">{data.articlePublished}</div>
<div className="text-base text-text-muted">Article Published</div>
</div>
</div>
<section className="mb-8">
<h2 className="text-[19px] font-semibold text-text-label mb-4">Your Roadmap</h2>
<div>
{data.roadmap.map((item) => (
<div key={item.id} className="w-[972px] h-[140px] bg-white rounded-sm px-5 py-4 flex flex-col justify-between shadow-sm mb-4">
<h3 className="text-base font-medium text-text-muted m-0">{item.name}</h3>
<div className="flex flex-col gap-2">
<div className="flex justify-between text-xs font-medium text-neutral-600">
<span>1/{item.durationDays} days milestones completed</span>
<span>{item.completionPercentage}%</span>
</div>
<div className="w-full h-[14px] bg-border-light rounded-[2px] overflow-hidden">
<div
className="h-full rounded-[2px]"
style={{ width: `${item.completionPercentage}%`, background: 'linear-gradient(90deg, #87c7ed 0%, #23a1eb 100%)' }}
/>
</div>
</div>
<button className="w-[120px] h-[30px] bg-white border border-primary-accent rounded-sm text-primary-accent font-semibold text-xs leading-[14.4px] cursor-pointer">Lanjut Belajar</button>
</div>
))}
</div>
</section>
<section>
<h2 className="text-[19px] font-semibold text-text-label mb-4">Your Articles</h2>
<div className="w-[972px] bg-white rounded-sm shadow-sm overflow-hidden">
<table className="w-full border-collapse">
<thead>
<tr className="bg-primary-50">
<th className="text-left text-xs font-semibold text-text-label px-4 py-3">No.</th>
<th className="text-left text-xs font-semibold text-text-label px-4 py-3">Judul Artikel</th>
<th className="text-left text-xs font-semibold text-text-label px-4 py-3">Materi</th>
<th className="text-left text-xs font-semibold text-text-label px-4 py-3">Status</th>
<th className="text-left text-xs font-semibold text-text-label px-4 py-3">Submit Date</th>
<th className="text-left text-xs font-semibold text-text-label px-4 py-3">Action</th>
</tr>
</thead>
<tbody>
{data.articles.slice(0, 10).map((article) => (
<tr key={article.id} className="border-t border-neutral-100">
<td className="text-xs text-text-muted px-4 py-3">{article.no}</td>
<td className="text-xs text-text-muted px-4 py-3">{article.judul}</td>
<td className="text-xs text-text-muted px-4 py-3">{article.materi}</td>
<td className="text-xs text-text-muted px-4 py-3">{article.status}</td>
<td className="text-xs text-text-muted px-4 py-3">{article.submitDate}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button className="h-7 px-2 rounded-sm border border-primary-accent text-primary-accent text-[10px] font-semibold cursor-pointer">Edit</button>
<button className="h-7 px-2 rounded-sm border border-danger-200 text-danger-500 text-[10px] font-semibold cursor-pointer">Delete</button>
<button className="h-7 px-2 rounded-sm border border-border-light text-text-label text-[10px] font-semibold cursor-pointer">View</button>
<button className="h-7 px-2 rounded-sm bg-primary-accent text-white text-[10px] font-semibold cursor-pointer">Submit</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
);
}
@@ -1,177 +0,0 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import {
useAuthStore,
useSessionQuery,
useMySessions,
useMentorMe,
} from '@imphnen-frontend-service/service'
import { Icon } from '@iconify/react'
export const Route = createFileRoute('/_authenticated/dashboard_/')({
component: DashboardIndexPage,
})
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>
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Welcome, {user?.fullname || user?.email?.split('@')[0] || 'User'}!
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1 font-sans">
{mentor ? 'Manage your mentoring sessions and mentees' : 'Find mentors and book sessions'}
</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-white dark:bg-gray-900 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<Icon icon="mdi:calendar-clock" className="text-blue-600 dark:text-blue-400 text-xl" />
</div>
<span className="text-sm text-gray-600 dark:text-gray-400">Upcoming</span>
</div>
<p className="text-3xl font-bold text-gray-900 dark:text-white">{upcomingSessions.length}</p>
</div>
<div className="bg-white dark:bg-gray-900 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-lg bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<Icon icon="mdi:check-circle" className="text-green-600 dark:text-green-400 text-xl" />
</div>
<span className="text-sm text-gray-600 dark:text-gray-400">Completed</span>
</div>
<p className="text-3xl font-bold text-gray-900 dark:text-white">{completedSessions.length}</p>
</div>
<div className="bg-white dark:bg-gray-900 rounded-xl p-6 shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
<Icon icon="mdi:account-star" className="text-purple-600 dark:text-purple-400 text-xl" />
</div>
<span className="text-sm text-gray-600 dark:text-gray-400">Role</span>
</div>
<p className="text-lg font-bold text-gray-900 dark:text-white">
{mentor ? 'Mentor' : 'Mentee'}
</p>
{mentor?.status && (
<span className={`inline-block mt-1 px-2 py-0.5 rounded text-xs font-medium ${
mentor.status === 'verified'
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400'
}`}>
{mentor.status}
</span>
)}
</div>
</div>
{/* Mentor CTA */}
{!mentor && (
<div className="mb-8 bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-xl p-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h3 className="font-bold text-primary-900 dark:text-primary-100 text-lg">Become a Mentor</h3>
<p className="text-primary-700 dark:text-primary-300 text-sm mt-1">
Share your knowledge and help others grow in their career.
</p>
</div>
<Link
to="/auth/register-mentor"
className="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
>
Register as Mentor
</Link>
</div>
</div>
)}
{/* Upcoming Sessions */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
{upcomingSessions.length > 0 ? 'Upcoming Sessions' : 'No Upcoming Sessions'}
</h2>
<Link
to="/mentoring"
className="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 flex items-center gap-1"
>
Browse Mentors
<Icon icon="mdi:chevron-right" width="18" />
</Link>
</div>
{upcomingSessions.length > 0 ? (
<div className="space-y-3">
{upcomingSessions.slice(0, 5).map((s: any) => (
<div key={s.id} className="bg-white dark:bg-gray-900 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center">
<Icon icon="mdi:video" className="text-primary-600 dark:text-primary-400" />
</div>
<div>
<p className="font-medium text-gray-900 dark:text-white">{s.topic}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{new Date(s.scheduled_at).toLocaleDateString('en-US', {
weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
})}
{' '}&middot;{' '}{s.duration_minutes} min
</p>
</div>
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
s.status === 'confirmed'
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400'
}`}>
{s.status}
</span>
</div>
))}
</div>
) : (
<div className="bg-white dark:bg-gray-900 rounded-xl p-8 text-center shadow-sm border border-gray-200 dark:border-gray-700">
<Icon icon="mdi:calendar-blank" className="text-5xl text-gray-300 dark:text-gray-600 mx-auto mb-3" />
<p className="text-gray-500 dark:text-gray-400">
No upcoming sessions. Browse mentors to book your first session!
</p>
</div>
)}
</div>
{/* Profile Card */}
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="bg-gradient-to-r from-primary-600 to-primary-500 h-20"></div>
<div className="px-6 pb-6">
<div className="flex items-end gap-4 -mt-10 mb-4">
{user?.avatar ? (
<img src={user.avatar} alt="" className="w-20 h-20 rounded-full border-4 border-white dark:border-gray-900 shadow-lg object-cover" />
) : (
<div className="w-20 h-20 rounded-full border-4 border-white dark:border-gray-900 shadow-lg bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-3xl text-gray-400">U</span>
</div>
)}
<div className="pb-1">
<h3 className="font-bold text-gray-900 dark:text-white text-lg">{user?.fullname}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">{user?.email}</p>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -1,26 +1,54 @@
import { useForm } from 'react-hook-form';
import {
authRegisterSchema,
TRegisterRequest,
usePostRegister,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// Local schema for the UI fields
const registerFormSchema = z.object({
first_name: z.string().min(1, 'Nama depan tidak boleh kosong'),
last_name: z.string().min(1, 'Nama belakang tidak boleh kosong'),
email: z.string().min(1, 'Email tidak boleh kosong').email('Email harus valid'),
phone_number: z.string().min(1, 'Nomor telepon tidak boleh kosong'),
otp_code: z.string().optional(),
password: z.string().min(8, 'Password minimal 8 karakter'),
confirm_password: z.string().min(1, 'Konfirmasi password tidak boleh kosong'),
}).refine((data) => data.password === data.confirm_password, {
message: 'Password tidak cocok',
path: ['confirm_password'],
});
type TRegisterFormFields = z.infer<typeof registerFormSchema>;
export const useRegisterHook = () => {
const form = useForm<TRegisterRequest>({
resolver: zodResolver(authRegisterSchema),
mode: 'all',
const form = useForm<TRegisterFormFields>({
resolver: zodResolver(registerFormSchema),
mode: 'onChange',
defaultValues: {
first_name: '',
last_name: '',
email: '',
phone_number: '',
otp_code: '',
password: '',
confirm_password: '',
},
});
const { mutate: register, isPending: isLoading } = usePostRegister();
const onSubmit = form.handleSubmit((data) =>
register({
const onSubmit = form.handleSubmit((data) => {
const payload: TRegisterRequest = {
email: data.email,
phone_number: data.phone_number,
fullname: `${data.first_name} ${data.last_name}`.trim(),
password: data.password,
fullname: data.fullname,
})
);
confirm_password: data.confirm_password,
};
register(payload);
});
return {
form,
@@ -1,25 +1,164 @@
import { createFileRoute } from '@tanstack/react-router'
import { FC, ReactElement } from 'react'
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { ReactElement, useState } from 'react'
import { RegisterResetBanner, ControlledInputField } from '@imphnen-frontend-service/ui/organisms'
import { ForgotStep } from '@imphnen-frontend-service/ui/molecules'
import { Button, Input } from '@imphnen-frontend-service/ui/atoms'
import { toast } from 'sonner'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
export const Route = createFileRoute('/_public/auth/forgot')({
component: ForgotPasswordPage,
})
const forgotSchema = z.object({
email: z.string().email('Email harus valid'),
otp_code: z.string().min(6, 'Minimal 6 digit'),
password: z.string().min(8, 'Minimal 8 karakter'),
confirm_password: z.string(),
}).refine((data) => data.password === data.confirm_password, {
message: 'Password tidak cocok',
path: ['confirm_password'],
})
type TForgotFields = z.infer<typeof forgotSchema>
function ForgotPasswordPage(): ReactElement {
const navigate = useNavigate()
const [step, setStep] = useState(1)
const { control } = useForm<TForgotFields>({
resolver: zodResolver(forgotSchema),
mode: 'onChange',
defaultValues: { email: '', otp_code: '', password: '', confirm_password: '' }
})
const handleNext = () => {
if (step < 3) setStep(step + 1)
}
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'>
<div className="min-h-screen bg-primary-50 flex items-center justify-center p-5">
<div className="flex w-[90%] max-w-[1120px] min-h-[732px] bg-white rounded-[48px] shadow-auth border border-border-light overflow-hidden relative z-[1] p-10 gap-6">
<RegisterResetBanner />
<div className='border-2 border-primary-500/50 w-[596px] rounded-lg py-[70px] px-[48px] flex flex-col justify-center'>
<ForgotStep step={1} />
<h3 className='mt-5 text-3xl font-semibold text-primary-500'>Forgot Password</h3>
<h5 className='mt-2 text-xl font-medium text-primary-500'>Masukkan email-mu, dan biarkan kami memanggil password-mu kembali dari dunia lain!</h5>
<h6 className='text-gray-600 mt-8'>Email</h6>
<Input type='email' size='lg' placeholder='Contoh: yourname@mail.com'></Input>
<Button variant='primary' className='mt-7'>Kirim Kode OTP ^^</Button>
<div className="flex-1 py-[53px] px-12 bg-white border border-border-light rounded-[48px] flex flex-col items-center justify-start w-full">
<div className="mb-8 w-full max-w-[493px]">
<ForgotStep step={step} />
</div>
{step === 1 && (
<div className="w-full max-w-[493px]">
<div className="mb-8 text-left">
<h1 className="text-[37px] font-semibold leading-[1.2] text-text-dark mb-2">Lupa Password?</h1>
<p className="text-base font-medium text-text-secondary">
Tenang, Senpai! Kami bantu ambil kembali akses akunmu!
</p>
</div>
<div className="mt-6">
<ControlledInputField
label="Email"
name="email"
size="lg"
control={control}
placeholder="Masukkan email-mu yang terdaftar"
/>
</div>
<div className="mt-10">
<button
type="button"
onClick={handleNext}
className="w-full h-[34px] bg-primary-accent text-white rounded-md text-[15px] font-semibold flex items-center justify-center hover:bg-[#1e8cd1] disabled:bg-neutral-400 transition-all duration-200 ease-in-out cursor-pointer"
>
Kirim Kode OTP ^^
</button>
</div>
</div>
)}
{step === 2 && (
<div className="w-full max-w-[493px]">
<div className="mb-8 text-left">
<h1 className="text-[37px] font-semibold leading-[1.2] text-text-dark mb-2">Verifikasi OTP</h1>
<p className="text-base font-medium text-text-secondary">
Masukkan kode 6 digit yang dikirimkan ke email-mu! 📬
</p>
</div>
<div className="mt-6">
<ControlledInputField
label="OTP Code"
name="otp_code"
size="lg"
control={control}
placeholder="Kode Otp"
maxLength={6}
/>
<div className="mt-4 text-right">
<span className="text-sm text-gray-500">Gak dapet kode? </span>
<button type="button" className="text-primary-accent hover:underline cursor-pointer font-medium">Kirim Ulang</button>
</div>
</div>
<div className="mt-10">
<button
type="button"
onClick={handleNext}
className="w-full h-[34px] bg-primary-accent text-white rounded-md text-[15px] font-semibold flex items-center justify-center hover:bg-[#1e8cd1] disabled:bg-neutral-400 transition-all duration-200 ease-in-out cursor-pointer"
>
Verifikasi Kode
</button>
</div>
</div>
)}
{step === 3 && (
<div className="w-full max-w-[493px]">
<div className="mb-8 text-left">
<h1 className="text-[37px] font-semibold leading-[1.2] text-text-dark mb-2">Summon Password Baru!</h1>
<p className="text-base font-medium text-text-secondary">
Senpai! Pastikan password barumu lebih OP dan tak terkalahkan!
</p>
</div>
<div className="mt-6">
<ControlledInputField
label="Password Baru"
name="password"
type="password"
size="lg"
control={control}
placeholder="Masukkan password sekeren jurus ultimate-mu!"
/>
</div>
<div className="mt-6">
<ControlledInputField
label="Ulang Password"
name="confirm_password"
type="password"
size="lg"
control={control}
placeholder="Ulangi password-mu, Senpai~!"
/>
</div>
<div className="mt-10">
<button
type="button"
onClick={() => {
toast.success('Password updated! Redirecting to login...')
navigate({ to: '/auth/login' })
}}
className="w-full h-[34px] bg-primary-accent text-white rounded-md text-[15px] font-semibold flex items-center justify-center hover:bg-[#1e8cd1] disabled:bg-neutral-400 transition-all duration-200 ease-in-out cursor-pointer"
>
Summon Password Baru!
</button>
</div>
</div>
)}
</div>
</div>
</div>
@@ -5,6 +5,7 @@ import { GithubOutlined } from '@ant-design/icons'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner'
import { LoginBanner } from '@imphnen-frontend-service/ui/organisms'
import { Icon } from '@iconify/react'
export const Route = createFileRoute('/_public/auth/login')({
@@ -60,76 +61,68 @@ function LoginPage() {
}
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">
<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
</Link>
</div>
<div className="min-h-screen bg-primary-50 flex items-center justify-center p-5">
<div className="flex w-[90%] max-w-[1120px] min-h-[712px] bg-white rounded-[48px] shadow-auth border border-border-light overflow-hidden relative z-[1] p-10 gap-6">
<LoginBanner />
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2">Welcome Back</h2>
<p className="text-gray-600 font-sans">Sign in to the mentoring platform</p>
</div>
{error && (
<div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 text-sm">{error}</p>
</div>
)}
<form onSubmit={onSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input id="email" type="text" {...register('email')} placeholder="your@email.com" disabled={loginMutation.isPending}
className={`w-full px-4 py-2.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed ${errors.email ? 'border-red-400' : 'border-gray-300'}`} />
{errors.email && <p className="text-red-500 text-xs mt-1">{errors.email.message}</p>}
</div>
<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" className="text-sm text-primary-600 hover:text-primary-700">Forgot password?</Link>
<div className="flex-1 py-[53px] px-10 bg-white border border-border-light rounded-[48px] flex flex-col items-center justify-start w-full">
<div className="w-full max-w-[404px]">
<div className="mb-8">
<h1 className="text-[46px] leading-[1.2] font-bold text-primary-accent mb-2">Hallo Minna-san</h1>
<p className="text-[19px] leading-[1.2] font-medium text-primary-accent">Welcome to Dimentorin by IMPHNEN</p>
</div>
<div className="relative">
<input id="password" type={showPassword ? 'text' : 'password'} {...register('password')} placeholder="••••••••" disabled={loginMutation.isPending}
className={`w-full px-4 py-2.5 pr-12 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed ${errors.password ? 'border-red-400' : 'border-gray-300'}`} />
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700">
<Icon icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
{error && (
<div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 text-sm text-center">{error}</p>
</div>
)}
<form onSubmit={onSubmit} className="space-y-5">
<div>
<label htmlFor="email" className="block text-[15px] leading-[19.5px] font-medium text-text-label mb-2">Email</label>
<input id="email" type="text" {...register('email')} placeholder="Masukkan email-mu, Senpai~! ✨" disabled={loginMutation.isPending}
className={`w-full h-[52px] px-5 border rounded-md bg-white text-text-dark text-[15px] focus:outline-none focus:border-primary-accent focus:shadow-[0_0_0_3px_rgba(35,161,235,0.12)] placeholder:text-placeholder disabled:bg-neutral-100 disabled:cursor-not-allowed transition-all duration-200 ease-in-out ${errors.email ? 'border-red-400' : 'border-[#d1d1d1]'}`} />
{errors.email && <p className="text-red-500 text-xs mt-1">{errors.email.message}</p>}
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label htmlFor="password" className="block text-[15px] leading-[19.5px] font-medium text-text-label">Password</label>
<Link to="/auth/forgot" className="text-[15px] font-medium text-primary-accent hover:underline cursor-pointer">Lupa Password?</Link>
</div>
<div className="relative">
<input id="password" type={showPassword ? 'text' : 'password'} {...register('password')} placeholder="Masukkan password rahasiamu! 🔒" disabled={loginMutation.isPending}
className={`w-full h-[52px] px-5 border rounded-md bg-white text-text-dark text-[15px] focus:outline-none focus:border-primary-accent focus:shadow-[0_0_0_3px_rgba(35,161,235,0.12)] placeholder:text-placeholder pr-12 disabled:bg-neutral-100 disabled:cursor-not-allowed transition-all duration-200 ease-in-out ${errors.password ? 'border-red-400' : 'border-[#d1d1d1]'}`} />
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-4 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-dark">
<Icon icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
</button>
</div>
{errors.password && <p className="text-red-500 text-xs mt-1">{errors.password.message}</p>}
</div>
<button type="submit" disabled={!isValid || loginMutation.isPending}
className="w-full h-[34px] bg-primary-accent text-[#f6f6f6] rounded-md text-[15px] font-semibold flex items-center justify-center hover:bg-[#1e8cd1] focus:outline-none focus:ring-2 focus:ring-primary-accent focus:ring-offset-2 disabled:bg-neutral-400 disabled:cursor-not-allowed transition-all duration-200 ease-in-out cursor-pointer">
{loginMutation.isPending ? 'Entering...' : 'Enter Isekai'}
</button>
</form>
<div className="my-6 flex items-center justify-center gap-4">
<div className="flex-1 h-px bg-divider-blue"></div>
<span className="text-xs font-medium text-primary-accent">Or</span>
<div className="flex-1 h-px bg-divider-blue"></div>
</div>
<button onClick={handleGithubLogin} disabled={isGithubLoading} type="button"
className="w-full h-[44px] flex items-center justify-center gap-3 bg-bg-secondary border border-border-subtle rounded-md font-semibold text-text-label hover:bg-[#f0f0f0] focus:outline-none focus:ring-2 focus:ring-neutral-300 focus:ring-offset-2 disabled:bg-neutral-100 disabled:cursor-not-allowed transition-all duration-200 ease-in-out cursor-pointer">
<GithubOutlined className="text-lg" />
<span className="text-[19px] leading-[1.2] font-semibold">{isGithubLoading ? 'Connecting...' : 'Log In With GitHub'}</span>
</button>
<div className="mt-4 text-center">
<p className="text-[15px] font-medium text-text-secondary">Belum punya akun? <Link to="/auth/register" className="text-primary-accent font-medium text-[15px] hover:underline cursor-pointer">Daftar disini</Link></p>
</div>
{errors.password && <p className="text-red-500 text-xs mt-1">{errors.password.message}</p>}
</div>
<button type="submit" disabled={!isValid || loginMutation.isPending}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors cursor-pointer">
{loginMutation.isPending ? 'Signing in...' : 'Sign in with Email'}
</button>
</form>
<div className="my-6 flex items-center">
<div className="flex-1 border-t border-gray-300"></div>
<span className="px-4 text-sm text-gray-500">OR</span>
<div className="flex-1 border-t border-gray-300"></div>
</div>
<button onClick={handleGithubLogin} disabled={isGithubLoading} type="button"
className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 border border-gray-300 rounded-lg font-semibold text-gray-900 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:bg-gray-100 disabled:cursor-not-allowed transition-colors cursor-pointer">
<GithubOutlined className="text-xl" />
<span>{isGithubLoading ? 'Connecting...' : 'Sign in with GitHub'}</span>
</button>
<p className="mt-3 text-xs text-center text-gray-500 font-sans">
Make sure your GitHub email is <a href="https://github.com/settings/emails" target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline">set to public</a> for GitHub sign in to work.
</p>
<div className="mt-6 text-center">
<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,8 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { FC, ReactElement } from 'react'
import { createFileRoute, Link } from '@tanstack/react-router'
import { 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 Route = createFileRoute('/_public/auth/register')({
@@ -13,101 +11,126 @@ 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]">
<div className="bg-white xl:min-w-[1130px] min-h-[712px] p-10 rounded-2xl shadow-md flex gap-6">
<div className="min-h-screen bg-primary-50 flex items-center justify-center p-5">
<div className="flex w-[90%] max-w-[1120px] min-h-[732px] bg-white rounded-[48px] shadow-auth border border-border-light overflow-hidden relative z-[1] p-10 gap-6">
<RegisterResetBanner />
<div className="xl:border-2 xl:border-primary-500/50 xl:w-[726px] rounded-lg py-[32px] px-[48px] flex justify-center">
<div className="xl:w-[714px]">
<Button
className='xl:hidden gap-3'
variant='secondary'
>
<ArrowLeftOutlined />
Login
</Button>
<h3 className="mt-5 text-3xl font-semibold text-primary-500">
Register
</h3>
<h5 className="text-primary-500 font-medium">
Yosha~! Saatnya Bergabung dengan Dimentorin
</h5>
<form onSubmit={onSubmit}>
<div className='my-7'>
<div className="flex-1 py-[53px] px-12 bg-white border border-border-light rounded-[48px] flex flex-col items-center justify-start w-full">
<div className="w-full max-w-[493px] mb-7">
<h1 className="text-[37px] font-semibold leading-[1.2] text-text-dark mb-2">Register</h1>
<p className="text-base font-medium text-text-secondary">Yosha~! Saatnya Bergabung dengan Dimentorin!</p>
</div>
<form onSubmit={onSubmit} className="w-full max-w-[493px]">
<div className="grid grid-cols-2 gap-4">
<div>
<ControlledInputField
label="Full name"
size="lg"
className="w-full"
placeholder="Nama Lengkap"
name={'fullname'}
label="Nama Depan"
name="first_name"
control={form.control}
size="lg"
placeholder="Nama depan"
disabled={isLoading}
/>
</div>
<div className="grid grid-flow-row-dense my-7 lg:grid-cols-2 gap-2">
<div>
<ControlledInputField
label="Nama Belakang"
name="last_name"
control={form.control}
size="lg"
placeholder="Nama Belakang"
disabled={isLoading}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-5">
<div>
<ControlledInputField
label="Email"
name="email"
control={form.control}
size="lg"
className="w-full"
placeholder="Contoh : yourname@mail.com"
type='email'
control={form.control}
name={'email'}
/>
<ControlledInputField
label="No Hp"
size="lg"
className="w-full"
placeholder="Contoh : 088877665544"
control={form.control}
name={'phone_number'}
disabled={isLoading}
/>
</div>
<div>
<ControlledInputField
label="No Hp"
name="phone_number"
control={form.control}
size="lg"
placeholder="Contoh : 08123456789"
disabled={isLoading}
/>
</div>
</div>
<div className="flex items-end gap-3 mt-5">
<div className="flex-1">
<ControlledInputField
label="OTP Code"
name="otp_code"
control={form.control}
size="lg"
placeholder="Kode Otp"
disabled={isLoading}
/>
</div>
<button
type="button"
className="w-[66px] h-[42px] bg-primary-accent text-white rounded-md text-[10px] font-medium flex items-center justify-center hover:bg-[#1e8cd1] disabled:bg-neutral-400 transition-all duration-200 ease-in-out"
>
Kirim OTP
</button>
</div>
<div className="mt-5">
<ControlledInputField
label="Password"
className="w-full"
size="lg"
name="password"
type="password"
control={form.control}
size="lg"
placeholder="Buat password sekeren jurus ultimate-mu!"
control={form.control}
name={'password'}
helperText='"Senpai~! Pastikan password-mu sekuat pertahanan kastil!"'
disabled={isLoading}
/>
<h6 className="text-sm text-gray-500 mt-1">
"Senpai~! Pastikan password-mu sekuat pertahanan kastil!"
</h6>
<h6 className="text-sm text-gray-500 mt-2">
Tips membuat password yang OP:
</h6>
<h6 className="text-sm text-gray-500">
- Minimal 8 karakter (semakin panjang, semakin power-up!{' '}
<span role="img" aria-label="emoji">
</span>
)
</h6>
<h6 className="text-sm text-gray-500">
- Campur huruf besar, kecil, angka, dan simbol untuk kombinasi
ultimate!
<span role="img" aria-label="emoji">
🔥
</span>
</h6>
<h6 className="text-sm text-gray-500 mb-2">
- Jangan pakai password yang gampang ditebak, nanti ketahuan
musuh!
<span role="img" aria-label="emoji">
🚨
</span>
</h6>
<p className="mt-2 text-[10px] text-gray-500 italic">
Tips membuat password yang OP:<br/>
- Minimal 8 karakter (semakin panjang, semakin power-up! )<br/>
- Campur huruf besar, kecil, angka, dan simbol untuk kombinasi ultimate!🔥<br/>
- Jangan pakai password yang gampang ditebak, nanti ketahuan musuh!🚨
</p>
</div>
<div className="mt-4">
<ControlledInputField
label="Repeat Password"
className="w-full"
size="lg"
label="Ulang Password"
name="confirm_password"
type="password"
placeholder="Ulangi password-mu, Senpai~!"
control={form.control}
name={"confirm_password"}
size="lg"
placeholder="Ulangi password-mu, Senpai~!"
disabled={isLoading}
/>
<Button className="xl:w-full mt-2" disabled={(!form.formState.isValid || isLoading)}>Linked Start !!!</Button>
</form>
</div>
<div className="mt-8">
<button
type="submit"
disabled={(!form.formState.isValid || isLoading)}
className="w-full h-[34px] bg-primary-accent text-white rounded-md text-[15px] font-semibold flex items-center justify-center hover:bg-[#1e8cd1] disabled:bg-neutral-400 transition-all duration-200 ease-in-out cursor-pointer"
>
{isLoading ? 'Processing...' : 'Linked Start!!!'}
</button>
</div>
</form>
<div className="mt-6 text-center w-full max-w-[493px]">
<p className="text-base font-medium text-text-secondary">Sudah punya akun? <Link to="/auth/login" className="text-primary-accent font-medium text-base hover:underline cursor-pointer">Login disini</Link></p>
</div>
</div>
</div>
@@ -0,0 +1,142 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { UserDashboard } from '../../routes/_authenticated/dashboard_/_components/user/user-dashboard';
import { MentorDashboard } from '../../routes/_authenticated/dashboard_/_components/mentor/mentor-dashboard';
/**
* Dashboard Route/Persona Render Tests
* Tests component-level rendering of user and mentor dashboards.
* Note: These are component tests, not full route tests (route integration tests may require heavier harness).
*/
describe('Dashboard Persona Rendering', () => {
describe('UserDashboard Component', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render welcome card title', () => {
render(<UserDashboard />);
expect(screen.getByText('Selamat Datang di Dimentorin.dev')).toBeDefined();
});
it('should display welcome card body text', () => {
render(<UserDashboard />);
expect(screen.getByText(/Yuk, mulai petualanganmu di menu Skill Discovery/)).toBeDefined();
});
it('should display CTA button', () => {
render(<UserDashboard />);
expect(screen.getByText('Temukan Roadmapmu^^')).toBeDefined();
});
it('should display overview metrics', () => {
render(<UserDashboard />);
expect(screen.getByText('Mentoring Session')).toBeDefined();
expect(screen.getByText('Article Submitted')).toBeDefined();
expect(screen.getByText('Article Published')).toBeDefined();
});
it('should display metrics with zero values', () => {
render(<UserDashboard />);
const zeros = screen.getAllByText('0');
expect(zeros.length).toBeGreaterThan(0);
});
it('should display roadmap section', async () => {
render(<UserDashboard />);
await waitFor(() => {
expect(screen.getByText('Your Roadmap')).toBeDefined();
expect(screen.getByText('Front End Basic')).toBeDefined();
});
});
it('should display progress labels on roadmap', async () => {
render(<UserDashboard />);
await waitFor(() => {
expect(screen.getByText('1/30 days milestones completed')).toBeDefined();
expect(screen.getByText('50%')).toBeDefined();
});
});
it('should display roadmap action button', async () => {
render(<UserDashboard />);
await waitFor(() => {
expect(screen.getAllByText('Lanjut Belajar').length).toBeGreaterThan(0);
});
});
it('should display articles table section and headers', () => {
render(<UserDashboard />);
expect(screen.getByText('Your Articles')).toBeDefined();
expect(screen.getByText('No.')).toBeDefined();
expect(screen.getByText('Judul Artikel')).toBeDefined();
expect(screen.getByText('Materi')).toBeDefined();
expect(screen.getByText('Status')).toBeDefined();
expect(screen.getByText('Submit Date')).toBeDefined();
expect(screen.getByText('Action')).toBeDefined();
});
});
describe('MentorDashboard Component', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render welcome card title', () => {
render(<MentorDashboard />);
expect(screen.getByText('Selamat Datang di Dimentorin.dev')).toBeDefined();
});
it('should display welcome card body text', () => {
render(<MentorDashboard />);
expect(screen.getByText(/Senpai~ saatnya kamu bantu para junior/)).toBeDefined();
});
it('should display Overviews tab', () => {
render(<MentorDashboard />);
expect(screen.getByText('Overviews')).toBeDefined();
});
it('should display Analytics tab', () => {
render(<MentorDashboard />);
expect(screen.getByText('Analytics')).toBeDefined();
});
it('should display overview metrics labels', () => {
render(<MentorDashboard />);
expect(screen.getByText('Your Rating')).toBeDefined();
expect(screen.getByText('Session Complete')).toBeDefined();
expect(screen.getByText('Mentee Impacted')).toBeDefined();
expect(screen.getByText('Total Feedback')).toBeDefined();
});
it('should display metrics with zero values', () => {
render(<MentorDashboard />);
const zeros = screen.getAllByText('0');
expect(zeros.length).toBeGreaterThan(0);
});
it('should display Topics chart on analytics tab', async () => {
render(<MentorDashboard />);
const analyticsTab = screen.getByText('Analytics');
fireEvent.click(analyticsTab);
await waitFor(() => {
expect(screen.getByText('Topics')).toBeDefined();
expect(screen.getByText('Basic IT')).toBeDefined();
expect(screen.getByText('Career & Self...')).toBeDefined();
});
});
it('should display Session Time Preference chart on analytics tab', async () => {
render(<MentorDashboard />);
const analyticsTab = screen.getByText('Analytics');
fireEvent.click(analyticsTab);
await waitFor(() => {
expect(screen.getByText('Session Time Preference')).toBeDefined();
expect(screen.getByText('17:00 - 17:45')).toBeDefined();
expect(screen.getByText('19:00 - 19:45')).toBeDefined();
});
});
});
});
+1
View File
@@ -1 +1,2 @@
VITE_API_URL=
VITE_GITHUB_CLIENT_ID=
+1
View File
@@ -1,4 +1,5 @@
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_GITHUB_CLIENT_ID=
TURNSTILE_SECRET_KEY=
NEXT_PUBLIC_TURNSTILE_SITEKEY=
+10 -1
View File
@@ -7,7 +7,7 @@ const meta: Meta<typeof Button> = {
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'success', 'danger'],
options: ['primary', 'secondary', 'text', 'bordered', 'success', 'danger'],
},
size: {
control: 'select',
@@ -102,3 +102,12 @@ export const Small: Story = {
children: 'Small Button',
},
};
export const Focus: Story = {
args: {
variant: 'primary',
size: 'md',
autoFocus: true,
children: 'Focused Button',
},
};
@@ -62,3 +62,12 @@ export const Disabled: Story = {
disabled: true,
},
};
export const Error: Story = {
args: {
size: 'md',
type: 'text',
error: true,
placeholder: 'Error state',
},
};
@@ -1,29 +1,25 @@
import { DetailedHTMLProps, FC, InputHTMLAttributes, ReactElement } from "react"
import { FC, ReactElement } from "react"
type TForgotStepProps = Omit<
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
'size' | 'type'
> & {
interface TForgotStepProps {
step: number
};
}
export const ForgotStep: FC<TForgotStepProps> = ({
step = 1,
...rest
}): ReactElement => {
return (
<div className="w-full grid grid-cols-3 gap-3">
<div className="flex flex-col items-center justify-center gap-2">
<div className={`${step === 1 ? "bg-primary-500" : "bg-primary-200"} px-5 py-2 rounded-md w-full`}></div>
<h4 className={`${step === 1 ? "text-primary-500" : "text-gray-500"} text-xs`}>Masukkan Email</h4>
<div className="w-full flex items-start gap-4 mb-8">
<div className="flex-1 flex flex-col items-center gap-2">
<div className={`h-[12px] rounded-[8px] w-full transition-colors duration-300 ${step >= 1 ? "bg-[#23a1eb]" : "bg-[#bce1fb]"}`}></div>
<h4 className={`font-semibold text-[12px] text-center transition-colors duration-300 ${step >= 1 ? "text-[#23a1eb]" : "text-[#454545]"}`}>Masukkan Email</h4>
</div>
<div className="flex flex-col items-center justify-center gap-2">
<div className={`${step === 2 ? "bg-primary-500" : "bg-primary-200"} px-5 py-2 rounded-md w-full`}></div>
<h4 className={`${step === 2 ? "text-primary-500" : "text-gray-500"} text-xs`}>Verifikasi OTP</h4>
<div className="flex-1 flex flex-col items-center gap-2">
<div className={`h-[12px] rounded-[8px] w-full transition-colors duration-300 ${step >= 2 ? "bg-[#23a1eb]" : "bg-[#bce1fb]"}`}></div>
<h4 className={`font-semibold text-[12px] text-center transition-colors duration-300 ${step >= 2 ? "text-[#23a1eb]" : "text-[#454545]"}`}>Verifikasi OTP</h4>
</div>
<div className="flex flex-col items-center justify-center gap-2">
<div className={`${step === 3 ? "bg-primary-500" : "bg-primary-200"} px-5 py-2 rounded-md w-full`}></div>
<h4 className={`${step === 3 ? "text-primary-500" : "text-gray-500"} text-xs`}>Summon Password ^^</h4>
<div className="flex-1 flex flex-col items-center gap-2">
<div className={`h-[12px] rounded-[8px] w-full transition-colors duration-300 ${step >= 3 ? "bg-[#23a1eb]" : "bg-[#bce1fb]"}`}></div>
<h4 className={`font-semibold text-[12px] text-center transition-colors duration-300 ${step >= 3 ? "text-[#23a1eb]" : "text-[#454545]"}`}>Summon Password ^^</h4>
</div>
</div>
)
@@ -1,23 +1,43 @@
import { ArrowLeftOutlined } from "@ant-design/icons";
// eslint-disable-next-line @nx/enforce-module-boundaries
import { Icon } from "@iconify/react";
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { FC, ReactElement } from "react";
function AuthBanner({text, href}: {text: string, href: string}){
return (
<div className='hidden xl:block relative rounded-md'>
<img src="/image/95319c4f9953dfe6180200e529dfcea5.webp" alt="Banner" className='w-[420px] min-h-[632px] object-[80%] object-cover rounded-lg' />
<div className='absolute top-0 bg-gradient-to-b from-primary-500 to-transparent w-full rounded-t-lg h-[163px] py-5 px-5'>
<Button variant='secondary' className='gap-2' onClick={() => document.location.href = `${href}`}>
<ArrowLeftOutlined />
<p>{text}</p>
</Button>
</div>
<div className='absolute bottom-0 bg-gradient-to-t from-primary-500 to-transparent w-full rounded-b-lg min-h-[263px] flex flex-col items-center justify-center'>
<img src="/image/9261045e09137f3fcb925a78c55b6ddb.webp" alt="Logo" width={317} />
</div>
</div>
)
function AuthBanner({ text, href }: { text: string; href: string }) {
return (
<div className="hidden xl:block relative rounded-lg overflow-hidden w-[420px] min-h-[632px]">
<img
src="/image/95319c4f9953dfe6180200e529dfcea5.webp"
alt="Banner"
className="w-full h-full object-cover"
/>
{/* Top Gradient */}
<div className="absolute top-0 left-0 w-full h-[263px] bg-gradient-to-b from-[#23a1eb] to-transparent" />
{/* Bottom Gradient */}
<div className="absolute bottom-0 left-0 w-full h-[263px] bg-gradient-to-t from-[#23a1eb] to-transparent" />
{/* Navigation Button */}
<div className="absolute top-6 left-6 z-10">
<button
onClick={() => (document.location.href = href)}
className="flex items-center gap-2 bg-white px-4 py-2 rounded-md text-[#23a1eb] font-semibold text-[15px] shadow-sm hover:bg-gray-50 transition-colors cursor-pointer"
>
<Icon icon="lucide:chevron-left" width="16" height="16" />
{text}
</button>
</div>
{/* Logo */}
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-10 w-[317px]">
<img
src="/image/9261045e09137f3fcb925a78c55b6ddb.webp"
alt="Logo"
className="w-full h-auto"
/>
</div>
</div>
);
}
export const LoginBanner: FC = (): ReactElement => {
+3 -2
View File
@@ -66,7 +66,7 @@
"linter": "eslint",
"unitTestRunner": "vitest"
}
},
}
},
"release": {
"version": {
@@ -77,5 +77,6 @@
"e2e-ci--**/**": {
"dependsOn": ["^build"]
}
}
},
"analytics": false
}