fix: update action versions in Nix build workflow (#78)
This commit is contained in:
+8
-1
@@ -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 +1,2 @@
|
||||
VITE_API_URL=
|
||||
VITE_GITHUB_CLIENT_ID=
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
VITE_API_URL=
|
||||
VITE_API_URL=
|
||||
VITE_DISABLE_AUTH=false
|
||||
VITE_GITHUB_CLIENT_ID=
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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 |
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+284
@@ -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>
|
||||
)
|
||||
}
|
||||
+154
@@ -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>
|
||||
);
|
||||
}
|
||||
+59
@@ -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>
|
||||
);
|
||||
}
|
||||
+117
@@ -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'
|
||||
})}
|
||||
{' '}·{' '}{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 +1,2 @@
|
||||
VITE_API_URL=
|
||||
VITE_GITHUB_CLIENT_ID=
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
NEXT_PUBLIC_API_URL=
|
||||
NEXT_PUBLIC_GITHUB_CLIENT_ID=
|
||||
|
||||
TURNSTILE_SECRET_KEY=
|
||||
NEXT_PUBLIC_TURNSTILE_SITEKEY=
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user