Compare commits

..
317 changed files with 13064 additions and 66879 deletions
-24
View File
@@ -1,24 +0,0 @@
{
"permissions": {
"allow": [
"Bash(npx nx run-many:*)",
"Bash(npm install:*)",
"Bash(npm view:*)",
"Bash(npx nx build landing)",
"Bash(npm uninstall:*)",
"Bash(dir:*)",
"Bash(ren page.tsx page-original.tsx)",
"Bash(ren:*)",
"Bash(npx supabase:*)",
"Bash(libs/service/src/types/supabase.ts)",
"Bash(npx nx build:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push)",
"Bash(findstr:*)",
"Bash(ls:*)"
],
"deny": [],
"ask": []
}
}
-55
View File
@@ -1,55 +0,0 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
dist
.next
out
build
coverage
# IDE
.vscode
.idea
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Environment
.env
.env.local
.env.*.local
# Testing
coverage
.nyc_output
# Logs
logs
*.log
# Nx
.nx
# Docker
Dockerfile
docker-compose*.yml
.dockerignore
# Misc
*.md
!README.md
.editorconfig
.prettierrc
.eslintrc
-3
View File
@@ -1,3 +0,0 @@
VITE_SUPABASE_URL=https://your-project-id.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key-here
+33
View File
@@ -0,0 +1,33 @@
name: Deploy Landing
on:
push:
paths:
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
set -e
if [ ! -d ~/imphnen-frontend-service ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
else
cd ~/imphnen-frontend-service && git pull
fi
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" > ~/imphnen-frontend-service/apps/landing/.env
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
docker image prune -af
-4
View File
@@ -62,7 +62,3 @@ storybook-static
# Next.js
.next
out
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
.env.local
-9
View File
@@ -1,9 +0,0 @@
{
"mcpServers": {
"nx-mcp": {
"type": "stdio",
"command": "npx",
"args": ["nx", "mcp"]
}
}
}
-13
View File
@@ -1,13 +0,0 @@
<!-- nx configuration start-->
<!-- Leave the start & end comments to automatically receive updates. -->
# General Guidelines for working with Nx
- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
- You have access to the Nx MCP server and its tools, use them to help the user
- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable.
- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies
- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration
- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors
<!-- nx configuration end-->
-1
View File
@@ -1 +0,0 @@
Tue, Nov 25, 2025 4:21:27 PM
-2
View File
@@ -1,2 +0,0 @@
# Deployment trigger
# Updated to deploy circular dependency fixes and auth hooks
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Backoffice</title>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

@@ -1,52 +0,0 @@
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"
import { PieLabelProps } from "recharts/types/polar/Pie"
type ChartProps = {
name: string
value: number
color: string
}
const chartData: ChartProps[] = [
{ name: "Active", value: 49, color: "#23A1EB" },
{ name: "Done", value: 24, color: "#81CBF8" },
{ name: "Canceled", value: 27, color: "#BCE1FB" },
]
const RADIAN = Math.PI / 180;
const renderCustomizedLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent }: PieLabelProps) => {
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const x = cx + radius * Math.cos(-(midAngle ?? 0) * RADIAN);
const y = cy + radius * Math.sin(-(midAngle ?? 0) * RADIAN);
return (
<text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central">
{`${((percent ?? 1) * 100).toFixed(0)}%`}
</text>
);
};
export const SessionStatusChart = () => {
return (
<ResponsiveContainer width="100%" height={320}>
<PieChart width={500} height={320}>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={40}
outerRadius={100}
labelLine={false}
label={renderCustomizedLabel}
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
)
}
@@ -1,36 +0,0 @@
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
type ChartProps = {
name: string
activeUser: number
activeSession: number
}
const chartData: ChartProps[] = [
{ name: "2014", activeUser: 0, activeSession: 0 },
{ name: "2015", activeUser: 15, activeSession: 25 },
{ name: "2016", activeUser: 30, activeSession: 40 },
{ name: "2017", activeUser: 45, activeSession: 55 },
{ name: "2018", activeUser: 60, activeSession: 70 },
{ name: "2019", activeUser: 75, activeSession: 85 },
{ name: "2020", activeUser: 90, activeSession: 95 },
{ name: "2021", activeUser: 85, activeSession: 80 },
{ name: "2022", activeUser: 95, activeSession: 90 },
{ name: "2023", activeUser: 100, activeSession: 100 },
]
export const UserGrowthChart = () => {
return (
<ResponsiveContainer width="100%" height={320}>
<LineChart data={chartData} width={500} height={320} margin={{ left: -32 }}>
<CartesianGrid />
<XAxis dataKey="name" />
<YAxis tickCount={10} />
<Tooltip />
<Legend />
<Line dataKey="activeUser" stroke="#23A1EB" />
<Line dataKey="activeSession" stroke="#0877C1" />
</LineChart>
</ResponsiveContainer>
)
}
@@ -1,115 +0,0 @@
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { BackofficeWrapper } from "@imphnen-frontend-service/ui/organisms";
import { For } from "@imphnen-frontend-service/utils";
import { ReactElement } from "react";
import { UserGrowthChart } from "./_components/chart/user-growth";
import { SessionStatusChart } from "./_components/chart/session-status";
const Overview = () => {
return (
<div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">0</h3>
<p className="text-neutral-400 text-p3">Total Users</p>
</div>
)
}
export default function Components(): ReactElement {
return (
<BackofficeWrapper title="Dimentorin.dev">
<h1 className="text-p1 font-semibold text-neutral-700 mb-5">Overview</h1>
<div className="space-y-14">
<div>
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
Overview
</Button>
<div className="grid grid-cols-5 gap-5">
<For data={Array.from({ length: 5 })}>
{(_, index) => <Overview key={index} />}
</For>
</div>
</div>
<div>
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
Trends & Analytics
</Button>
<div className="grid grid-cols-7 gap-x-5">
<div className="bg-white px-6 py-4 rounded-lg col-span-5">
<div className="flex items-center justify-between mb-7">
<h2 className="font-semibold text-p3 text-neutral-700">User Growth</h2>
<div></div>
</div>
<UserGrowthChart />
</div>
<div className="bg-white px-6 py-4 rounded-lg col-span-2">
<h2 className="font-semibold text-p3 text-neutral-700 mb-7">Session Status</h2>
<SessionStatusChart />
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-x-5">
<div className="bg-white px-7 py-4 rounded-lg">
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top 5 Mentors</h2>
<div>
<table className="w-full">
<thead>
<tr className="text-label1 bg-primary-50 text-left rounded-full">
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
<th className="font-medium py-4 px-5 w-3/5">Nama Lengkap</th>
<th className="font-medium py-4 px-5 rounded-r-lg">Avg Rating</th>
</tr>
</thead>
<tbody>
<For data={Array.from({ length: 5 })}>
{(_, index) => (
<tr key={index} className="shadow rounded-lg">
<td className="py-4 px-5">{index + 1}</td>
<td className="py-4 px-5">Mursid Al-Catraz</td>
<td className="py-4 px-5">4.9</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
<div className="bg-white px-6 py-4 rounded-lg">
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top Booked Mentoring Topics</h2>
<div>
<table className="w-full">
<thead>
<tr className="text-label1 bg-primary-50 text-left font-medium">
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
<th className="font-medium py-4 px-5 w-3/5">Nama Lengkap</th>
<th className="font-medium py-4 px-5 rounded-r-lg">Total Sesi</th>
</tr>
</thead>
<tbody>
<For data={Array.from({ length: 5 })}>
{(_, index) => (
<tr key={index} className="shadow rounded-lg">
<td className="py-4 px-5">{index + 1}</td>
<td className="py-4 px-5">Mursid Al-Catraz</td>
<td className="py-4 px-5">1000</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
</div>
</div>
</BackofficeWrapper>
)
}
@@ -1,181 +0,0 @@
import { SearchOutlined } from "@ant-design/icons";
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
import { cn, For } from "@imphnen-frontend-service/utils";
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
import { ReactElement, useState } from "react"
const TABS = {
MENTORING: 'Mentoring',
PLATFORM: 'Platform'
} as const
type Tabs = typeof TABS[keyof typeof TABS]
type FeedbackStatus = 'done' | 'todo';
interface FeedbackType {
id: number
name: string
email: string
rating: number
status: FeedbackStatus
}
const mockData: FeedbackType[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
name: i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana',
email: 'fullname23@gmail.com',
rating: 4.5,
status: i % 2 === 0 ? 'done' : 'todo',
}))
export default function Components(): ReactElement {
const [activeTab, setActiveTab] = useState<Tabs>(TABS.MENTORING)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const columns: ColumnDef<FeedbackType>[] = [
{
id: 'select',
meta: { cellClassName: cn("w-20") },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'name',
header: 'Name',
accessorKey: 'name',
},
{
id: 'email',
header: 'Email',
accessorKey: 'email',
},
{
id: 'rating',
header: 'Rating',
accessorKey: 'rating',
},
{
id: 'status',
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<FeedbackStatus, string> = {
done: 'bg-success-200 text-success-500',
todo: 'bg-primary-200 text-primary-500',
};
const statusText: Record<FeedbackStatus, string> = {
done: 'Done',
todo: 'To Do',
};
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
},
{
header: 'Action',
meta: { cellClassName: cn("w-72") },
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation()
}}
className="flex items-center gap-2 w-max"
>
<SearchOutlined className="text-[16px]" /> Lihat Feedback
</Button>
),
},
]
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<BackofficeWrapper title="Dimentorin.dev">
<div className="mb-8 flex justify-between items-center">
<h1 className="text-p1 font-semibold text-neutral-700">Feedback</h1>
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
<For data={Object.values(TABS)}>
{(tab) => (
<Button
key={tab}
variant="text"
className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
onClick={() => setActiveTab(tab)}
>
{tab}
</Button>
)}
</For>
</div>
</div>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-5 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama mentor/mentee"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<Select>
<option selected disabled>Rating</option>
<option value="4.5">4.5</option>
<option value="5">5</option>
</Select>
<Select>
<option selected disabled>Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</Select>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</section>
</BackofficeWrapper>
);
}
@@ -1,64 +0,0 @@
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
import { FC, ReactElement } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
getAdminUsers,
getAdminTeams,
getAdminSubmissions,
} from '@imphnen-frontend-service/service';
export const HackathonDashboardPage: FC = (): ReactElement => {
// Fetch total participants
const { data: usersData } = useQuery({
queryKey: ['admin-users-count'],
queryFn: () => getAdminUsers({ page: 1, per_page: 1 }),
});
// Fetch total teams
const { data: teamsData } = useQuery({
queryKey: ['admin-teams-count'],
queryFn: () => getAdminTeams({ page: 1, per_page: 1 }),
});
// Fetch total submissions
const { data: submissionsData } = useQuery({
queryKey: ['admin-submissions-count'],
queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }),
});
const totalParticipants = usersData?.meta?.total_data ?? '??';
const totalTeams = teamsData?.meta?.total_data ?? '??';
const totalSubmissions = submissionsData?.meta?.total_data ?? '??';
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
<section className="grid grid-cols-5 gap-5">
{/* Participant */}
<div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
{totalParticipants}
</h3>
<p className="text-neutral-400 text-p3">Total Participants</p>
</div>
{/* Team */}
<div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
{totalTeams}
</h3>
<p className="text-neutral-400 text-p3">Total Teams</p>
</div>
{/* Project Submitted */}
<div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
{totalSubmissions}
</h3>
<p className="text-neutral-400 text-p3">Total Project Submitted</p>
</div>
</section>
</BackofficeWrapper>
);
};
export default HackathonDashboardPage;
@@ -1,249 +0,0 @@
import { FC } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import {
CloseOutlined,
LinkOutlined,
ProjectOutlined,
FileImageOutlined,
} from '@ant-design/icons';
import { TAdminSubmissionItem } from '@imphnen-frontend-service/service';
import { cn } from '@imphnen-frontend-service/utils';
interface SubmissionModalProps {
isOpen: boolean;
onClose: () => void;
submission: TAdminSubmissionItem;
}
const SubmissionModal: FC<SubmissionModalProps> = ({
isOpen,
onClose,
submission,
}) => {
if (!isOpen) return null;
const getStatusColor = (status: string) => {
switch (status) {
case 'submitted':
return 'bg-success-50 border-success-200 text-success-800';
case 'pending':
return 'bg-orange-50 border-orange-200 text-orange-800';
case 'approved':
return 'bg-blue-50 border-blue-200 text-blue-800';
case 'rejected':
return 'bg-error-50 border-error-200 text-error-800';
default:
return 'bg-neutral-50 border-neutral-200 text-neutral-800';
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-success-100 flex items-center justify-center">
<ProjectOutlined className="text-success-600 text-lg" />
</div>
<div>
<h2 className="text-xl font-semibold text-neutral-900">
{submission.project_name}
</h2>
<p className="text-sm text-neutral-500">
Team ID: {submission.team_id} Submitted{' '}
{new Date(submission.submitted_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</div>
</div>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 transition-colors cursor-pointer"
>
<CloseOutlined className="text-xl" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Submission Status */}
<div
className={cn(
'flex items-center gap-3 p-4 border rounded-lg',
getStatusColor(submission.status)
)}
>
<div
className={cn(
'w-3 h-3 rounded-full',
submission.status === 'submitted' && 'bg-success-500',
submission.status === 'pending' && 'bg-orange-500',
submission.status === 'approved' && 'bg-blue-500',
submission.status === 'rejected' && 'bg-error-500'
)}
></div>
<div>
<p className="text-sm font-medium">
Status:{' '}
{submission.status.charAt(0).toUpperCase() +
submission.status.slice(1)}
</p>
<p className="text-xs">Submitted by: {submission.submitted_by}</p>
</div>
</div>
{/* Project Description */}
<div>
<h3 className="text-sm font-medium text-neutral-700 mb-2">
Project Description
</h3>
<p className="text-sm text-neutral-600 leading-relaxed">
{submission.description}
</p>
</div>
{/* Project Links */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-neutral-700">
Project Links
</h3>
{/* Repository URL */}
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
<LinkOutlined className="text-primary-500 mt-1" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-neutral-600 mb-1">
Repository
</p>
<a
href={submission.repository_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary-600 hover:text-primary-700 hover:underline break-all"
>
{submission.repository_url}
</a>
</div>
</div>
{/* Demo URL */}
{submission.demo_url && (
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
<LinkOutlined className="text-primary-500 mt-1" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-neutral-600 mb-1">
Live Demo
</p>
<a
href={submission.demo_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary-600 hover:text-primary-700 hover:underline break-all"
>
{submission.demo_url}
</a>
</div>
</div>
)}
{/* Presentation URL */}
{submission.presentation_url && (
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
<LinkOutlined className="text-primary-500 mt-1" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-neutral-600 mb-1">
Presentation
</p>
<a
href={submission.presentation_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary-600 hover:text-primary-700 hover:underline break-all"
>
{submission.presentation_url}
</a>
</div>
</div>
)}
</div>
{/* Screenshots */}
{submission.screenshots && submission.screenshots.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-neutral-700 flex items-center gap-2">
<FileImageOutlined className="text-primary-500" />
Screenshots ({submission.screenshots.length})
</h3>
<div className="grid grid-cols-2 gap-3">
{submission.screenshots.map((screenshot, index) => (
<a
key={index}
href={screenshot}
target="_blank"
rel="noopener noreferrer"
className="block rounded-lg overflow-hidden border border-neutral-200 hover:border-primary-300 transition-colors"
>
<img
src={screenshot}
alt={`Screenshot ${index + 1}`}
className="w-full h-40 object-cover"
/>
</a>
))}
</div>
</div>
)}
{/* Metadata */}
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-neutral-200">
<div>
<p className="text-xs text-neutral-500 mb-1">Created</p>
<p className="text-sm text-neutral-900">
{new Date(submission.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
<div>
<p className="text-xs text-neutral-500 mb-1">Last Updated</p>
<p className="text-sm text-neutral-900">
{new Date(submission.updated_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-neutral-200 bg-neutral-50">
<Button variant="secondary" onClick={onClose}>
Close
</Button>
{/* <Button
variant="primary"
onClick={() => {
console.log('Edit submission:', submission.id);
}}
>
Edit Status
</Button> */}
</div>
</div>
</div>
);
};
export default SubmissionModal;
@@ -1,375 +0,0 @@
import {
FC,
ReactElement,
useState,
useEffect,
useMemo,
useCallback,
} from 'react';
import SubmissionModal from './_components/submission-modal';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import {
SearchOutlined,
FilterOutlined,
LoadingOutlined,
EyeOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import {
getAdminSubmissions,
TAdminSubmissionItem,
} from '@imphnen-frontend-service/service';
import { useSearchParams } from 'react-router-dom';
type SubmissionType = TAdminSubmissionItem;
export const HackathonSubmissionsPage: FC = (): ReactElement => {
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = Math.max(
1,
parseInt(searchParams.get('page') || '1', 10)
);
const searchQuery = searchParams.get('search') || '';
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
const statusFilter = searchParams.get('status') || 'all';
const [showSubmissionModal, setShowSubmissionModal] = useState(false);
const [selectedSubmission, setSelectedSubmission] =
useState<SubmissionType | null>(null);
const [globalFilter, setGlobalFilter] = useState(searchQuery);
// Fetch submissions from API
const {
data: submissionsResponse,
isLoading,
isFetching,
} = useQuery({
queryKey: [
'admin-submissions',
currentPage,
perPage,
statusFilter,
searchQuery,
],
queryFn: () =>
getAdminSubmissions({
page: currentPage,
per_page: perPage,
status: statusFilter !== 'all' ? statusFilter : undefined,
search: searchQuery || undefined,
}),
staleTime: 30000, // 30 seconds cache
gcTime: 5 * 60 * 1000, // 5 minutes
});
const totalData = submissionsResponse?.meta?.total_data || 0;
const totalPages = submissionsResponse?.meta?.total_page || 1;
// Handle page change
const handlePageChange = useCallback(
(newPage: number) => {
const params = new URLSearchParams();
params.set('page', newPage.toString());
if (perPage !== 10) params.set('per_page', perPage.toString());
if (searchQuery) params.set('search', searchQuery);
if (statusFilter !== 'all') params.set('status', statusFilter);
setSearchParams(params);
window.scrollTo({ top: 0, behavior: 'smooth' });
},
[setSearchParams, perPage, searchQuery, statusFilter]
);
// Validate page number
useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
setSearchParams({ page: totalPages.toString() });
}
}, [currentPage, totalPages, setSearchParams, isLoading]);
// Sync globalFilter with URL
useEffect(() => {
setGlobalFilter(searchQuery);
}, [searchQuery]);
// Handle search
const handleSearch = useCallback(() => {
const params = new URLSearchParams();
params.set('page', '1');
if (perPage !== 10) params.set('per_page', perPage.toString());
if (globalFilter.trim()) {
params.set('search', globalFilter.trim());
}
if (statusFilter !== 'all') params.set('status', statusFilter);
setSearchParams(params);
}, [globalFilter, setSearchParams, perPage, statusFilter]);
const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
},
[handleSearch]
);
// Handle per page change
const handlePerPageChange = useCallback(
(newPerPage: number) => {
const params = new URLSearchParams();
params.set('page', '1');
params.set('per_page', newPerPage.toString());
if (searchQuery) params.set('search', searchQuery);
if (statusFilter !== 'all') params.set('status', statusFilter);
setSearchParams(params);
},
[setSearchParams, searchQuery, statusFilter]
);
// Handle status filter change
// const handleStatusFilterChange = useCallback(
// (newStatus: string) => {
// const params = new URLSearchParams();
// params.set('page', '1');
// if (perPage !== 10) params.set('per_page', perPage.toString());
// if (searchQuery) params.set('search', searchQuery);
// if (newStatus !== 'all') params.set('status', newStatus);
// setSearchParams(params);
// },
// [setSearchParams, perPage, searchQuery]
// );
// Handle modal
const handleShowSubmissionModal = useCallback(
(submission: SubmissionType) => {
setSelectedSubmission(submission);
setShowSubmissionModal(true);
},
[]
);
const handleCloseSubmissionModal = useCallback(() => {
setShowSubmissionModal(false);
setSelectedSubmission(null);
}, []);
// Get submissions data
const filteredData = useMemo(() => {
return submissionsResponse?.data || [];
}, [submissionsResponse]);
// Memoize columns
const columns: ColumnDef<SubmissionType>[] = useMemo(
() => [
{
accessorKey: 'project_name',
header: 'Project Name',
cell: ({ row }) => (
<span className="font-medium text-neutral-900">
{row.original.project_name}
</span>
),
enableSorting: true,
},
{
accessorKey: 'team_id',
header: 'Team ID',
cell: ({ row }) => (
<span className="text-sm text-neutral-700 font-mono">
{row.original.team_id}
</span>
),
enableSorting: false,
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const status = row.original.status;
return (
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
status === 'submitted'
? 'bg-success-100 text-success-800'
: status === 'pending'
? 'bg-orange-100 text-orange-800'
: 'bg-neutral-100 text-neutral-700'
)}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
},
enableSorting: true,
},
{
accessorKey: 'submitted_at',
header: 'Submitted',
cell: ({ row }) => (
<span className="text-neutral-900 text-sm">
{new Date(row.original.submitted_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
),
enableSorting: true,
sortingFn: 'datetime',
},
{
id: 'actions',
header: 'Actions',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 text-sm px-4 py-2"
onClick={() => handleShowSubmissionModal(row.original)}
>
<EyeOutlined className="text-sm" />
View
</Button>
),
enableSorting: false,
},
],
[handleShowSubmissionModal]
);
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
Project Submissions
</h1>
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
<div className="flex flex-wrap gap-3 items-center justify-between">
<div className="flex flex-wrap gap-3 items-center">
{/* Search bar */}
<div className="relative">
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
<input
type="text"
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
placeholder="Search by project name..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
onKeyPress={handleSearchKeyPress}
/>
</div>
{/* Per Page Dropdown */}
<div className="relative">
<select
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={perPage}
onChange={(e) =>
handlePerPageChange(parseInt(e.target.value, 10))
}
>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
<option value={100}>100 / page</option>
</select>
</div>
{/* Status Filter */}
{/* <div className="relative">
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
<select
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-40 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={statusFilter}
onChange={(e) => handleStatusFilterChange(e.target.value)}
>
<option value="all">All Status</option>
<option value="submitted">Submitted</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
</div> */}
</div>
</div>
{/* Active filters */}
{/* {statusFilter !== 'all' && (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span>
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
Status: {statusFilter}
<button
onClick={() => handleStatusFilterChange('all')}
className="text-info-600 hover:text-info-800 cursor-pointer"
>
</button>
</span>
<Button
variant="secondary"
size="sm"
onClick={() => {
handleStatusFilterChange('all');
setGlobalFilter('');
}}
className="text-sm text-neutral-600"
>
Clear All
</Button>
</div>
)} */}
{/* Loading & results */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
<span className="ml-3 text-neutral-600">
Loading submissions...
</span>
</div>
) : filteredData.length > 0 ? (
<>
<div className="text-sm text-neutral-600">
Showing {filteredData.length} of {totalData} submissions (Page{' '}
{currentPage} of {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">(Updating...)</span>
)}
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination={true}
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="text-center py-12 text-neutral-500">
No submissions found. Try adjusting your filters.
</div>
)}
</section>
{/* Submission Modal */}
{selectedSubmission && (
<SubmissionModal
isOpen={showSubmissionModal}
onClose={handleCloseSubmissionModal}
submission={selectedSubmission}
/>
)}
</BackofficeWrapper>
);
};
export default HackathonSubmissionsPage;
@@ -1,514 +0,0 @@
import { FC, useState, useEffect, useMemo, useRef } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { CityFilterSelect } from '../../../../components/city-filter-select';
import TeamBannerPlaceholder from './team-banner-placeholder';
import { cn } from '@imphnen-frontend-service/utils';
import { TAdminTeamItem } from '@imphnen-frontend-service/service';
import {
TeamOutlined,
CloseOutlined,
DeleteOutlined,
SaveOutlined,
CalendarOutlined,
CrownOutlined,
ExclamationOutlined,
UploadOutlined,
CameraOutlined,
EyeOutlined,
EyeInvisibleOutlined,
} from '@ant-design/icons';
type TeamType = TAdminTeamItem;
interface ModalProps {
isOpen: boolean;
onClose: () => void;
team: TeamType | null;
}
const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
const [formData, setFormData] = useState<TeamType | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showLogoMenu, setShowLogoMenu] = useState(false);
const logoInputRef = useRef<HTMLInputElement>(null);
const bannerInputRef = useRef<HTMLInputElement>(null);
// Initialize form data when modal opens
useEffect(() => {
if (isOpen) {
if (team) {
setFormData({ ...team });
} else {
setFormData({
id: '',
name: '',
description: '',
city: '',
banner: null,
logo: null,
visibility: 'public',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
leader_id: '',
});
}
}
}, [isOpen, team]);
// Check if form has changes
const hasChanges = useMemo(() => {
if (!formData || !team) return !!formData;
return (
formData.name !== team.name ||
formData.description !== team.description ||
formData.city !== team.city ||
formData.visibility !== team.visibility ||
formData.logo !== team.logo ||
formData.banner !== team.banner
);
}, [formData, team]);
// Check if required fields are filled
const isFormValid = useMemo(() => {
if (!formData) return false;
return (
formData.name.trim() !== '' &&
formData.city.trim() !== '' &&
formData.description.trim() !== ''
);
}, [formData]);
const canSave = hasChanges && isFormValid;
if (!isOpen || !formData) return null;
const handleInputChange = (field: keyof TeamType, value: string | null) => {
setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
};
const handleSave = () => {
console.log('Saving team:', formData);
onClose();
};
const handleDelete = () => {
if (!team) return;
console.log('Deleting team:', team.id);
setShowDeleteConfirm(false);
onClose();
};
const handleLogoUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
if (file.size > 5 * 1024 * 1024) {
alert('Image size must be less than 5MB');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const logoUrl = e.target?.result as string;
handleInputChange('logo', logoUrl);
setShowLogoMenu(false);
};
reader.readAsDataURL(file);
};
const handleBannerUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
if (file.size > 5 * 1024 * 1024) {
alert('Image size must be less than 5MB');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const bannerUrl = e.target?.result as string;
handleInputChange('banner', bannerUrl);
};
reader.readAsDataURL(file);
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
<TeamOutlined className="text-primary-600 text-lg" />
</div>
<div>
<h2 className="text-xl font-semibold text-neutral-900">
{team ? 'Team Details' : 'Create New Team'}
</h2>
<p className="text-sm text-neutral-500">
{team
? 'View and manage team information'
: 'Add a new team to the hackathon'}
</p>
</div>
</div>
<button
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors cursor-pointer"
onClick={() => {
setShowLogoMenu(false);
onClose();
}}
>
<CloseOutlined className="text-neutral-400 text-lg" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6" onClick={() => setShowLogoMenu(false)}>
<input
type="file"
ref={logoInputRef}
onChange={handleLogoUpload}
accept="image/*"
className="hidden"
/>
<input
type="file"
ref={bannerInputRef}
onChange={handleBannerUpload}
accept="image/*"
className="hidden"
/>
{/* Banner Section */}
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Team Banner{' '}
<span className="text-xs text-neutral-500">
(3:1 aspect ratio recommended)
</span>
</label>
<div className="relative group">
<TeamBannerPlaceholder
banner={formData.banner || undefined}
teamName={formData.name || 'Team Name'}
className="rounded-lg border border-neutral-200 transition-all group-hover:border-primary-300"
/>
<div className="absolute inset-0 bg-black/40 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
bannerInputRef.current?.click();
}}
className="bg-white/90 hover:bg-white text-neutral-700 border-transparent shadow-sm gap-2"
>
<UploadOutlined className="text-sm" />
{formData.banner ? 'Change Banner' : 'Add Banner'}
</Button>
{formData.banner && (
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleInputChange('banner', null);
}}
className="bg-white/90 hover:bg-white text-red-600 border-transparent shadow-sm hover:text-red-700 gap-2"
>
<DeleteOutlined className="text-sm" />
Delete
</Button>
)}
</div>
</div>
</div>
{/* Logo & Name */}
<div className="grid grid-cols-12 gap-4 items-start">
<div className="col-span-2">
<label className="block text-sm font-medium text-neutral-700 mb-2">
Logo
</label>
<div className="relative group">
<div className="w-24 h-24 rounded-full bg-neutral-100 flex items-center justify-center overflow-hidden border border-neutral-200 group-hover:border-primary-300 transition-colors">
{formData.logo ? (
<img
src={formData.logo}
alt={formData.name || 'Team Logo'}
className="w-full h-full object-cover"
/>
) : (
<TeamOutlined className="text-neutral-400 text-xl" />
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
setShowLogoMenu(!showLogoMenu);
}}
className="absolute inset-0 bg-neutral-300/80 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center w-24 h-24"
>
<CameraOutlined className="text-white text-lg" />
</button>
{showLogoMenu && (
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10">
<button
onClick={(e) => {
e.stopPropagation();
logoInputRef.current?.click();
}}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-2 cursor-pointer"
>
<UploadOutlined className="text-sm" />
{formData.logo ? 'Change Logo' : 'Upload Logo'}
</button>
{formData.logo && (
<button
onClick={(e) => {
e.stopPropagation();
handleInputChange('logo', null);
setShowLogoMenu(false);
}}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer"
>
<DeleteOutlined className="text-sm" />
Remove Logo
</button>
)}
</div>
)}
</div>
</div>
<div className="col-span-10 space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Team Name <span className="text-danger-500">*</span>
</label>
<input
type="text"
className="w-full border border-neutral-200 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
placeholder="Enter team name"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
/>
</div>
</div>
{/* Description */}
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Description <span className="text-danger-500">*</span>
</label>
<textarea
className="w-full border border-neutral-200 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none resize-none"
placeholder="Enter team description"
rows={3}
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
/>
</div>
{/* City */}
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
City <span className="text-danger-500">*</span>
</label>
<CityFilterSelect
value={formData.city || 'all'}
onChange={(city) =>
handleInputChange('city', city === 'all' ? '' : city)
}
className="w-full"
placeholder="Search cities..."
allOptionLabel="Select a city"
filterIcon={false}
/>
</div>
{/* Visibility */}
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Team Visibility
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="visibility"
value="public"
checked={formData.visibility === 'public'}
onChange={(e) =>
handleInputChange('visibility', e.target.value)
}
className="text-primary-600"
/>
<EyeOutlined className="text-info-600" />
<span className="text-sm">Public</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="visibility"
value="private"
checked={formData.visibility === 'private'}
onChange={(e) =>
handleInputChange('visibility', e.target.value)
}
className="text-primary-600"
/>
<EyeInvisibleOutlined className="text-neutral-600" />
<span className="text-sm">Private</span>
</label>
</div>
</div>
{/* Team Details */}
{team && (
<div className="space-y-4 border-t border-neutral-200 pt-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Team Leader ID
</label>
<div className="p-3 bg-neutral-50 rounded-lg flex items-center gap-3">
<CrownOutlined className="text-yellow-600 text-lg" />
<span className="text-sm text-neutral-700 font-mono">
{team.leader_id}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Created
</label>
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
<CalendarOutlined className="text-neutral-500" />
<span className="text-sm text-neutral-700">
{new Date(team.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Last Updated
</label>
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
<CalendarOutlined className="text-neutral-500" />
<span className="text-sm text-neutral-700">
{new Date(team.updated_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
</div>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-neutral-200">
<div>
{team && (
<Button
variant="danger"
size="md"
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-2"
>
<DeleteOutlined />
Delete Team
</Button>
)}
</div>
<div className="flex items-center gap-3">
<Button variant="secondary" size="md" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
size="md"
onClick={handleSave}
disabled={!canSave}
className="flex items-center gap-2"
>
<SaveOutlined />
{team ? 'Save Changes' : 'Create Team'}
</Button>
</div>
</div>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-60 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-full bg-danger-100 flex items-center justify-center">
<ExclamationOutlined className="text-danger-600 text-xl" />
</div>
<div>
<h3 className="text-lg font-semibold text-neutral-900">
Delete Team
</h3>
<p className="text-sm text-neutral-500">
This action cannot be undone.
</p>
</div>
</div>
<p className="text-sm text-neutral-700 mb-6">
Are you sure you want to delete "{team?.name}"? This will
permanently remove the team and all associated data.
</p>
<div className="flex items-center gap-3 justify-end">
<Button
variant="secondary"
size="md"
onClick={() => setShowDeleteConfirm(false)}
>
Cancel
</Button>
<Button
variant="danger"
size="md"
onClick={handleDelete}
className="flex items-center gap-2"
>
<DeleteOutlined />
Delete Team
</Button>
</div>
</div>
</div>
)}
</div>
);
};
export default ModalTeamDetail;
@@ -1,82 +0,0 @@
import { FC } from 'react';
import { TeamOutlined } from '@ant-design/icons';
import { cn } from '@imphnen-frontend-service/utils';
interface TeamBannerPlaceholderProps {
banner?: string;
teamName: string;
className?: string;
showPlaceholder?: boolean;
}
const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
banner,
teamName,
className = '',
showPlaceholder = true,
}) => {
const aspectRatioClass = 'aspect-[3/1]'; // 3:1 aspect ratio
if (!banner && !showPlaceholder) {
return null;
}
if (banner) {
return (
<div
className={cn(
'w-full bg-gray-100 overflow-hidden relative',
aspectRatioClass,
className
)}
>
<img
src={banner}
alt={`${teamName} banner`}
className="w-full h-full object-cover"
onError={(e) => {
// Fallback to placeholder if image fails to load
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const placeholder = target.nextElementSibling as HTMLElement;
if (placeholder) {
placeholder.style.display = 'flex';
}
}}
/>
{/* Fallback placeholder (hidden by default, shown on image error) */}
<div
className={cn(
'absolute inset-0 bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
'hidden' // Hidden by default
)}
>
<div className="text-center">
<TeamOutlined className="text-4xl text-gray-400 mb-2" />
<p className="text-sm text-gray-500 font-medium">{teamName}</p>
<p className="text-xs text-gray-400">Team Banner</p>
</div>
</div>
</div>
);
}
// No banner - show placeholder
return (
<div
className={cn(
'w-full bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
aspectRatioClass,
className
)}
>
<div className="text-center">
<TeamOutlined className="text-4xl text-gray-400 mb-2" />
<p className="text-sm text-gray-500 font-medium">{teamName}</p>
<p className="text-xs text-gray-400">No Banner</p>
</div>
</div>
);
};
export default TeamBannerPlaceholder;
@@ -1,447 +0,0 @@
import {
FC,
ReactElement,
useState,
useEffect,
useMemo,
useCallback,
} from 'react';
import ModalTeamDetail from './_components/modal-team-detail-new';
import { CityFilterSelect } from '../../../components/city-filter-select';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import {
EditOutlined,
TeamOutlined,
SearchOutlined,
FilterOutlined,
PlusOutlined,
LoadingOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import {
getAdminTeams,
TAdminTeamItem,
} from '@imphnen-frontend-service/service';
import { useSearchParams } from 'react-router-dom';
type TeamType = TAdminTeamItem;
export const HackathonTeamsPage: FC = (): ReactElement => {
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = Math.max(
1,
parseInt(searchParams.get('page') || '1', 10)
);
const searchQuery = searchParams.get('search') || '';
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showNewTeamModal, setShowNewTeamModal] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<TeamType | null>(null);
useState<TeamType | null>(null);
const [globalFilter, setGlobalFilter] = useState(searchQuery);
// Advanced filtering states
const [visibilityFilter, setVisibilityFilter] = useState('all');
const [cityFilter, setCityFilter] = useState('all');
// Fetch teams from API
const {
data: teamsResponse,
isLoading,
isFetching,
} = useQuery({
queryKey: [
'admin-teams',
currentPage,
perPage,
cityFilter,
visibilityFilter,
searchQuery,
],
queryFn: () =>
getAdminTeams({
page: currentPage,
per_page: perPage,
search: searchQuery || undefined,
}),
staleTime: 30000, // 30 seconds cache
gcTime: 5 * 60 * 1000, // 5 minutes
});
const totalData = teamsResponse?.meta?.total_data || 0;
const totalPages = teamsResponse?.meta?.total_page || 1;
// Handle page change - update URL query params
const handlePageChange = useCallback(
(newPage: number) => {
const params = new URLSearchParams();
params.set('page', newPage.toString());
if (perPage !== 10) params.set('per_page', perPage.toString());
if (searchQuery) params.set('search', searchQuery);
setSearchParams(params);
window.scrollTo({ top: 0, behavior: 'smooth' });
},
[setSearchParams, perPage, searchQuery]
);
// Validate page number doesn't exceed total pages
useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
setSearchParams({ page: totalPages.toString() });
}
}, [currentPage, totalPages, setSearchParams, isLoading]);
// Sync globalFilter with URL search param on mount
useEffect(() => {
setGlobalFilter(searchQuery);
}, [searchQuery]);
// Handle search teams
const handleSearch = useCallback(() => {
const params = new URLSearchParams();
params.set('page', '1');
if (perPage !== 10) params.set('per_page', perPage.toString());
if (globalFilter.trim()) {
params.set('search', globalFilter.trim());
}
setSearchParams(params);
}, [globalFilter, setSearchParams, perPage]);
// Handle Enter key press in search input
const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
},
[handleSearch]
);
// Handle per page change
const handlePerPageChange = useCallback(
(newPerPage: number) => {
const params = new URLSearchParams();
params.set('page', '1');
params.set('per_page', newPerPage.toString());
if (searchQuery) params.set('search', searchQuery);
setSearchParams(params);
},
[setSearchParams, searchQuery]
);
// Memoize the callback to prevent recreation
const handleShowDetailModal = useCallback((team: TeamType) => {
setSelectedTeam(team);
setShowDetailModal(true);
}, []);
const handleCloseDetailModal = useCallback(() => {
setShowDetailModal(false);
setSelectedTeam(null);
}, []);
const handleShowNewTeamModal = useCallback(() => {
setShowNewTeamModal(true);
}, []);
const handleCloseNewTeamModal = useCallback(() => {
setShowNewTeamModal(false);
}, []);
// Get teams data from API response
const filteredData = useMemo(() => {
return teamsResponse?.data || [];
}, [teamsResponse]);
// Memoize columns to prevent recreation on every render
const columns: ColumnDef<TeamType>[] = useMemo(
() => [
{
accessorKey: 'name',
header: 'Team',
cell: ({ row }) => {
const team = row.original;
return (
<div className="flex items-center gap-3">
{/* Team Logo */}
<div className="w-10 h-10 rounded-full bg-neutral-100 flex items-center justify-center shrink-0 overflow-hidden">
{team.logo ? (
<img
src={team.logo}
alt={team.name}
className="w-full h-full object-cover"
/>
) : (
<TeamOutlined className="text-neutral-400 text-lg" />
)}
</div>
{/* Team Name */}
<div className="min-w-0 flex-1">
<p
className="font-medium text-neutral-900 truncate max-w-sm"
title={team.name}
>
{team.name}
</p>
</div>
</div>
);
},
enableSorting: true,
},
{
accessorKey: 'city',
header: 'City',
cell: ({ row }) => (
<span className="text-neutral-700">{row.original.city}</span>
),
enableSorting: true,
},
{
accessorKey: 'visibility',
header: 'Visibility',
cell: ({ row }) => {
const isPublic = row.original.visibility === 'public';
return (
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
isPublic
? 'bg-success-100 text-success-800'
: 'bg-neutral-100 text-neutral-700'
)}
>
{isPublic ? 'Public' : 'Private'}
</span>
);
},
enableSorting: true,
},
{
id: 'leader',
header: 'Leader ID',
cell: ({ row }) => (
<div className="text-sm text-neutral-700 font-mono">
{row.original.leader_id}
</div>
),
enableSorting: false,
},
{
accessorKey: 'created_at',
header: 'Created',
cell: ({ row }) => (
<span className="text-neutral-900 text-sm">
{new Date(row.original.created_at).toLocaleDateString('en-UK', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
),
enableSorting: true,
sortingFn: 'datetime',
},
{
id: 'actions',
header: 'Actions',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 text-sm px-4 py-2"
onClick={() => handleShowDetailModal(row.original)}
>
<EditOutlined className="text-sm" />
Manage
</Button>
</div>
),
enableSorting: false,
},
],
[handleShowDetailModal]
);
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
Team Management
</h1>
{/* Filters and actions */}
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
<div className="flex flex-wrap gap-3 items-center justify-between">
{/* Left side - Search & filters */}
<div className="flex flex-wrap gap-3 items-center">
{/* Search bar */}
<div className="relative">
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
<input
type="text"
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
placeholder="Search teams by name or city..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
onKeyPress={handleSearchKeyPress}
/>
</div>
{/* Per Page Dropdown */}
<div className="relative">
<select
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={perPage}
onChange={(e) =>
handlePerPageChange(parseInt(e.target.value, 10))
}
>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
<option value={100}>100 / page</option>
</select>
</div>
{/* Visibility Filter */}
{/* <div className="relative">
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
<select
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-40 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={visibilityFilter}
onChange={(e) => setVisibilityFilter(e.target.value)}
>
<option value="all">All Visibility</option>
<option value="public">Public</option>
<option value="private">Private</option>
</select>
</div> */}
{/* City Filter */}
{/* <CityFilterSelect
value={cityFilter}
onChange={setCityFilter}
className="w-full sm:w-44"
placeholder="Search cities..."
allOptionLabel="All Cities"
/> */}
</div>
{/* Right side - Add Team Button */}
<div className="flex items-center gap-3">
<Button
variant="primary"
size="md"
className="flex items-center gap-2 px-4 py-2"
onClick={handleShowNewTeamModal}
>
<PlusOutlined className="text-sm" />
Add Team
</Button>
</div>
</div>
{/* Active filters display */}
{(visibilityFilter !== 'all' || cityFilter !== 'all') && (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span>
{/* Visibility filter badge */}
{visibilityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
Visibility: {visibilityFilter}
<button
onClick={() => setVisibilityFilter('all')}
className="text-info-600 hover:text-info-800 cursor-pointer"
>
</button>
</span>
)}
{/* City filter badge */}
{cityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
City: {cityFilter}
<button
onClick={() => setCityFilter('all')}
className="text-green-600 hover:text-green-800 cursor-pointer"
>
</button>
</span>
)}
{/* Clear all filters */}
<Button
variant="secondary"
size="sm"
onClick={() => {
setVisibilityFilter('all');
setCityFilter('all');
setGlobalFilter('');
}}
className="text-sm text-neutral-600"
>
Clear All
</Button>
</div>
)}
{/* Loading & results display */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
<span className="ml-3 text-neutral-600">Loading teams...</span>
</div>
) : filteredData.length > 0 ? (
<>
<div className="text-sm text-neutral-600">
Showing {filteredData.length} of {totalData} teams (Page{' '}
{currentPage} of {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">(Updating...)</span>
)}
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination={true}
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="text-center py-12 text-neutral-500">
No teams found. Try adjusting your filters.
</div>
)}
</section>
{/* Modals component */}
<ModalTeamDetail
isOpen={showDetailModal}
onClose={handleCloseDetailModal}
team={selectedTeam}
/>
{/* New Team Modal */}
<ModalTeamDetail
isOpen={showNewTeamModal}
onClose={handleCloseNewTeamModal}
team={null} // null indicates creating new team
/>
</BackofficeWrapper>
);
};
export default HackathonTeamsPage;
@@ -1,655 +0,0 @@
import { FC, useState, useEffect, useMemo, useRef } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import {
UserOutlined,
EnvironmentOutlined,
CalendarOutlined,
SaveOutlined,
CloseOutlined,
ExclamationOutlined,
CameraOutlined,
DeleteOutlined,
UploadOutlined,
} from '@ant-design/icons';
interface UserType {
id: string;
avatar?: string | null;
fullname: string;
bio?: string;
location: string | null;
is_active: boolean;
skills: string[];
created_at: string;
updated_at: string;
}
interface ModalProps {
isOpen: boolean;
onClose: () => void;
user: UserType | null;
}
const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
const [formData, setFormData] = useState<UserType | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showAvatarMenu, setShowAvatarMenu] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Initialize form data when modal opens
useEffect(() => {
if (isOpen) {
if (user) {
// Edit existing user
setFormData({ ...user });
} else {
// Create new user
setFormData({
id: '', // Will be generated by backend
fullname: '',
bio: '',
location: '',
is_active: true,
skills: [],
avatar: undefined,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
}
}
}, [isOpen, user]);
// Check if form has changes
const hasChanges = useMemo(() => {
if (!formData) return false;
if (!user) return true; // New user always has changes
return (
formData.fullname !== user.fullname ||
formData.location !== user.location ||
formData.is_active !== user.is_active ||
formData.avatar !== user.avatar ||
JSON.stringify(formData.skills) !== JSON.stringify(user.skills) ||
formData.bio !== user.bio
);
}, [formData, user]);
// Check if required fields are filled
const isFormValid = useMemo(() => {
if (!formData) return false;
return formData.fullname?.trim() !== '' && formData.location?.trim() !== '';
}, [formData]);
const canSave = hasChanges && isFormValid;
if (!isOpen || !formData) return null;
const handleInputChange = (
field: keyof UserType,
value: string | boolean | string[] | undefined
) => {
setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
};
const handleSkillsChange = (skills: string[]) => {
setFormData((prev) => (prev ? { ...prev, skills } : null));
};
const handleSave = () => {
if (!formData) return;
if (user) {
// Update existing user
console.log('Update user data:', formData);
} else {
// Create new user
console.log('Create new user:', formData);
}
// Here you would typically make an API call to save the data
onClose();
};
const handleCancel = () => {
if (user) {
setFormData({ ...user }); // Reset to original for edit mode
}
onClose();
};
const handleDeleteAccount = () => {
if (!user) return; // Can't delete new user
console.log('Delete user:', user.id);
setShowDeleteConfirm(false);
onClose();
};
const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
// Validate file type
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
alert('Image size must be less than 5MB');
return;
}
// Create preview URL
const reader = new FileReader();
reader.onload = (e) => {
const avatarUrl = e.target?.result as string;
handleInputChange('avatar', avatarUrl);
setShowAvatarMenu(false);
};
reader.readAsDataURL(file);
}
};
const handleRemoveAvatar = () => {
handleInputChange('avatar', undefined);
setShowAvatarMenu(false);
};
const triggerFileUpload = () => {
fileInputRef.current?.click();
};
const availableSkills = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
return (
<div className="fixed inset-0 z-50">
<div
className="fixed inset-0 bg-black/50"
onClick={(e) => {
setShowAvatarMenu(false);
onClose();
}}
/>
<div className="fixed inset-0 flex items-center justify-center p-4">
<div
className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Hidden File Input */}
<input
type="file"
ref={fileInputRef}
onChange={handleAvatarUpload}
accept="image/*"
className="hidden"
/>
{/* Header */}
<div className="border-b border-neutral-200 px-8 py-6 flex justify-between items-start">
<div className="flex items-center gap-4">
{/* Interactive User Avatar */}
<div className="relative group ">
<div className="w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden border-2 border-transparent group-hover:border-primary-300 transition-colors">
{formData.avatar ? (
<img
src={formData.avatar}
alt={formData.fullname}
className="w-full h-full object-cover"
/>
) : (
<UserOutlined className="text-neutral-500 text-2xl" />
)}
</div>
{/* Avatar Hover Overlay */}
<button
onClick={() => setShowAvatarMenu(!showAvatarMenu)}
className="absolute inset-0 bg-neutral-400 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<CameraOutlined className="text-white text-lg" />
</button>
{/* Avatar Menu Dropdown */}
{showAvatarMenu && (
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10">
<button
onClick={triggerFileUpload}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-2 cursor-pointer"
>
<UploadOutlined className="text-sm" />
{formData.avatar ? 'Change Photo' : 'Upload Photo'}
</button>
{formData.avatar && (
<button
onClick={handleRemoveAvatar}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer"
>
<DeleteOutlined className="text-sm" />
Remove Photo
</button>
)}
</div>
)}
</div>
<div>
<div className="flex items-center gap-3 mb-2">
<h2 className="text-2xl font-bold text-neutral-900">
{user ? 'Edit User Profile' : 'Create New User'}
</h2>
{user && (
<span className="px-3 py-1 bg-info-100 text-info-700 text-xs font-medium rounded-2xl">
Hover avatar to change
</span>
)}
</div>
<div className="text-sm text-neutral-500">
{user
? `Make changes to ${
formData.fullname || 'this user'
}'s profile information`
: 'Fill in the information below to create a new user account'}
</div>
</div>
</div>
<button
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors cursor-pointer"
onClick={() => {
setShowAvatarMenu(false);
handleCancel();
}}
>
<CloseOutlined className="text-neutral-400 text-lg" />
</button>
</div>
{/* Content */}
<div className="p-8" onClick={() => setShowAvatarMenu(false)}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Left Column - Basic Info */}
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
Basic Information
</h3>
<div className="space-y-4">
{/* Full Name - Required */}
<div className="flex items-center gap-3">
<UserOutlined className="text-neutral-400" />
<div className="flex-1">
<label className="text-sm text-neutral-500 block mb-1">
Full Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.fullname}
onChange={(e) =>
handleInputChange('fullname', e.target.value)
}
className={cn(
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none',
!formData.fullname ||
formData.fullname.trim() === ''
? 'border-red-300 bg-red-50'
: 'border-neutral-300'
)}
placeholder="Enter full name"
/>
{(!formData.fullname ||
formData.fullname.trim() === '') && (
<p className="text-red-500 text-xs mt-1">
Full name is required
</p>
)}
</div>
</div>
{/* Location - Required */}
<div className="flex items-center gap-3">
<EnvironmentOutlined className="text-neutral-400" />
<div className="flex-1">
<label className="text-sm text-neutral-500 block mb-1">
Location <span className="text-red-500">*</span>
</label>
<select
value={formData.location || ''}
onChange={(e) =>
handleInputChange('location', e.target.value)
}
className={cn(
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white',
!formData.location ||
formData.location.trim() === ''
? 'border-red-300 bg-red-50'
: 'border-neutral-300'
)}
>
<option value="">Select location</option>
<option value="Jakarta">Jakarta</option>
<option value="Bandung">Bandung</option>
<option value="Surabaya">Surabaya</option>
<option value="Medan">Medan</option>
<option value="Yogyakarta">Yogyakarta</option>
</select>
{(!formData.location ||
formData.location.trim() === '') && (
<p className="text-red-500 text-xs mt-1">
Location is required
</p>
)}
</div>
</div>
{/* Joined Date - Read Only - Only show for existing users */}
{user && (
<div className="flex items-center gap-3">
<CalendarOutlined className="text-neutral-400" />
<div>
<p className="text-sm text-neutral-500">
Joined Date
</p>
<p className="font-medium">
{new Date(formData.created_at).toLocaleDateString(
'en-US',
{
year: 'numeric',
month: 'long',
day: 'numeric',
}
)}
</p>
</div>
</div>
)}
</div>
</div>
{/* Bio Section - Optional */}
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-3">
Bio{' '}
<span className="text-neutral-400 text-sm font-normal">
(Optional)
</span>
</h3>
<textarea
value={formData.bio || ''}
onChange={(e) =>
handleInputChange('bio', e.target.value || undefined)
}
placeholder="Tell us about yourself..."
rows={4}
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none resize-none"
/>
</div>
</div>
{/* Right Column - Skills & Status */}
<div className="space-y-6">
{/* Account Status - Enhanced Tab Design */}
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
Account Status
</h3>
<div className="flex bg-neutral-100 p-1 rounded-lg">
<button
onClick={() => handleInputChange('is_active', true)}
className={cn(
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
formData.is_active
? 'bg-white text-success-700 shadow-sm ring-1 ring-success-200'
: 'text-neutral-600 hover:text-neutral-800'
)}
>
<div className="flex items-center justify-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
formData.is_active
? 'bg-success-500'
: 'bg-neutral-400'
)}
/>
Active
</div>
</button>
<button
onClick={() => handleInputChange('is_active', false)}
className={cn(
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
!formData.is_active
? 'bg-white text-neutral-700 shadow-sm ring-1 ring-neutral-200'
: 'text-neutral-600 hover:text-neutral-800'
)}
>
<div className="flex items-center justify-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
!formData.is_active
? 'bg-neutral-500'
: 'bg-neutral-400'
)}
/>
Inactive
</div>
</button>
</div>
<p className="text-xs text-neutral-500 mt-2">
{formData.is_active
? 'User can access their account and participate in activities'
: 'User account is suspended and cannot access services'}
</p>
</div>
{/* Skills Section - Optional */}
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
Skills & Expertise{' '}
<span className="text-neutral-400 text-sm font-normal">
(Optional)
</span>
</h3>
<div className="space-y-3">
<div className="flex flex-wrap gap-2 min-h-10 p-3 border border-neutral-300 rounded-lg bg-neutral-50">
{formData.skills.length > 0 ? (
formData.skills.map((skill, index) => (
<span
key={index}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-2xl text-sm font-medium bg-blue-100 text-blue-800"
>
{skill}
<button
onClick={() =>
handleSkillsChange(
formData.skills.filter((_, i) => i !== index)
)
}
className="text-blue-600 hover:text-blue-800 ml-1 cursor-pointer"
>
</button>
</span>
))
) : (
<span className="text-neutral-400 text-sm">
No skills added yet
</span>
)}
</div>
<select
value=""
onChange={(e) => {
if (
e.target.value &&
!formData.skills.includes(e.target.value)
) {
handleSkillsChange([
...formData.skills,
e.target.value,
]);
}
}}
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white"
>
<option value="">Add a skill...</option>
{availableSkills
.filter((skill) => !formData.skills.includes(skill))
.map((skill) => (
<option key={skill} value={skill}>
{skill}
</option>
))}
</select>
</div>
</div>
{/* Account Details - Read Only - Only show for existing users */}
{user && (
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
Account Details
</h3>
<div className="space-y-3 bg-neutral-50 p-4 rounded-lg">
<div className="flex justify-between items-center py-1">
<span className="text-neutral-600 text-sm">
User ID
</span>
<span className="font-mono text-sm text-neutral-800">
{formData.id}
</span>
</div>
<div className="flex justify-between items-center py-1">
<span className="text-neutral-600 text-sm">
Last Updated
</span>
<span className="text-sm text-neutral-800">
{new Date(formData.updated_at).toLocaleDateString(
'en-US',
{
month: 'short',
day: 'numeric',
year: 'numeric',
}
)}
</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* Footer Actions */}
<div className="border-t border-neutral-200 px-8 py-6">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<div className="text-sm text-neutral-500">
{canSave
? 'Ready to save changes'
: hasChanges
? 'Please fill required fields'
: 'No changes made'}
</div>
{/* Delete Account Button - Only show for existing users */}
{user && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="text-red-600 hover:text-red-700 text-sm font-medium transition-colors cursor-pointer"
>
Delete Account
</button>
)}
</div>
<div className="flex items-center gap-3">
<Button
variant="secondary"
size="sm"
onClick={handleCancel}
className="px-6"
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSave}
disabled={!canSave}
className={cn(
'flex items-center gap-2 px-6',
!canSave && 'opacity-50 cursor-not-allowed'
)}
>
<SaveOutlined className="text-sm" />
{user ? 'Save Changes' : 'Create User'}
</Button>
</div>
</div>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 z-60">
<div
className="fixed inset-0 bg-black/50"
onClick={() => setShowDeleteConfirm(false)}
/>
<div className="fixed inset-0 flex items-center justify-center p-4">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md">
<div className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
<ExclamationOutlined className="text-red-600 text-lg" />
</div>
<div>
<h3 className="text-lg font-semibold text-neutral-900">
Delete Account
</h3>
<p className="text-sm text-neutral-500">
This action cannot be undone
</p>
</div>
</div>
<p className="text-neutral-700 mb-6">
Are you sure you want to permanently delete{' '}
<strong>{formData.fullname}</strong>'s account? This will
remove all their data and cannot be reversed.
</p>
<div className="flex gap-3 justify-end">
<Button
variant="secondary"
size="sm"
onClick={() => setShowDeleteConfirm(false)}
className="px-4"
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={handleDeleteAccount}
className="px-4 bg-red-600 hover:bg-red-700 border-red-600"
>
Delete Account
</Button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default ModalUserDetail;
@@ -1,573 +0,0 @@
import {
FC,
ReactElement,
useState,
useEffect,
useMemo,
useCallback,
} from 'react';
import ModalUserDetail from './_components/modal-user-detail';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import {
EditOutlined,
UserOutlined,
SearchOutlined,
FilterOutlined,
PlusOutlined,
LoadingOutlined,
} from '@ant-design/icons';
import { CityFilterSelect } from '../../../components/city-filter-select';
import { useQuery } from '@tanstack/react-query';
import {
getAdminUsers,
TAdminUserItem,
} from '@imphnen-frontend-service/service';
import { useSearchParams } from 'react-router-dom';
type UserType = TAdminUserItem;
// Skills options for filter
const skillsOptions = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
export const HackathonUsersPage: FC = (): ReactElement => {
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = Math.max(
1,
parseInt(searchParams.get('page') || '1', 10)
);
const searchQuery = searchParams.get('search') || '';
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showNewUserModal, setShowNewUserModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
const [globalFilter, setGlobalFilter] = useState(searchQuery);
// Advanced filtering states
const [statusFilter, setStatusFilter] = useState('all');
const [cityFilter, setCityFilter] = useState('all');
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
// Fetch users from API
const {
data: usersResponse,
isLoading,
isFetching,
} = useQuery({
queryKey: [
'admin-users',
currentPage,
perPage,
cityFilter,
statusFilter,
searchQuery,
],
queryFn: () =>
getAdminUsers({
page: currentPage,
per_page: perPage,
search: searchQuery || undefined,
}),
staleTime: 30000, // 30 seconds cache
gcTime: 5 * 60 * 1000, // 5 minutes
});
const totalData = usersResponse?.meta?.total_data || 0;
const totalPages = usersResponse?.meta?.total_page || 1;
// Handle page change - update URL query params
const handlePageChange = useCallback(
(newPage: number) => {
const params = new URLSearchParams();
params.set('page', newPage.toString());
if (perPage !== 10) params.set('per_page', perPage.toString());
if (searchQuery) params.set('search', searchQuery);
setSearchParams(params);
window.scrollTo({ top: 0, behavior: 'smooth' });
},
[setSearchParams, perPage, searchQuery]
);
// Validate page number doesn't exceed total pages
useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
setSearchParams({ page: totalPages.toString() });
}
}, [currentPage, totalPages, setSearchParams, isLoading]);
// Sync globalFilter with URL search param on mount
useEffect(() => {
setGlobalFilter(searchQuery);
}, [searchQuery]);
// Handle search users
const handleSearch = useCallback(() => {
const params = new URLSearchParams();
params.set('page', '1');
if (perPage !== 10) params.set('per_page', perPage.toString());
if (globalFilter.trim()) {
params.set('search', globalFilter.trim());
}
setSearchParams(params);
}, [globalFilter, setSearchParams, perPage]);
// Handle Enter key press in search input
const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
},
[handleSearch]
);
// Handle per page change
const handlePerPageChange = useCallback(
(newPerPage: number) => {
const params = new URLSearchParams();
params.set('page', '1');
params.set('per_page', newPerPage.toString());
if (searchQuery) params.set('search', searchQuery);
setSearchParams(params);
},
[setSearchParams, searchQuery]
);
// Memoize the callback to prevent recreation
const handleShowDetailModal = useCallback((user: UserType) => {
setSelectedUser(user);
setShowDetailModal(true);
}, []);
const handleCloseDetailModal = useCallback(() => {
setShowDetailModal(false);
setSelectedUser(null);
}, []);
const handleShowNewUserModal = useCallback(() => {
setShowNewUserModal(true);
}, []);
const handleCloseNewUserModal = useCallback(() => {
setShowNewUserModal(false);
}, []);
// Filter data based on current filter states
const filteredData = useMemo(() => {
const usersData = usersResponse?.data || [];
return usersData.filter((user: UserType) => {
// Status filter
if (statusFilter !== 'all') {
const isActive = statusFilter === 'active';
if (user.is_active !== isActive) return false;
}
// Skills filter
if (skillsFilter.length > 0) {
const userSkills = user.skills || [];
const hasMatchingSkill = skillsFilter.some((skill) =>
userSkills.includes(skill)
);
if (!hasMatchingSkill) return false;
}
return true;
});
}, [usersResponse, statusFilter, skillsFilter]);
// Memoize columns to prevent recreation on every render
const columns: ColumnDef<UserType>[] = useMemo(
() => [
{
accessorKey: 'fullname',
header: 'User',
cell: ({ row }) => (
<div className="flex items-center gap-3">
{/* Avatar */}
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
{row.original.avatar ? (
<img
src={row.original.avatar}
alt={row.original.fullname}
className="w-full h-full object-cover"
/>
) : (
<UserOutlined className="text-neutral-500 text-lg" />
)}
</div>
{/* Name only */}
<div className="min-w-0 flex-1">
<p className="font-medium text-neutral-900 truncate">
{row.original.fullname}
</p>
</div>
</div>
),
enableSorting: true,
},
{
accessorKey: 'skills',
header: 'Skills',
cell: ({ row }) => {
const skills = row.original.skills || [];
return (
<div className="flex flex-wrap gap-1 max-w-xs">
{skills.length > 0 ? (
<>
{skills.slice(0, 2).map((skill, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
>
{skill.replace(' Developer', '').replace(' Engineer', '')}
</span>
))}
{skills.length > 2 && (
<span className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-200 text-success-700">
+{skills.length - 2}
</span>
)}
</>
) : (
<span className="text-neutral-400">-</span>
)}
</div>
);
},
enableSorting: false,
},
{
accessorKey: 'location',
header: 'Location',
cell: ({ row }) => (
<span className="text-neutral-700">{row.original.location}</span>
),
enableSorting: true,
},
{
accessorKey: 'is_active',
header: 'Status',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
)}
/>
<span
className={cn(
'text-sm font-medium',
row.original.is_active ? 'text-success-700' : 'text-neutral-500'
)}
>
{row.original.is_active ? 'Active' : 'Inactive'}
</span>
</div>
),
enableSorting: true,
sortingFn: (rowA, rowB) => {
const aActive = rowA.original.is_active;
const bActive = rowB.original.is_active;
if (aActive && !bActive) return -1;
if (!aActive && bActive) return 1;
return 0;
},
},
{
accessorKey: 'created_at',
header: 'Joined',
cell: ({ row }) => (
<span className="text-neutral-900 text-sm">
{new Date(row.original.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
),
enableSorting: true,
sortingFn: 'datetime',
},
{
id: 'actions',
header: 'Actions',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 text-sm px-4 py-2"
onClick={() => handleShowDetailModal(row.original)}
>
<EditOutlined className="text-sm" />
Manage
</Button>
{/* <Button
variant="secondary"
size="sm"
className="text-sm px-4 py-2"
onClick={() => {
// Toggle user status - implement later
console.log(`Toggle status for ${row.original.fullname}`);
}}
>
{row.original.is_active ? 'Deactivate' : 'Activate'}
</Button> */}
</div>
),
enableSorting: false,
},
],
[handleShowDetailModal]
);
return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
User Management
</h1>
{/* Filters and actions */}
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
<div className="flex flex-wrap gap-3 items-center justify-between">
{/* Left side - Search & filters */}
<div className="flex flex-wrap gap-3 items-center">
{/* Search bar */}
<div className="relative">
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
<input
type="text"
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
placeholder="Search users by name or location..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
onKeyPress={handleSearchKeyPress}
/>
</div>
{/* Per Page Dropdown */}
<div className="relative">
<select
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={perPage}
onChange={(e) =>
handlePerPageChange(parseInt(e.target.value, 10))
}
>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
<option value={100}>100 / page</option>
</select>
</div>
{/* Status Filter */}
{/* <div className="relative">
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
<select
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-36 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div> */}
{/* City Filter */}
{/* <CityFilterSelect
value={cityFilter}
onChange={setCityFilter}
className="w-full sm:w-44"
placeholder="Search cities..."
allOptionLabel="All Cities"
/>
{cityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
Location: {cityFilter}
<button
onClick={() => setCityFilter('all')}
className="text-green-600 hover:text-green-800 cursor-pointer"
>
</button>
</span>
)} */}
{/* Skills Filter with Icon */}
{/* <div className="relative">
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
<select
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value=""
onChange={(e) => {
if (
e.target.value &&
!skillsFilter.includes(e.target.value)
) {
setSkillsFilter((prev) => [...prev, e.target.value]);
}
}}
>
<option value="">Add Skill Filter</option>
{skillsOptions.map((skill) => (
<option
key={skill}
value={skill}
disabled={skillsFilter.includes(skill)}
>
{skill}
</option>
))}
</select>
</div> */}
</div>
{/* Right side - Add User Button */}
<div className="flex items-center gap-3">
<Button
variant="primary"
size="md"
className="flex items-center gap-2 px-4 py-2"
onClick={handleShowNewUserModal}
>
<PlusOutlined className="text-sm" />
Add User
</Button>
</div>
</div>
{/* Active filters display */}
{(skillsFilter.length > 0 ||
statusFilter !== 'all' ||
cityFilter !== 'all') && (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span>
{/* Status filter badge */}
{statusFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
Status: {statusFilter}
<button
onClick={() => setStatusFilter('all')}
className="text-info-600 hover:text-info-800 cursor-pointer"
>
</button>
</span>
)}
{/* Location filter badge */}
{cityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
City: {cityFilter}
<button
onClick={() => setCityFilter('all')}
className="text-green-600 hover:text-green-800 cursor-pointer"
>
</button>
</span>
)}
{/* Skills filter badges */}
{skillsFilter.map((skill) => (
<span
key={skill}
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm"
>
{skill.replace(' Developer', '').replace(' Engineer', '')}
<button
onClick={() =>
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
}
className="text-purple-600 hover:text-purple-800 cursor-pointer"
>
</button>
</span>
))}
{/* Clear all filters */}
<Button
variant="secondary"
size="sm"
onClick={() => {
setStatusFilter('all');
setCityFilter('all');
setSkillsFilter([]);
setGlobalFilter('');
}}
className="text-sm text-neutral-600"
>
Clear All
</Button>
</div>
)}
{/* Loading & results display */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
<span className="ml-3 text-neutral-600">Loading users...</span>
</div>
) : filteredData.length > 0 ? (
<>
<div className="text-sm text-neutral-600">
Showing {filteredData.length} of {totalData} users (Page{' '}
{currentPage} of {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">(Updating...)</span>
)}
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination={true}
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="text-center py-12 text-neutral-500">
No users found. Try adjusting your filters.
</div>
)}
</section>
{/* Modals component */}
<ModalUserDetail
isOpen={showDetailModal}
onClose={handleCloseDetailModal}
user={selectedUser}
/>
{/* New User Modal */}
<ModalUserDetail
isOpen={showNewUserModal}
onClose={handleCloseNewUserModal}
user={null} // null indicates creating new user
/>
</BackofficeWrapper>
);
};
export default HackathonUsersPage;
+3 -41
View File
@@ -1,51 +1,13 @@
import { FC, ReactElement, useState } from 'react';
import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
export const AppLayout: FC = (): ReactElement => {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
return (
<div className="bg-primary-50 min-h-screen flex justify-center">
<div className="bg-primary-50 min-h-screen w-full flex">
<BackofficeSidebar
isOpen={mobileSidebarOpen}
onClose={() => setMobileSidebarOpen(false)}
/>
<div className="flex-1 overflow-auto">
{/* Sticky top header */}
<header
className={
'lg:hidden sticky top-0 bg-white border-b border-primary-200 px-4 py-3 flex items-center gap-3 ' +
(mobileSidebarOpen ? 'z-0' : 'z-30')
}
>
{/* Mobile menu button (shown on small screens) */}
<button
type="button"
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700 cursor-pointer"
onClick={() => setMobileSidebarOpen(true)}
aria-label="Open sidebar"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<h1 className="text-p3 font-semibold text-primary-700">
IMPHNEN Backoffice
</h1>
</header>
<BackofficeSidebar />
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
<Outlet />
</div>
</div>
@@ -1,85 +0,0 @@
import { ArrowRightOutlined } from "@ant-design/icons";
import { Button, Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms";
import { Accordion, Modal, ModalProps } from "@imphnen-frontend-service/ui/molecules";
import { cn, For } from "@imphnen-frontend-service/utils";
import { FC } from "react";
const labelClass = cn('text-neutral-800 text-[10px] font-medium mb-1.5 inline-block md:text-xs md:mb-2 xl:text-p3')
export const ModalCreateRoadmap: FC<Omit<ModalProps, 'children'>> = ({ isOpen, onClose }) => {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="xl:max-w-[64rem] bg-white px-10 py-9"
closeButtonClassName="hidden"
>
<div className="overflow-y-auto max-h-[80vh]">
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
Create Roadmaps
</h1>
<div className="grid grid-cols-5 gap-6 mb-12">
<div className="col-span-3">
<label className={labelClass}>Prompt</label>
<Textarea
className="min-w-full w-full h-[calc(100%-2rem)]"
placeholder="Create a learning roadmap for (subject) at the (level) level. Include topics, estimated duration, and logical progression."
/>
</div>
<div className="col-span-2 space-y-8">
<div>
<label className={labelClass}>Roadmap Name</label>
<Input type="text" className="min-w-full w-full" placeholder="Nama Roadmap" />
</div>
<div>
<label className={labelClass}>Roadmap Name</label>
<Select className="w-full">
<option value="pemula">Pemula</option>
<option value="menengah">Menengah</option>
</Select>
</div>
<div>
<label className={labelClass}>Model</label>
<Select className="w-full">
<option value="gpt-3.5-turbo">GPT 3.5 Turbo</option>
<option value="gpt-4">GPT 4</option>
<option value="gpt-4-32k">GPT 4 32k</option>
</Select>
</div>
</div>
<div className="col-span-full">
<Button>
Generate Roadmap
</Button>
</div>
</div>
<div>
<h2 className="text-neutral-600 text-p2 font-semibold mb-8">
Roadmap Preview
</h2>
<div className="space-y-2.5">
<For data={Array.from({ length: 3 })}>
{(_, index) => (
<Accordion
key={index}
title={`Day ${index + 1}`}
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit. In convallis tincidunt nisl, id consequat mi malesuada vel. Nulla facilisi. Nam in turpis ligula."
/>
)}
</For>
</div>
</div>
<div className="flex justify-end mt-12">
<Button type="button" className="flex items-center gap-5">
Submit Roadmap
<ArrowRightOutlined />
</Button>
</div>
</div>
</Modal>
)
}
@@ -1,174 +0,0 @@
import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons";
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
import { cn } from "@imphnen-frontend-service/utils";
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
import { useState } from "react";
import { ModalCreateRoadmap } from "./_components/modal/create-roadmap";
type LearningStatus = 'active' | 'inactive'
type RoadmapType = {
id: number
name: string
learningLevel: string
status: LearningStatus
}
const mockData: RoadmapType[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
name: i === 0 ? 'Ahmad Wijuana' : 'Anna Wiguana',
learningLevel: ['Pemula', 'Menengah'][Math.floor(Math.random() * 2)],
status: i % 2 === 0 ? 'active' : 'inactive',
}))
export default function Components(): React.ReactElement {
const [openCreateModal, setOpenCreateModal] = useState(false)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const columns: ColumnDef<RoadmapType>[] = [
{
id: 'select',
meta: { cellClassName: cn("w-20") },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'id',
header: 'No',
accessorKey: 'id',
},
{
id: 'name',
header: 'Nama Roadmap',
accessorKey: 'name',
},
{
id: 'learningLevel',
header: 'Tingkat Belajar',
accessorKey: 'learningLevel',
},
{
id: 'status',
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<LearningStatus, string> = {
inactive: 'bg-danger-200 text-danger-700',
active: 'bg-success-200 text-success-500',
};
const statusText: Record<LearningStatus, string> = {
inactive: 'Inactive',
active: 'Active',
};
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
},
{
header: 'Action',
meta: { cellClassName: cn("w-72") },
cell: ({ row }) => (
<div className="flex gap-[8px]">
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-2"
>
<EditOutlined /> Action
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-2"
>
<DeleteOutlined /> Delete
</Button>
</div>
),
},
]
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<BackofficeWrapper title="Dimentorin.dev">
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Content & Roadmap</h1>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex items-center justify-between mb-9">
<h2 className="text-p2 font-semibold text-neutral-600">AI Roadmaps</h2>
<Button type="button" variant="primary" className="flex items-center gap-2" onClick={() => setOpenCreateModal(true)}>
<PlusOutlined /> Buat Roadmap
</Button>
</div>
<div className="flex justify-between items-center gap-5 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama roadmap"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<Select>
<option selected disabled>Tingkat Belajar</option>
<option value="pemula">Pemula</option>
<option value="menengah">Menengah</option>
</Select>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</section>
<ModalCreateRoadmap isOpen={openCreateModal} onClose={() => setOpenCreateModal(false)} />
</BackofficeWrapper>
)
}
@@ -1,114 +0,0 @@
import { Icon } from "@iconify/react";
import { Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms";
import { Modal } from "@imphnen-frontend-service/ui/molecules";
import { cn, For } from "@imphnen-frontend-service/utils";
import { FC } from "react";
const TOPICS = [
{ id: 2, icon: '🏢', name: 'Industry Insight' },
{ id: 4, icon: '🖥️', name: 'Basic IT' },
]
const placeholder = `Hi [Nama Mentor], Saya [Nama Kamu] & saya berharap dapat memiliki sesi mentoring dengan Anda.
Saat ini, saya tertarik untuk mengejar __. Tujuan saya untuk sesi ini adalah __.
Saya ingin tahu secara khusus tentang ___.
1.Pertanyaan Anda
2. ...
3. ...`
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
type ModalProps = {
open: boolean
setOpen: (open: boolean) => void
}
export const ModalDetailSession: FC<ModalProps> = ({ open, setOpen }) => {
return (
<Modal
isOpen={open}
onClose={() => setOpen(false)}
className="xl:max-w-[64rem] bg-white px-10 py-9"
closeButtonClassName="hidden"
>
<div>
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
Detail Sesi
</h1>
<div className="grid grid-cols-9 px-9 py-7 border rounded-md gap-12 mb-10">
<div className="col-span-4 flex items-center gap-x-12">
<div>
<h2 className="text-p2 font-semibold text-primary-500 mb-4">Mentor</h2>
<div>
<p className="text-p3 font-semibold mb-2.5">Muhammad Firdaus Oi Oi Oi, S.H., M.H.</p>
<p className="text-neutral-600">UI Designer at Oray orayan Studios</p>
</div>
</div>
<Icon icon="ph:arrow-right" className="text-9xl text-primary-500" />
</div>
<div className="col-span-5 flex items-center gap-12">
<div>
<h2 className="text-p2 font-semibold text-primary-500 mb-4">Mentee</h2>
<div>
<p className="text-p3 font-semibold mb-2.5">Muhammad Firdaus Oi Oi Oi, S.H., M.H.</p>
<p className="text-neutral-600">UI Designer at Oray orayan Studios</p>
</div>
</div>
<div>
<h2 className="text-p2 font-semibold text-neutral-700 mb-4">Status</h2>
<div className="py-2 px-6 rounded-md text-center bg-success-200 text-success-500 font-semibold">
Finished
</div>
</div>
</div>
</div>
<div>
<h1 className="text-p3 font-medium mb-2.5">Topics</h1>
<div className="p-5 bg-primary-50 border border-primary-100 rounded-md flex flex-wrap gap-2.5 mb-8">
<For data={TOPICS}>
{(item, index) => (
<div
key={index}
className="px-2.5 py-2 text-neutral-800 bg-white border border-primary-100 rounded-md shadow font-medium"
>
<span>{item.icon} </span>
<span>{item.name}</span>
</div>
)}
</For>
</div>
<div className="grid gap-2.5 md:grid-cols-2 md:gap-5">
<div>
<label className={labelClass}>Tanggal</label>
<Input type="date" className="min-w-full w-full" />
</div>
<div>
<label className={labelClass}>Waktu</label>
<Input type="time" className="min-w-full w-full" />
</div>
<div className="relative md:col-span-full">
<label className={labelClass}>Lokasi</label>
<Select className="min-w-full w-full">
<option value="online">Online</option>
<option value="offline">Offline</option>
</Select>
</div>
<div className="md:col-span-full">
<label className={labelClass}>Pertanyaan Untuk Senpai</label>
<Textarea
className="min-w-full w-full h-40"
placeholder={placeholder}
/>
</div>
</div>
</div>
</div>
</Modal>
)
}
@@ -1,168 +0,0 @@
import { SearchOutlined } from "@ant-design/icons";
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
import { cn } from "@imphnen-frontend-service/utils";
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
import { ReactElement, useState } from "react";
import { ModalDetailSession } from "./_components/modal/detail";
type SessionStatus = 'ongoing' | 'finished';
interface SessionType {
id: string
mentorName: string
menteeName: string
datetime: number
status: SessionStatus
}
const mockData: SessionType[] = Array.from({ length: 90 }, (_, i) => ({
id: `DS-${i + 1}`,
mentorName: 'Ahmad Wijuana',
menteeName: 'Sofia Wijuana',
datetime: new Date().getTime(),
status: i % 2 === 0 ? 'ongoing' : 'finished',
}))
export default function Components(): ReactElement {
const [openDetail, setOpenDetail] = useState(false);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const columns: ColumnDef<SessionType>[] = [
{
id: 'select',
meta: { cellClassName: cn("w-20") },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'id',
header: 'ID Sesi',
accessorKey: 'id',
},
{
id: 'mentorName',
header: 'Nama Mentor',
accessorKey: 'name',
},
{
id: 'menteeName',
header: 'Nama Mentee',
accessorKey: 'name',
},
{
id: 'datetime',
header: 'Waktu',
accessorKey: 'datetime',
},
{
id: 'status',
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<SessionStatus, string> = {
ongoing: 'bg-warning-200 text-warning-700',
finished: 'bg-success-200 text-success-500',
};
const statusText: Record<SessionStatus, string> = {
ongoing: 'On Going',
finished: 'Finished',
};
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
},
{
header: 'Action',
meta: { cellClassName: cn("w-52") },
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setOpenDetail(true);
}}
className="flex items-center gap-2 w-max"
>
<SearchOutlined className="text-[16px]" /> Cek Detail
</Button>
),
},
]
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<BackofficeWrapper title="Dimentorin.dev">
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Session Management</h1>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-5 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama lengkap"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<Select>
<option selected disabled>Rating</option>
<option value="4.5">4.5</option>
<option value="5">5</option>
</Select>
<Select>
<option selected disabled>Status</option>
<option value="finished">Finished</option>
<option value="ongoing">On Going</option>
</Select>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</section>
<ModalDetailSession open={openDetail} setOpen={setOpenDetail} />
</BackofficeWrapper>
)
}
@@ -1,61 +0,0 @@
import { Button, Input, Select, ToggleInput } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils"
import { FC } from "react"
const labelClass = cn('text-neutral-800 text-[10px] font-medium mb-1.5 inline-block md:text-xs md:mb-2 xl:text-p3')
export const GeneralSettings: FC = () => {
return (
<div>
<h1 className="text-p2 font-semibold text-neutral-700 mb-8">General Settings</h1>
<div>
<ToggleInput label="Mode Maintenance" />
<h2 className="text-p3 font-semibold text-neutral-700 mb-5">Platform Settings</h2>
<div className="grid grid-cols-2 gap-x-8 gap-y-5 mb-8">
<div>
<label className={labelClass}>Nama Platform</label>
<Input type="text" className="min-w-full w-full" />
</div>
<div>
<label className={labelClass}>Bahasa</label>
<Select defaultValue="id" className="w-full">
<option value="id">Indonesia</option>
<option value="en">English</option>
</Select>
</div>
<div>
<label className={labelClass}>Logo Platform</label>
<Input type="file" className="min-w-full w-full" />
</div>
<div>
<label className={labelClass}>Favicon</label>
<Input type="file" className="min-w-full w-full" />
</div>
</div>
<h2 className="text-p3 font-semibold text-neutral-700 mb-5">Legal Settings</h2>
<div className="grid gap-x-8 gap-y-5 mb-20">
<div>
<label className={labelClass}>URL Syarat & Ketentuan</label>
<Input type="text" className="min-w-full w-full" />
</div>
<div>
<label className={labelClass}>URL Kebijakan Privasi</label>
<Input type="text" className="min-w-full w-full" />
</div>
</div>
<div className="flex justify-end gap-5">
<Button type="button" variant="bordered">
Batal
</Button>
<Button type="button">
Simpan
</Button>
</div>
</div>
</div>
)
}
@@ -1,34 +0,0 @@
import { Button, Textarea, ToggleInput } from "@imphnen-frontend-service/ui/atoms"
import { FC } from "react"
export const NotificationSettings: FC = () => {
return (
<div>
<h1 className="text-p2 font-semibold text-neutral-700 mb-8">Notification Settings</h1>
<div>
<div className="flex items-center gap-8 flex-wrap">
<ToggleInput label="Nyalakan Notifikasi Email" />
<ToggleInput label="Beritahu mentor ketika ada request" />
<ToggleInput label="Beritahu mentee untuk update sesi " />
</div>
<div className="mb-20">
<label className="text-neutral-800 font-medium inline-block mb-2 text-p3">
API Integrasi Notifikasi (URL)
</label>
<Textarea placeholder="Masukkan url API notifikasi yang akan digunakan" className="w-full h-40" />
</div>
<div className="flex justify-end gap-5">
<Button type="button" variant="bordered">
Batal
</Button>
<Button type="button">
Simpan
</Button>
</div>
</div>
</div>
)
}
@@ -1,50 +0,0 @@
import { Button, Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils"
import { FC } from "react"
const labelClass = cn('text-neutral-800 text-[10px] font-medium mb-1.5 inline-block md:text-xs md:mb-2 xl:text-p3')
export const PaymentSettings: FC = () => {
return (
<div>
<h1 className="text-p2 font-semibold text-neutral-700 mb-8">Payment</h1>
<div className="grid grid-cols-2 gap-x-8 gap-y-5 mb-8">
<div>
<label className={labelClass}>Integrasi Payment Gateway</label>
<Textarea className="min-w-full w-full h-20" placeholder="Durasi dalam menit" />
</div>
<div>
<label className={labelClass}>Mata Uang</label>
<Select defaultValue="idr">
<option value="idr">Rupiah (IDR)</option>
<option value="usd">Dollar (USD)</option>
</Select>
</div>
<div>
<label className={labelClass}>Harga sesi mentoring <span className="text-neutral-600">(default)</span></label>
<Input type="text" className="min-w-full w-full" placeholder="Masukkan harga sesi mentoring" />
</div>
<div>
<label className={labelClass}>Tarif Komisi untuk Platform</label>
<Input type="text" className="min-w-full w-full" placeholder="Masukkan persentase komisi" />
</div>
</div>
<h2 className="text-p3 font-semibold text-neutral-700 mb-5">Invoice</h2>
<div className="mb-32">
<label className={labelClass}>Masukkan Format Invoice</label>
<Input type="file" className="min-w-full w-full" placeholder="InvoiceDimentorin.png" />
</div>
<div className="flex justify-end gap-5">
<Button type="button" variant="bordered">
Batal
</Button>
<Button type="button">
Simpan
</Button>
</div>
</div>
)
}
@@ -1,35 +0,0 @@
import { Button, Input } from "@imphnen-frontend-service/ui/atoms";
import { cn } from "@imphnen-frontend-service/utils";
import { FC } from "react";
const labelClass = cn('text-neutral-800 text-[10px] font-medium mb-1.5 inline-block md:text-xs md:mb-2 xl:text-p3')
export const SecuritySettings: FC = () => {
return (
<div>
<h1 className="text-p2 font-semibold text-neutral-700 mb-8">Security Settings</h1>
<div className="grid grid-cols-2 gap-8 mb-20">
<div>
<label className={labelClass}>Durasi Session Timeout</label>
<Input type="text" className="min-w-full w-full" placeholder="Durasi dalam menit" />
<p className="text-[10px] text-neutral-800">Auto logout setelah X menit tidak aktif</p>
</div>
<div>
<label className={labelClass}>Blokir Setelah Upaya Gagal</label>
<Input type="text" className="min-w-full w-full" placeholder="x kali perobaan login" />
<p className="text-[10px] text-neutral-800">Misal: 5 kali salah login = lock akun</p>
</div>
</div>
<div className="flex justify-end gap-5">
<Button type="button" variant="bordered">
Batal
</Button>
<Button type="button">
Simpan
</Button>
</div>
</div>
)
}
@@ -1,118 +0,0 @@
import { DeleteOutlined, UserSwitchOutlined } from "@ant-design/icons";
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { DataTable } from "@imphnen-frontend-service/ui/organisms";
import { cn } from "@imphnen-frontend-service/utils";
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
import { FC, useState } from "react";
type UserRolesPermissionType = {
id: number
role: string
totalUser: number
}
const mockData: UserRolesPermissionType[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
role: ['Admin', 'Super Admin', 'Mentee', 'Mentor'][Math.floor(Math.random() * 4)],
totalUser: 10
}))
export const UserRolesPermission: FC = () => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const columns: ColumnDef<UserRolesPermissionType>[] = [
{
id: 'select',
meta: { cellClassName: cn("w-20") },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'role',
header: 'Role',
accessorKey: 'role',
},
{
id: 'totalUser',
header: 'Total User',
accessorKey: 'totalUser',
},
{
header: 'Action',
meta: { cellClassName: cn("w-96") },
cell: ({ row }) => (
<div className="flex items-center gap-4">
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-2 w-max"
>
<UserSwitchOutlined className="text-[16px]" /> Manage Permissions
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-2 w-max"
>
<DeleteOutlined className="text-[16px]" /> Delete Role
</Button>
</div>
),
},
]
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<div>
<div className="mb-8 flex items-center justify-between">
<h1 className="text-p2 font-semibold text-neutral-700">User Roles & Permissions</h1>
<Button type="button">
Add Rols
</Button>
</div>
<div className="bg-white shadow p-8 rounded-lg">
<DataTable data={mockData} columns={columns} table={table} />
</div>
</div>
)
}
@@ -1,53 +0,0 @@
import { BackofficeWrapper } from "@imphnen-frontend-service/ui/organisms"
import { cn, For } from "@imphnen-frontend-service/utils"
import { useState } from "react"
import { GeneralSettings } from "./_components/general"
import { UserRolesPermission } from "./_components/user-roles-permission"
import { NotificationSettings } from "./_components/notification"
import { SecuritySettings } from "./_components/security"
import { PaymentSettings } from "./_components/payment"
const TABS = {
general: "General Settings",
userRolePermissions: "User Roles & Permissions",
notification: "Notification Settings",
security: "Security",
payment: "Payment",
} as const
type Tabs = typeof TABS[keyof typeof TABS]
export default function Components(): React.ReactElement {
const [activeTab, setActiveTab] = useState<Tabs>(TABS.general)
return (
<BackofficeWrapper title="Dimentorin.dev">
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Settings</h1>
<div className="flex items-start gap-x-8">
<div className="w-64 bg-white p-2.5 shadow space-y-2 rounded-md">
<For data={Object.values(TABS)}>
{(tab) => (
<button
key={tab}
className={cn(
"px-4 py-3 w-full text-left font-medium rounded-md text-neutral-400 cursor-pointer select-none hover:bg-primary-100",
activeTab === tab && "bg-primary-500 text-white hover:bg-primary-600"
)}
onClick={() => setActiveTab(tab)}
>
{tab}
</button>
)}
</For>
</div>
<div className="bg-white px-8 py-6 shadow space-y-2 rounded-md flex-1">
{activeTab === TABS.general && <GeneralSettings />}
{activeTab === TABS.userRolePermissions && <UserRolesPermission />}
{activeTab === TABS.notification && <NotificationSettings />}
{activeTab === TABS.security && <SecuritySettings />}
{activeTab === TABS.payment && <PaymentSettings />}
</div>
</div>
</BackofficeWrapper>
)
}
@@ -1,55 +0,0 @@
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { Modal } from "@imphnen-frontend-service/ui/molecules";
import { FC } from "react";
type ModalDeleteProps = {
open: boolean
setOpen: (open: boolean) => void
hadnleDelete?: () => void
}
export const ModalDelete: FC<ModalDeleteProps> = ({ open, setOpen, hadnleDelete }) => {
return (
<Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
isOpen={open}
onClose={() => setOpen(false)}
closeButtonClassName="hidden"
>
<Modal.Header className="gap-8">
<img
src="/chibi-delete.webp"
alt="Delete item?"
width={148}
className="self-center"
/>
<div className="text-center">
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
Hapus Akun
</h2>
<p className="text-p3 text-neutral-400">
Apakah kamu yakin untuk menghapus akun ini?
</p>
</div>
</Modal.Header>
<Modal.Content className="flex gap-4">
<Button
variant="bordered"
size="lg"
className="w-full border-danger-500 text-danger-500 hover:bg-danger-50 hover:text-danger-600 hover:border-danger-600"
onClick={() => setOpen(false)}
>
Batal
</Button>
<Button
variant="danger"
size="lg"
className="w-full"
onClick={() => hadnleDelete?.()}
>
Hapus Akun
</Button>
</Modal.Content>
</Modal>
)
}
@@ -1,86 +0,0 @@
import { Button, Input } from "@imphnen-frontend-service/ui/atoms";
import { cn } from "@imphnen-frontend-service/utils";
import { FC, useState } from "react";
import { ModalDelete } from "../delete";
import { ModalProps } from "../type";
import { ModalSuspendOrBan } from "../suspend-or-ban";
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
export const AccountProfile: FC<ModalProps> = ({ setOpen }) => {
const [openDelete, setOpenDelete] = useState(false)
const [openSuspendOrBan, setOpenSuspendOrBan] = useState(false)
return (
<div>
<div className="flex items-center gap-x-8 mb-8">
<div className="size-[100px] rounded-full overflow-hidden">
<img src="/images/asd687hwq6nds4dfjj2983.webp" alt="Profile" className="w-full object-cover" />
</div>
<div>
<Button type="button" size="sm" variant="bordered" className="mb-4">
Upload Foto
</Button>
<p className="text-neutral-400 font-medium">
Setidaknya rekomendasi ukuran 240x240 px. <br />
.jpg, .jpeg, .png diperbolehkan
</p>
</div>
</div>
<div>
<div className="mb-8">
<h1 className="mb-7 text-p2 font-semibold">Informasi Pribadi</h1>
<div className="grid grid-cols-2 gap-8">
<div>
<label className={labelClass}>Nama Depan</label>
<Input type="text" className="min-w-full w-full" placeholder="Nama Depan" />
</div>
<div>
<label className={labelClass}>Nama Belakang</label>
<Input type="text" className="min-w-full w-full" placeholder="Nama Belakang" />
</div>
<div>
<label className={labelClass}>Email</label>
<Input type="email" className="min-w-full w-full" placeholder="Email" />
</div>
<div>
<label className={labelClass}>Nomor Telepon</label>
<Input type="text" className="min-w-full w-full" placeholder="+62 81234567890" />
</div>
</div>
</div>
<div>
<h1 className="mb-7 text-p2 font-semibold">Status Akun</h1>
<div className="flex items-center gap-5">
<Button
type="button"
size="sm"
variant="bordered"
className="border-danger-500 text-danger-500"
onClick={() => setOpenSuspendOrBan(true)}
>
Suspend/Ban
</Button>
<Button type="button" size="sm" variant="danger" onClick={() => setOpenDelete(true)}>
Delete Akun
</Button>
</div>
</div>
<div className="flex justify-end gap-x-5">
<Button type="button" variant="bordered" onClick={() => setOpen(false)}>
Batal
</Button>
<Button type="button" disabled>
Simpan
</Button>
</div>
</div>
<ModalDelete open={openDelete} setOpen={setOpenDelete} />
<ModalSuspendOrBan open={openSuspendOrBan} setOpen={setOpenSuspendOrBan} />
</div>
)
}
@@ -1,110 +0,0 @@
import { SearchOutlined } from "@ant-design/icons"
import { Input, Select } from "@imphnen-frontend-service/ui/atoms"
import { DataTable } from "@imphnen-frontend-service/ui/organisms"
import { cn } from "@imphnen-frontend-service/utils"
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table"
import { FC, useState } from "react"
import dayjs from "dayjs"
interface ActivityLogType {
id: number
date: string
menu: string
activity: string
}
const mockData: ActivityLogType[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
date: new Date().toISOString(),
menu: 'Mentoring',
activity: 'Mentoring Session',
}))
export const ActivityLog: FC = () => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
})
const columns: ColumnDef<ActivityLogType>[] = [
{
id: 'select',
meta: { cellClassName: cn("w-16") },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'date',
header: 'Waktu',
accessorKey: 'date',
cell: (info) => dayjs(info.row.original.date).format('DD MMMM YYYY, HH:mm WIB'),
},
{
id: 'menu',
header: 'Menu',
accessorKey: 'menu',
},
{
id: 'activity',
header: 'Activity',
accessorKey: 'activity',
}
]
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<div className="p-8 shadow rounded-lg">
<div className="flex justify-between items-center gap-5 mb-9">
<div className="relative w-1/2">
<Input
placeholder="Cari aktivitas"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="w-1/4">
<Input type="date" className="min-w-full w-full" />
</div>
<Select>
<option selected disabled>Menu</option>
<option value="profle">Profile</option>
<option value="profle-2">Profile</option>
</Select>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</div>
)
}
@@ -1,89 +0,0 @@
import { Icon } from "@iconify/react"
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { For } from "@imphnen-frontend-service/utils"
import { FC } from "react"
const SOCIAL_LINKS = [
{ icon: <Icon icon="mdi:linkedin" className="text-2xl" />, url: "https://linkedin.com", label: "LinkedIn" },
{ icon: <Icon icon="mdi:github" className="text-2xl" />, url: "https://github.com", label: "Github" },
{ icon: <Icon icon="mingcute:meta-line" className="text-2xl" />, url: "https://facebook.com", label: "Facebook (Meta)" },
{ icon: <Icon icon="mdi:stack-overflow" className="text-2xl" />, url: "https://stackoverflow.com", label: "Stack Overflow" }
]
export const DetailProfile: FC = () => {
return (
<div>
<div className="shadow rounded-lg p-8 mb-7">
<div className="flex justify-between items-start mb-6">
<div className="flex items-center gap-x-6">
<div className="size-[54px] rounded-full overflow-hidden">
<img src="/images/asd687hwq6nds4dfjj2983.webp" alt="Profile" className="w-full object-cover" />
</div>
<div>
<h1 className="text-p2 font-semibold text-neutral-800">Muhammad Firdaus Oiwobo</h1>
<p className="text-p3 font-medium text-neutral-600">Mentor</p>
</div>
</div>
<Button type="button" variant="text" className="bg-primary-100">
Actively Seeking Job
</Button>
</div>
<div className="flex items-center gap-5">
<For data={SOCIAL_LINKS}>
{({ icon, url, label }) => (
<a key={url} href={url} target="_blank" rel="noreferrer">
<Button type="button" size="sm" className="flex items-center gap-x-2">
{icon}
{label}
</Button>
</a>
)}
</For>
</div>
</div>
<div className="grid grid-cols-2 gap-7">
<div className="text-pretty px-8 py-10 rounded-lg shadow h-max">
<h1 className="text-p2 font-semibold text-neutral-800 mb-6">Description</h1>
<p className="text-p3 font-medium text-neutral-600">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
</p>
</div>
<div className="px-8 py-10 rounded-lg shadow h-max">
<h1 className="text-p2 font-semibold text-neutral-800 mb-6">Personal Informations</h1>
<div className="grid gap-6">
<div className="flex items-center gap-x-5">
<div className="bg-primary-50 rounded-full p-2.5 flex items-center justify-center text-primary-500">
<Icon icon="ic:outline-mail" className="text-3xl" />
</div>
<div className="text-p3">
<p className="text-neutral-800 font-semibold">rzalaxib23@gmail.com</p>
<p className="text-neutral-600 font-medium">Email Address</p>
</div>
</div>
<div className="flex items-center gap-x-5">
<div className="bg-primary-50 rounded-full p-2.5 flex items-center justify-center text-primary-500">
<Icon icon="cil:phone" className="text-3xl" />
</div>
<div className="text-p3">
<p className="text-neutral-800 font-semibold">+62 888 8888 8888</p>
<p className="text-neutral-600 font-medium">Phone Number</p>
</div>
</div>
<div className="flex items-center gap-x-5">
<div className="bg-primary-50 rounded-full p-2.5 flex items-center justify-center text-primary-500">
<Icon icon="ion:location-outline" className="text-3xl" />
</div>
<div className="text-p3">
<p className="text-neutral-800 font-semibold">Jl. Mergosari, Kec. Suryakencana, Banjaran</p>
<p className="text-neutral-600 font-medium">Location</p>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -1,68 +0,0 @@
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { Modal } from "@imphnen-frontend-service/ui/molecules"
import { cn, For, Show } from "@imphnen-frontend-service/utils"
import { FC, useEffect, useState } from "react"
import { AccountProfile } from "./account-profile"
import { DetailProfile } from "./detail-profile"
import { ActivityLog } from "./activity-log"
import { ModalDetailUserProps } from "./type"
const TABS = {
account: 'account profile',
detail: 'detail profile',
activity: 'activity logs',
} as const
type TabType = typeof TABS[keyof typeof TABS]
export const ModalDetailUser: FC<ModalDetailUserProps> = ({
open,
setOpen,
}) => {
const [activeTab, setActiveTab] = useState<TabType>(TABS.account)
useEffect(() => {
if (!open) setActiveTab(TABS.account)
}, [open])
return (
<Modal
isOpen={open}
onClose={() => setOpen(false)}
className="xl:max-w-[84rem] bg-white px-10 py-9"
closeButtonClassName="hidden"
>
<div>
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
Detail - Muhammad Firdaus Oiwobo
</h1>
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md w-max mb-8">
<For data={Object.values(TABS)}>
{(tab) => (
<Button
key={tab}
variant="text"
className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
onClick={() => setActiveTab(tab)}
>
{tab}
</Button>
)}
</For>
</div>
<div>
<Show condition={activeTab === TABS.account}>
<AccountProfile open={open} setOpen={setOpen} />
</Show>
<Show condition={activeTab === TABS.detail}>
<DetailProfile />
</Show>
<Show condition={activeTab === TABS.activity}>
<ActivityLog />
</Show>
</div>
</div>
</Modal>
)
}
@@ -1,5 +0,0 @@
import { ModalProps } from "../type";
export interface ModalDetailUserProps extends ModalProps {
userId?: number | null
}
@@ -1,42 +0,0 @@
import { FC } from "react";
import { ModalProps } from "../type";
import { Modal } from "@imphnen-frontend-service/ui/molecules";
import { cn } from "@imphnen-frontend-service/utils";
import { Button, Select, Textarea } from "@imphnen-frontend-service/ui/atoms";
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
export const ModalSuspendOrBan: FC<ModalProps> = ({ open, setOpen }) => {
return (
<Modal
isOpen={open}
onClose={() => setOpen(false)}
className="bg-white px-10 py-9 xl:max-w-[32rem]"
closeButtonClassName="hidden"
>
<div>
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
Suspend/Ban
</h1>
<div className="space-y-6">
<div>
<label className={labelClass}>Suspend/Ban</label>
<Select defaultValue="suspend" className="w-full">
<option value="suspend">Suspend</option>
<option value="banned">Banned</option>
</Select>
</div>
<div>
<label className={labelClass}>Alasan</label>
<Textarea placeholder="Masukkan alasan suspend/ban" className="w-full h-40" />
</div>
<Button type="button" className="w-full">
Selesai
</Button>
</div>
</div>
</Modal>
)
}
@@ -1,4 +0,0 @@
export interface ModalProps {
open: boolean
setOpen: (open: boolean) => void
}
@@ -1,206 +0,0 @@
import { SearchOutlined } from '@ant-design/icons';
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
import { cn, For } from '@imphnen-frontend-service/utils';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import { ReactElement, useState } from 'react';
import { ModalDetailUser } from './_components/modal/detail';
type UserStatus = 'active' | 'inactive';
interface UserType {
id: number;
name: string;
email: string;
rating: number;
status: UserStatus;
}
const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
name: i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana',
email: 'fullname23@gmail.com',
rating: 4.5,
status: i % 2 === 0 ? 'active' : 'inactive',
}));
export default function Components(): ReactElement {
const TABS = ['mentor', 'mentee'] as const;
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor');
const [showDetail, setShowDetail] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const columns: ColumnDef<UserType>[] = [
{
id: 'select',
meta: { cellClassName: cn('w-20') },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'name',
header: 'Name',
accessorKey: 'name',
},
{
id: 'email',
header: 'Email',
accessorKey: 'email',
},
{
id: 'rating',
header: 'Rating',
accessorKey: 'rating',
},
{
id: 'status',
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<UserStatus, string> = {
active: 'bg-success-200 text-success-500',
inactive: 'bg-danger-200 text-danger-500',
};
const statusText: Record<UserStatus, string> = {
active: 'Active',
inactive: 'Inactive',
};
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
},
{
header: 'Action',
meta: { cellClassName: cn('w-72') },
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setSelectedUserId(row.original.id);
setShowDetail(true);
}}
className="flex items-center gap-2 w-max"
>
<SearchOutlined className="text-[16px]" /> Lihat Detail & Action
</Button>
),
},
];
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<BackofficeWrapper title="Dimentorin.dev">
<div className="mb-8 flex justify-between items-center">
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">
User Management
</h1>
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
<For data={TABS}>
{(tab) => (
<Button
key={tab}
variant="text"
className={cn(
'px-3 py-2 capitalize',
activeTab === tab && 'bg-white'
)}
onClick={() => setActiveTab(tab)}
>
{tab}
</Button>
)}
</For>
</div>
</div>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-5 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama lengkap"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<Select>
<option selected disabled>
Rating
</option>
<option value="4.5">4.5</option>
<option value="5">5</option>
</Select>
<Select>
<option selected disabled>
Status
</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</Select>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</section>
<ModalDetailUser
open={showDetail}
setOpen={setShowDetail}
userId={selectedUserId}
/>
</BackofficeWrapper>
);
}
@@ -2,39 +2,22 @@ import { useForm } from 'react-hook-form';
import {
authLoginSchema,
TLoginRequest,
useBackofficeLogin,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useNavigate } from 'react-router';
import { toast } from 'sonner';
import { useSession } from '@imphnen-frontend-service/utils';
export const useLogin = () => {
const navigate = useNavigate();
const loginMutation = useBackofficeLogin();
const form = useForm<TLoginRequest>({
resolver: zodResolver(authLoginSchema),
mode: 'all',
defaultValues: {
email: '',
password: '',
},
});
const onSubmit = form.handleSubmit(async (data) => {
try {
await loginMutation.mutateAsync(data);
toast.success('Login berhasil!');
navigate('/hackathon-dashboard');
} catch (error) {
console.error('[Backoffice Login] Error:', error);
toast.error((error as Error).message || 'Login gagal');
}
});
const { signIn } = useSession();
const onSubmit = form.handleSubmit((data) => signIn(data));
return {
form,
onSubmit,
isLoading: loginMutation.isPending,
};
};
@@ -4,7 +4,7 @@ import { useLogin } from './_hooks/use-login';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
export const Components: FC = (): ReactElement => {
const { form, onSubmit, isLoading } = useLogin();
const { form, onSubmit } = useLogin();
return (
<div className="flex justify-center items-center min-h-screen">
@@ -22,7 +22,6 @@ export const Components: FC = (): ReactElement => {
name="email"
size="lg"
className="w-full"
disabled={isLoading}
/>
<ControlledInputField
control={form.control}
@@ -32,11 +31,10 @@ export const Components: FC = (): ReactElement => {
name="password"
size="lg"
className="w-full"
disabled={isLoading}
/>
<Button
disabled={
isLoading ||
form.formState.isSubmitting ||
form.formState.isValidating ||
!form.formState.isValid
}
@@ -44,7 +42,7 @@ export const Components: FC = (): ReactElement => {
size="md"
className="w-full"
>
{isLoading ? 'Loading...' : 'Login'}
Login
</Button>
</form>
</div>
-23
View File
@@ -1,23 +0,0 @@
import { Link } from 'react-router-dom';
export default function NotFoundPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<h1 className="text-9xl font-bold text-gray-200 mb-4">404</h1>
<h2 className="text-3xl font-semibold text-gray-900 dark:text-gray-300 mb-4">
Page Not Found
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
The page you are looking for doesn't exist or has been moved.
</p>
<Link
to="/hackathon-dashboard"
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
Go Back Home
</Link>
</div>
</div>
);
}
-29
View File
@@ -1,29 +0,0 @@
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
export default function ErrorPage() {
const error = useRouteError();
let errorMessage: string;
if (isRouteErrorResponse(error)) {
errorMessage = error.statusText;
} else if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === 'string') {
errorMessage = error;
} else {
console.error(error);
errorMessage = 'Unknown error';
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-6xl font-bold text-red-600 mb-4">Oops!</h1>
<p className="text-xl text-gray-700 mb-2">
Sorry, an unexpected error has occurred.
</p>
<p className="text-gray-500 italic">{errorMessage}</p>
</div>
</div>
);
}
@@ -1,148 +0,0 @@
import { FC, useState, useRef, useEffect } from 'react';
import { FilterOutlined } from '@ant-design/icons';
import INDONESIAN_CITIES from '../constants/cities';
interface CityFilterSelectProps {
value: string;
onChange: (value: string) => void;
className?: string;
placeholder?: string;
allOptionLabel?: string;
filterIcon?: boolean;
}
export const CityFilterSelect: FC<CityFilterSelectProps> = ({
value,
onChange,
className = '',
placeholder = 'Search cities...',
allOptionLabel = 'All Cities',
filterIcon = true,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Filter cities based on search query
const filteredCities = INDONESIAN_CITIES.filter((city) =>
city.toLowerCase().includes(searchQuery.toLowerCase())
);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setSearchQuery('');
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelectCity = (city: string) => {
onChange(city);
setSearchQuery('');
setIsOpen(false);
};
const handleInputClick = () => {
setIsOpen(true);
setSearchQuery('');
};
const handleClearSelection = () => {
onChange('all');
setSearchQuery('');
setIsOpen(false);
};
const displayValue = value === 'all' ? allOptionLabel : value;
const showClearButton = value !== 'all' && !isOpen;
return (
<div className={`relative ${className}`} ref={dropdownRef}>
<div className="relative">
{filterIcon && (
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
)}
<input
ref={inputRef}
type="text"
value={isOpen ? searchQuery : displayValue}
onChange={(e) => {
setSearchQuery(e.target.value);
if (!isOpen) setIsOpen(true);
}}
onClick={handleInputClick}
onFocus={handleInputClick}
placeholder={isOpen ? placeholder : displayValue}
className={`border border-neutral-200 rounded-lg pr-10 py-2.5 text-sm w-full focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer ${
filterIcon ? ' pl-10' : 'pl-3'
}`}
/>
{showClearButton && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleClearSelection();
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 text-xs cursor-pointer z-20"
>
</button>
)}
</div>
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{/* All Cities Option */}
<div
onClick={() => handleSelectCity('all')}
className={`px-3 py-2 cursor-pointer hover:bg-neutral-50 border-b border-neutral-100 ${
value === 'all'
? 'bg-primary-50 text-primary-700 font-medium'
: 'text-neutral-900'
}`}
>
{allOptionLabel}
</div>
{/* Filtered Cities */}
{filteredCities.length > 0 ? (
<div className="py-1">
{filteredCities.slice(0, 100).map((city) => (
<div
key={city}
onClick={() => handleSelectCity(city)}
className={`px-3 py-2 cursor-pointer hover:bg-neutral-50 text-sm ${
value === city
? 'bg-primary-50 text-primary-700 font-medium'
: 'text-neutral-700'
}`}
>
{city}
</div>
))}
{filteredCities.length > 100 && (
<div className="px-3 py-2 text-xs text-neutral-500 border-t border-neutral-100">
Showing first 100 results. Continue typing to refine...
</div>
)}
</div>
) : searchQuery ? (
<div className="px-3 py-2 text-neutral-500 text-sm">
No cities found matching "{searchQuery}"
</div>
) : null}
</div>
)}
</div>
);
};
-518
View File
@@ -1,518 +0,0 @@
const INDONESIAN_CITIES: string[] = [
'Aceh Selatan',
'Aceh Tenggara',
'Aceh Timur',
'Aceh Tengah',
'Aceh Barat',
'Aceh Besar',
'Pidie',
'Aceh Utara',
'Simeulue',
'Aceh Singkil',
'Bireuen',
'Aceh Barat Daya',
'Gayo Lues',
'Aceh Jaya',
'Nagan Raya',
'Aceh Tamiang',
'Bener Meriah',
'Pidie Jaya',
'Kota Banda Aceh',
'Kota Sabang',
'Kota Lhokseumawe',
'Kota Langsa',
'Kota Subulussalam',
'Tapanuli Tengah',
'Tapanuli Utara',
'Tapanuli Selatan',
'Nias',
'Langkat',
'Karo',
'Deli Serdang',
'Simalungun',
'Asahan',
'Labuhanbatu',
'Dairi',
'Toba',
'Mandailing Natal',
'Nias Selatan',
'Pakpak Bharat',
'Humbang Hasundutan',
'Samosir',
'Serdang Bedagai',
'Batu Bara',
'Padang Lawas Utara',
'Padang Lawas',
'Labuhanbatu Selatan',
'Labuhanbatu Utara',
'Nias Utara',
'Nias Barat',
'Kota Medan',
'Kota Pematangsiantar',
'Kota Sibolga',
'Kota Tanjung Balai',
'Kota Binjai',
'Kota Tebing Tinggi',
'Kota Padangsidimpuan',
'Kota Gunungsitoli',
'Pesisir Selatan',
'Solok',
'Sijunjung',
'Tanah Datar',
'Padang Pariaman',
'Agam',
'Lima Puluh Kota',
'Pasaman',
'Kepulauan Mentawai',
'Dharmasraya',
'Solok Selatan',
'Pasaman Barat',
'Kota Padang',
'Kota Solok',
'Kota Sawahlunto',
'Kota Padang Panjang',
'Kota Bukittinggi',
'Kota Payakumbuh',
'Kota Pariaman',
'Kampar',
'Indragiri Hulu',
'Bengkalis',
'Indragiri Hilir',
'Pelalawan',
'Rokan Hulu',
'Rokan Hilir',
'Siak',
'Kuantan Singingi',
'Kepulauan Meranti',
'Kota Pekanbaru',
'Kota Dumai',
'Kerinci',
'Merangin',
'Sarolangun',
'Batanghari',
'Muaro Jambi',
'Tanjung Jabung Barat',
'Tanjung Jabung Timur',
'Bungo',
'Tebo',
'Kota Jambi',
'Kota Sungai Penuh',
'Ogan Komering Ulu',
'Ogan Komering Ilir',
'Muara Enim',
'Lahat',
'Musi Rawas',
'Musi Banyuasin',
'Banyuasin',
'Ogan Komering Ulu Timur',
'Ogan Komering Ulu Selatan',
'Ogan Ilir',
'Empat Lawang',
'Penukal Abab Lematang Ilir',
'Musi Rawas Utara',
'Kota Palembang',
'Kota Pagar Alam',
'Kota Lubuk Linggau',
'Kota Prabumulih',
'Bengkulu Selatan',
'Rejang Lebong',
'Bengkulu Utara',
'Kaur',
'Seluma',
'Muko Muko',
'Lebong',
'Kepahiang',
'Bengkulu Tengah',
'Kota Bengkulu',
'Lampung Selatan',
'Lampung Tengah',
'Lampung Utara',
'Lampung Barat',
'Tulang Bawang',
'Tanggamus',
'Lampung Timur',
'Way Kanan',
'Pesawaran',
'Pringsewu',
'Mesuji',
'Tulang Bawang Barat',
'Pesisir Barat',
'Kota Bandar Lampung',
'Kota Metro',
'Bangka',
'Belitung',
'Bangka Selatan',
'Bangka Tengah',
'Bangka Barat',
'Belitung Timur',
'Kota Pangkal Pinang',
'Bintan',
'Karimun',
'Natuna',
'Lingga',
'Kepulauan Anambas',
'Kota Batam',
'Kota Tanjung Pinang',
'Kepulauan Seribu',
'Kota Jakarta Pusat',
'Kota Jakarta Utara',
'Kota Jakarta Barat',
'Kota Jakarta Selatan',
'Kota Jakarta Timur',
'Bogor',
'Sukabumi',
'Cianjur',
'Bandung',
'Garut',
'Tasikmalaya',
'Ciamis',
'Kuningan',
'Cirebon',
'Majalengka',
'Sumedang',
'Indramayu',
'Subang',
'Purwakarta',
'Karawang',
'Bekasi',
'Bandung Barat',
'Pangandaran',
'Kota Bogor',
'Kota Sukabumi',
'Kota Bandung',
'Kota Cirebon',
'Kota Bekasi',
'Kota Depok',
'Kota Cimahi',
'Kota Tasikmalaya',
'Kota Banjar',
'Cilacap',
'Banyumas',
'Purbalingga',
'Banjarnegara',
'Kebumen',
'Purworejo',
'Wonosobo',
'Magelang',
'Boyolali',
'Klaten',
'Sukoharjo',
'Wonogiri',
'Karanganyar',
'Sragen',
'Grobogan',
'Blora',
'Rembang',
'Pati',
'Kudus',
'Jepara',
'Demak',
'Semarang',
'Temanggung',
'Kendal',
'Batang',
'Pekalongan',
'Pemalang',
'Tegal',
'Brebes',
'Kota Magelang',
'Kota Surakarta',
'Kota Salatiga',
'Kota Semarang',
'Kota Pekalongan',
'Kota Tegal',
'Kulon Progo',
'Bantul',
'Gunungkidul',
'Sleman',
'Kota Yogyakarta',
'Pacitan',
'Ponorogo',
'Trenggalek',
'Tulungagung',
'Blitar',
'Kediri',
'Malang',
'Lumajang',
'Jember',
'Banyuwangi',
'Bondowoso',
'Situbondo',
'Probolinggo',
'Pasuruan',
'Sidoarjo',
'Mojokerto',
'Jombang',
'Nganjuk',
'Madiun',
'Magetan',
'Ngawi',
'Bojonegoro',
'Tuban',
'Lamongan',
'Gresik',
'Bangkalan',
'Sampang',
'Pamekasan',
'Sumenep',
'Kota Kediri',
'Kota Blitar',
'Kota Malang',
'Kota Probolinggo',
'Kota Pasuruan',
'Kota Mojokerto',
'Kota Madiun',
'Kota Surabaya',
'Kota Batu',
'Pandeglang',
'Lebak',
'Tangerang',
'Serang',
'Kota Tangerang',
'Kota Cilegon',
'Kota Serang',
'Kota Tangerang Selatan',
'Jembrana',
'Tabanan',
'Badung',
'Gianyar',
'Klungkung',
'Bangli',
'Karangasem',
'Buleleng',
'Kota Denpasar',
'Lombok Barat',
'Lombok Tengah',
'Lombok Timur',
'Sumbawa',
'Dompu',
'Bima',
'Sumbawa Barat',
'Lombok Utara',
'Kota Mataram',
'Kota Bima',
'Kupang',
'Timor Tengah Selatan',
'Timor Tengah Utara',
'Belu',
'Alor',
'Flores Timur',
'Sikka',
'Ende',
'Ngada',
'Manggarai',
'Sumba Timur',
'Sumba Barat',
'Lembata',
'Rote Ndao',
'Manggarai Barat',
'Nagekeo',
'Sumba Tengah',
'Sumba Barat Daya',
'Manggarai Timur',
'Sabu Raijua',
'Malaka',
'Kota Kupang',
'Sambas',
'Mempawah',
'Sanggau',
'Ketapang',
'Sintang',
'Kapuas Hulu',
'Bengkayang',
'Landak',
'Sekadau',
'Melawi',
'Kayong Utara',
'Kubu Raya',
'Kota Pontianak',
'Kota Singkawang',
'Kotawaringin Barat',
'Kotawaringin Timur',
'Kapuas',
'Barito Selatan',
'Barito Utara',
'Katingan',
'Seruyan',
'Sukamara',
'Lamandau',
'Gunung Mas',
'Pulang Pisau',
'Murung Raya',
'Barito Timur',
'Kota Palangkaraya',
'Tanah Laut',
'Kotabaru',
'Banjar',
'Barito Kuala',
'Tapin',
'Hulu Sungai Selatan',
'Hulu Sungai Tengah',
'Hulu Sungai Utara',
'Tabalong',
'Tanah Bumbu',
'Balangan',
'Kota Banjarmasin',
'Kota Banjarbaru',
'Paser',
'Kutai Kartanegara',
'Berau',
'Kutai Barat',
'Kutai Timur',
'Penajam Paser Utara',
'Mahakam Ulu',
'Kota Balikpapan',
'Kota Samarinda',
'Kota Bontang',
'Bulungan',
'Malinau',
'Nunukan',
'Tana Tidung',
'Kota Tarakan',
'Bolaang Mongondow',
'Minahasa',
'Kepulauan Sangihe',
'Kepulauan Talaud',
'Minahasa Selatan',
'Minahasa Utara',
'Minahasa Tenggara',
'Bolaang Mongondow Utara',
'Kepulauan Siau Tagulandang Biaro (Sitaro)',
'Bolaang Mongondow Timur',
'Bolaang Mongondow Selatan',
'Kota Manado',
'Kota Bitung',
'Kota Tomohon',
'Kota Kotamobagu',
'Banggai',
'Poso',
'Donggala',
'Toli Toli',
'Buol',
'Morowali',
'Banggai Kepulauan',
'Parigi Moutong',
'Tojo Una Una',
'Sigi',
'Banggai Laut',
'Morowali Utara',
'Kota Palu',
'Kepulauan Selayar',
'Bulukumba',
'Bantaeng',
'Jeneponto',
'Takalar',
'Gowa',
'Sinjai',
'Bone',
'Maros',
'Pangkajene Kepulauan',
'Barru',
'Soppeng',
'Wajo',
'Sidenreng Rappang',
'Pinrang',
'Enrekang',
'Luwu',
'Tana Toraja',
'Luwu Utara',
'Luwu Timur',
'Toraja Utara',
'Kota Makassar',
'Kota Pare Pare',
'Kota Palopo',
'Kolaka',
'Konawe',
'Muna',
'Buton',
'Konawe Selatan',
'Bombana',
'Wakatobi',
'Kolaka Utara',
'Konawe Utara',
'Buton Utara',
'Kolaka Timur',
'Konawe Kepulauan',
'Muna Barat',
'Buton Tengah',
'Buton Selatan',
'Kota Kendari',
'Kota Bau Bau',
'Gorontalo',
'Boalemo',
'Bone Bolango',
'Pahuwato',
'Gorontalo Utara',
'Kota Gorontalo',
'Pasangkayu (Mamuju Utara)',
'Mamuju',
'Mamasa',
'Polewali Mandar',
'Majene',
'Mamuju Tengah',
'Maluku Tengah',
'Maluku Tenggara',
'Kepulauan Tanimbar (Maluku Tenggara Barat)',
'Buru',
'Seram Bagian Timur',
'Seram Bagian Barat',
'Kepulauan Aru',
'Maluku Barat Daya',
'Buru Selatan',
'Kota Ambon',
'Kota Tual',
'Halmahera Barat',
'Halmahera Tengah',
'Halmahera Utara',
'Halmahera Selatan',
'Kepulauan Sula',
'Halmahera Timur',
'Pulau Morotai',
'Pulau Taliabu',
'Kota Ternate',
'Kota Tidore Kepulauan',
'Jayapura',
'Kepulauan Yapen',
'Biak Numfor',
'Sarmi',
'Keerom',
'Waropen',
'Supiori',
'Mamberamo Raya',
'Kota Jayapura',
'Manokwari',
'Fak Fak',
'Teluk Bintuni',
'Teluk Wondama',
'Kaimana',
'Manokwari Selatan',
'Pegunungan Arfak',
'Merauke',
'Boven Digoel',
'Mappi',
'Asmat',
'Nabire',
'Puncak Jaya',
'Paniai',
'Mimika',
'Puncak',
'Dogiyai',
'Intan Jaya',
'Deiyai',
'Jayawijaya',
'Pegunungan Bintang',
'Yahukimo',
'Tolikara',
'Mamberamo Tengah',
'Yalimo',
'Lanny Jaya',
'Nduga',
'Sorong',
'Sorong Selatan',
'Raja Ampat',
'Tambrauw',
'Maybrat',
'Kota Sorong',
];
export default INDONESIAN_CITIES;
+2 -2
View File
@@ -130,7 +130,7 @@
html {
font-family: 'Bai Jamjuree', sans-serif;
font-weight: 400;
font-size: 14px;
font-size: 12px;
line-height: 1.2;
}
}
}
+2 -2
View File
@@ -84,7 +84,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
if (mappingPublicRoutes.includes(pathname)) {
if (token) return redirect('/hackathon-dashboard');
if (token) return redirect('/dashboard');
return null;
}
@@ -100,7 +100,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
if (!hasPermission) {
return '/hackathon-dashboard';
return '/dashboard';
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/apps/backoffice',
server: {
port: 3003,
port: 3000,
host: 'localhost',
},
preview: {
-1
View File
@@ -1 +0,0 @@
Tue, Nov 25, 2025 4:21:27 PM
-2
View File
@@ -1,2 +0,0 @@
# Deployment trigger
# Updated to deploy circular dependency fixes and auth hooks
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

@@ -3,7 +3,7 @@ import { motion } from 'framer-motion'
export function HeroSection() {
return (
<section className="relative w-full z-0 py-20 md:py-28 lg:py-32">
<section className="relative w-full py-20 md:py-28 lg:py-32">
{/* Background gradients and shapes */}
<div className="absolute inset-0 -z-10 overflow-x-clip hidden md:block">
<motion.div
@@ -2,8 +2,8 @@ import { CloseOutlined, MenuOutlined } from "@ant-design/icons";
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { cn, For, Show } from "@imphnen-frontend-service/utils";
import { motion, useMotionValueEvent, useScroll, Variants } from "framer-motion";
import { FC, useMemo, useState } from "react";
import { Link, NavLink, useLocation } from "react-router-dom";
import { FC, useState } from "react";
import { Link, useLocation } from "react-router-dom";
const MENUS: { label: string; href: string }[] = [
{ label: "Home", href: "/" },
@@ -30,15 +30,8 @@ export const Header: FC = () => {
else setShow(true)
})
const isActive = useMemo(() => {
return (path: string) => {
if (path === '/' && location.pathname !== '/') return false
return location.pathname.includes(path)
}
}, [location.pathname]);
return (
<div className={cn("w-full px-8 pt-8 top-0 z-50 md:px-[60px] md:pt-[60px] lg:px-20 sticky", !show && "overflow-hidden")}>
<div className="w-full px-8 pt-8 top-0 z-50 md:px-[60px] md:pt-[60px] lg:px-20 sticky overflow-hidden">
<motion.header
className="bg-white shadow-lg rounded-lg h-12 flex justify-between w-full max-w-7xl xl:mx-auto md:h-[60px] lg:h-[71px]"
aria-roledescription="nav"
@@ -70,22 +63,18 @@ export const Header: FC = () => {
variant="text"
className={cn(
"px-10 py-1.5 text-neutral-300 hover:text-neutral-400 lg:px-2.5 lg:py-2",
isActive(menu.href) && "text-primary-500 hover:text-primary-500"
location.pathname === menu.href && "text-primary-500 hover:text-primary-500"
)}
>
<NavLink to={menu.href}>{menu.label}</NavLink>
<Link to={menu.href}>{menu.label}</Link>
</Button>
)}
</For>
<Link to="/auth/login">
<Button type="button" className="px-12 py-1 lg:hidden">Login</Button>
</Link>
<Button type="button" className="px-12 py-1 md:hidden">Login</Button>
</nav>
<div>
<Link to="/auth/login">
<Button type="button" className="px-5 py-2 hidden lg:block">Login</Button>
</Link>
<Button type="button" className="px-5 py-2 hidden lg:block">Login</Button>
<Button
type="button"
variant="text"
@@ -1,88 +0,0 @@
import { Icon } from "@iconify/react";
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { cn } from "@imphnen-frontend-service/utils";
import { motion } from "framer-motion";
export default function DetailArticle() {
return (
<main>
<motion.section
className="w-full p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, ease: "easeInOut" }}
>
<div className="w-full max-w-[900px] mx-auto mb-8 md:mb-[60px]">
<div className="mb-10 flex items-center justify-between">
<Button
type="button"
variant="text"
className="flex gap-x-2 text-[10px] px-2.5 py-2 md:text-[15px]"
>
<Icon icon="material-symbols:arrow-back-rounded" />
<span>Back</span>
</Button>
<Button
type="button"
variant="text"
className="flex gap-x-2 text-[10px] px-2.5 py-2 md:text-[15px]"
>
<span>Read Next</span>
<Icon icon="material-symbols:arrow-forward-rounded" />
</Button>
</div>
<div className="text-center leading-tight">
<h1
className="text-[23px] text-neutral-800 font-semibold mb-6 md:text-[37px] md:mb-10 xl:text-[46px]"
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</h1>
<p
className="text-[15px] font-semibold text-neutral-800 mb-2 md:text-[19px] md:mb-1 xl:text-[23px] xl:mb-2"
>
Writer&rsquo;s Name
</p>
<p className="text-xs text-neutral-600 mb-6 md:text-[15px] xl:text-[19px]">
14 March 2025
</p>
<div
className="text-[10px] text-primary-600 font-medium bg-primary-200 px-3 py-1 rounded-4xl w-max mx-auto md:text-xs xl:text-[15px]"
>
5 min read
</div>
</div>
</div>
<div className="w-full max-w-[900px] mx-auto">
<div className="mb-8 w-full h-auto aspect-16/10 overflow-hidden md:aspect-31/16 md:mb-[60px] xl:aspect-45/26">
<img src="/image/59de8a682dfbe42455613ea07477ff3e30532c1f.webp" alt="Cover Article" className="size-full object-cover" />
</div>
<div
className={cn(
"text-balance text-[15px] font-medium text-neutral-600 space-y-8",
"md:space-y-12 md:text-[19px] xl:text-[23px] xl:max-w-[780px] xl:mx-auto",
)}
>
<h2 className="text-[19px] text-neutral-800 font-semibold leading-tight md:text-[23px] xl:text-[29px]">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus.
</h2>
<p className="leading-tight">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
</p>
<img src="/image/code-article.webp" alt="Code" className="w-full h-auto mb-12" />
<p className="leading-tight">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
</p>
<p className="leading-tight">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
</p>
</div>
</div>
</motion.section>
</main>
)
}
@@ -1,74 +0,0 @@
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { cn, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react"
import { Link } from "react-router-dom"
export type ArticleCardProps = {
variant?: "default" | "recent" | "featured"
}
export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default' }) => {
return (
<div
className={cn(
variant === "recent" && "md:grid md:grid-cols-2 md:gap-7 xl:flex xl:items-center",
)}
>
<div
className={cn(
"w-full h-auto aspect-16/10 overflow-hidden mb-8 md:h-60 md:w-full xl:aspect-auto",
variant === "featured" && "md:h-[300px]",
variant === "recent" && "md:h-auto md:w-full md:aspect-15/7 md:mb-0 xl:aspect-square xl:w-[162px] xl:h-auto",
)}
>
<img
src="/image/59de8a682dfbe42455613ea07477ff3e30532c1f.webp"
alt="Cover's Article"
className={cn("w-full object-cover", variant === "recent" && "h-full")}
/>
</div>
<div className={cn("leading-tight", variant === "recent" && "xl:flex-1")}>
<div className="w-max bg-primary-200 text-primary-600 px-3 py-1 text-xs font-medium rounded-4xl mb-4 xl:mb-[15px]">
5 min read
</div>
<Link
to="/articles/detail"
className={cn(
"text-[15px] text-neutral-800 font-semibold mb-4 line-clamp-2 md:text-[19px] xl:mb-[23px]",
variant === "featured" && "text-[19px] md:text-[23px]",
variant === "recent" && "mb-7 xl:text-[27px]"
)}
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</Link>
<Show condition={variant !== "recent"}>
<p
className={cn(
"text-xs text-neutral-600 mb-6 md:text-[15px] md:mb-8 xl:text-[19px]",
variant === "featured" && "text-[15px] md:text-[19px] md:mb-6",
)}
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris.
</p>
</Show>
<div
className={cn(
"flex justify-between items-center text-xs text-neutral-600 md:text-[15px] xl:text-[19px]",
variant === "default" && "mb-4",
variant === "featured" && "text-[15px] md:text-[19px] xl:text-base",
)}
>
<p>Writers Name</p>
<p>14 March 2025</p>
</div>
<Show condition={variant === "default"}>
<Button type="button" size="sm" className="px-2.5 py-2">
Baca Artikel
</Button>
</Show>
</div>
</div>
)
}
@@ -1,122 +0,0 @@
import { ReactElement, useRef, useState } from "react";
import { ArticleCard } from "./_components/card/article";
import { cn, For } from "@imphnen-frontend-service/utils";
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { motion, useInView, Variants } from "framer-motion";
const CATEGORIES = ['UI/UX Design', 'Software/Web Dev', 'Data & AI', 'Cloud & DevOps', 'Cybersecurity', 'IT & Network', 'Project Management', 'QA & Testing'] as const
type Category = typeof CATEGORIES[number]
export default function Components(): ReactElement {
const [activeTab, setActiveTab] = useState<Category>('UI/UX Design')
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}
const childVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
return (
<main ref={ref}>
<motion.section
className="w-full p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20"
variants={containerVariants}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
>
<div className="max-w-7xl mx-auto mb-8 md:mb-20">
<motion.h1
className="text-[23px] font-semibold text-neutral-800 mb-9 md:text-[29px] xl:text-[46px] xl:font-bold"
variants={childVariants}
>
Featuring Articles
</motion.h1>
<div className="grid gap-12 xl:grid-cols-2">
<motion.div variants={childVariants}>
<ArticleCard variant="featured" />
</motion.div>
<div>
<motion.h1
className="text-[19px] text-neutral-800 font-semibold mb-10 md:text-[29px] xl:text-[37px] xl:mb-12"
variants={childVariants}
>
Recent Articles
</motion.h1>
<div className="grid gap-10">
<For data={Array.from({ length: 3 })}>
{(_, index) => (
<motion.div key={index} variants={childVariants}>
<ArticleCard variant="recent" />
</motion.div>
)}
</For>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto">
<motion.h1
className="text-[19px] text-neutral-800 font-semibold mb-4 md:text-[23px] xl:text-[29px]"
variants={childVariants}
>
Category
</motion.h1>
<div className="scrollbar-hide mb-8 w-full overflow-auto mx-auto">
<motion.div className="min-w-max w-auto flex gap-x-3" variants={childVariants}>
<For data={CATEGORIES}>
{(category) => (
<Button
key={category}
type="button"
variant="text"
onClick={() => setActiveTab(category)}
className={cn(
"relative text-[10px] text-neutral-400 font-medium px-3 py-2 rounded-4xl md:text-xs md:font-semibold xl:text-[15px]",
"before:w-0 before:absolute before:h-0.5 before:mx-auto before:inset-x-0 before:bg-primary-500 hover:before:w-full before:bottom-0 before:left-0 before:transition-all before:duration-300",
activeTab === category && "text-primary-500 before:w-full"
)}
>
{category}
</Button>
)}
</For>
</motion.div>
</div>
<div className="grid gap-8 xl:grid-cols-3">
<For data={Array.from({ length: 3 })}>
{(_, index) => (
<motion.div key={index} variants={childVariants}>
<ArticleCard />
</motion.div>
)}
</For>
</div>
</div>
</motion.section>
</main>
)
}
+1 -1
View File
@@ -6,7 +6,7 @@ export default function Layout() {
return (
<div className="flex min-h-screen flex-col bg-primary-50">
<Header />
<main className="flex-1">
<main className="flex-1 z-0">
<Outlet />
</main>
<Footer />
@@ -1,163 +0,0 @@
import { CloseOutlined } from "@ant-design/icons"
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { AnimatePresence, motion } from "framer-motion"
import { FC, useCallback, useEffect, useState } from "react"
import { TopicStep } from "./steps/topic"
import { cn, Show } from "@imphnen-frontend-service/utils"
import { ScheduleStep } from "./steps/schedule"
import { ProfileStep } from "./steps/profile"
import { QrisPaymentStep } from "./steps/qris-payement"
import { VAPaymentStep } from "./steps/va-payment"
import { SuccessStep } from "./steps/success"
import { PaymentStep } from "./steps/payment"
const STEPS = ['topic', 'schedule', 'profile', 'payment', 'qr-payment', 'va-payment', 'success'] as const
type Step = typeof STEPS[number]
type Props = {
open: boolean
setOpen: (open: boolean) => void
}
export const AppointmentModal: FC<Props> = ({ open, setOpen }) => {
const [step, setStep] = useState<Step>('topic')
const [selectedTopics, setSelectedTopics] = useState<number[]>([])
const handleStep = (action: 'next' | 'prev') => {
if (action === 'next' && step === 'success') {
setOpen(false)
} else if (action === 'next') {
setStep(STEPS[STEPS.indexOf(step) + 1])
} else if (action === 'prev' && step !== 'topic') {
setStep(STEPS[STEPS.indexOf(step) - 1])
}
}
const handleEscapeKey = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape" && open) setOpen(false)
},
[open, setOpen]
)
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden"
window.addEventListener("keydown", handleEscapeKey)
} else {
document.body.style.overflow = ""
}
return () => {
document.body.style.overflow = ""
window.removeEventListener("keydown", handleEscapeKey)
}
}, [handleEscapeKey, open])
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, top: "50%" }}
animate={{ opacity: 1, top: 0 }}
exit={{ opacity: 0, top: "50%" }}
className="fixed inset-0 z-100 flex items-center justify-center"
>
<div className="fixed inset-0 bg-primary-900/30" onClick={() => setOpen(false)} />
<div
className={cn(
"relative bg-primary-50 px-5 py-6 w-full max-w-[280px] mx-4 rounded-lg md:max-w-[627px] md:py-10 md:px-[60px] md:rounded-xl xl:max-w-[668px]",
step === 'success' && 'md:max-w-[400px] md:p-10 xl:max-w-[400px]',
)}
>
<Show condition={step !== 'success'}>
<Button
type="button"
size="sm"
variant="text"
className="absolute top-3 right-3 bg-primary-200 p-1 shadow md:bg-white md:p-2 md:top-8 md:right-10"
>
<CloseOutlined className="md:text-lg" />
</Button>
</Show>
<div>
{step !== 'success' && (
<img
src="/logos/simple.svg"
alt="IMPHNEN Logo"
className="mb-6 h-8 w-auto mx-auto md:h-12 xl:h-[60px]"
/>
)}
<div className="scrollbar-hide max-h-[50dvh] overflow-y-auto">
{step !== 'success' && (
<div className="relative mb-7 bg-primary-500 p-4 overflow-hidden rounded-md md:px-7 md:py-5 md:flex md:justify-between md:gap-x-6">
<p className="text-xs font-semibold text-primary-50 md:text-[19px] md:w-[290px] xl:text-[23px]">
<Show
condition={step !== 'qr-payment' && step !== 'va-payment'}
fallback="Yosha~! Saatnya Membayar :)"
>
Yosha~! Saatnya Level Up dengan Sesi Mentoring bersama Senpai!
</Show>
</p>
<img
src="/image/mascot-character.webp"
alt="IMPHNEN Mascot"
className="hidden w-[166px] h-auto absolute right-0 -bottom-3 md:block xl:w-[207px] xl:-bottom-5"
/>
</div>
)}
<AnimatePresence>
{step === 'topic' && <TopicStep selectedTopics={selectedTopics} setSelectedTopics={setSelectedTopics} />}
{step === 'schedule' && <ScheduleStep />}
{step === 'profile' && <ProfileStep />}
{step === 'payment' && <PaymentStep selectedTopics={selectedTopics} />}
{step === 'qr-payment' && <QrisPaymentStep />}
{step === 'va-payment' && <VAPaymentStep />}
{step === 'success' && <SuccessStep />}
</AnimatePresence>
</div>
<Show condition={step !== 'success'}>
<hr className="my-6 border-primary-300" />
</Show>
<div className="flex justify-between">
<Button
type="button"
size="sm"
variant="text"
className={cn((step === 'topic' || step === 'success') && 'hidden')}
onClick={() => handleStep('prev')}
>
Kembali
</Button>
<Button
type="button"
size="sm"
variant="primary"
className={cn((step === 'topic' || step === 'success') && 'w-full')}
disabled={selectedTopics.length === 0 && step === 'topic'}
onClick={() => handleStep('next')}
>
<Show
condition={step !== 'success'}
fallback="Halman Booking"
>
<Show condition={step !== 'payment'} fallback="Bayar Sekarang">
Selanjutnya
</Show>
</Show>
</Button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
@@ -1,120 +0,0 @@
import { For, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react"
import { motion } from "framer-motion"
import { TOPICS } from "../../../sections/topics"
type Props = {
selectedTopics: number[]
}
export const PaymentStep: FC<Props> = ({ selectedTopics }) => {
return (
<motion.div
className="bg-white px-6 py-5 rounded-md grid gap-6 md:grid-cols-2 md:gap-8 xl:grid-cols-9"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<div className="space-y-6 xl:col-span-4">
<div>
<p className="w-max mx-auto text-[10px] text-neutral-400 font-medium mb-2 md:text-xs md:ms-0">
Senpai Kamu
</p>
<div>
<div className="aspect-square mb-2 size-10 mx-auto rounded-full overflow-hidden md:size-20 md:ms-0">
<img src="/image/testimonial.webp" alt="Mentor" className="w-full object-cover" />
</div>
<div className="w-44 mx-auto text-center md:ms-0 md:text-left md:mb-2 md:text-[15px] md:w-auto">
<h3 className="text-xs font-semibold mb-1 xl:text-[15px]">
Muhammad Firdaus Oi Oi Oi, S.H., M.H.
</h3>
<p className="text-[10px] text-neutral-600 md:text-xs">
UI Designer at Oray orayan Studios
</p>
</div>
</div>
</div>
<div>
<p className="text-[10px] text-neutral-400 font-medium mb-2 md:text-xs">
Topics :
</p>
<div className="flex flex-wrap gap-2">
<For data={TOPICS}>
{(topic) => (
<Show key={topic.id} condition={selectedTopics.includes(topic.id)}>
<div
className="px-2.5 py-2 text-neutral-800 bg-white border border-primary-100 rounded-md shadow text-[10px] font-medium"
>
<span>{topic.icon} </span>
<span>{topic.name}</span>
</div>
</Show>
)}
</For>
</div>
</div>
</div>
<div className="space-y-6 xl:col-span-5">
<div>
<p className="text-[10px] text-neutral-400 font-medium mb-2 md:text-xs">
Metode Pembayaran
</p>
<div className="mb-3">
<p className="text-[8px] text-neutral-400 mb-2 md:text-[10px]">
Virtual Account
</p>
<div className="grid grid-cols-3 gap-2 md:grid-cols-4">
<For data={['BCA', 'BRI', 'BNI', 'MANDIRI', 'BCA-Virtual', 'BNI-Virtual', 'MANDIRI-Virtual']}>
{(bank, index) => (
<div
key={index}
className="p-1 bg-white rounded-xs border border-primary-50 flex items-center gap-x-1"
>
<input type="radio" name="payment" id={bank} className="size-2" />
<label htmlFor={bank} className="block">
<img src="/image/payment/bca.webp" alt={bank} className="object-scale-down" />
</label>
</div>
)}
</For>
</div>
</div>
<div>
<p className="text-[8px] text-neutral-400 mb-2 md:text-[10px]">
QRIS
</p>
<div className="grid grid-cols-3 gap-2 md:grid-cols-4">
<div
className="p-1 bg-white rounded-xs border border-primary-50 flex items-center gap-x-1"
>
<input type="radio" name="payment" id="QRIS" className="size-2" />
<label htmlFor="QRIS" className="block">
<img src="/image/payment/qris.webp" alt="QRIS" className="object-scale-down" />
</label>
</div>
</div>
</div>
</div>
<div>
<p className="text-[10px] text-neutral-400 font-medium mb-2 md:text-xs">
Detail Pembayaran
</p>
<div className="grid grid-cols-2 space-y-2.5 text-[10px] text-neutral-400 font-medium md:text-xs md:space-y-3">
<p className="font-normal">Subtotal :</p>
<p className="text-right">Rp. 50.000</p>
<p className="font-normal">Service Fee :</p>
<p className="text-right">Rp. 2.000</p>
<hr className="border-neutral-200 col-span-full mt-1" />
<p className="text-neutral-700">Total :</p>
<p className="text-right text-neutral-700">Rp. 52.000</p>
</div>
</div>
</div>
</motion.div>
)
}
@@ -1,38 +0,0 @@
import { Input } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils"
import { motion } from "framer-motion"
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
export const ProfileStep = () => {
return (
<motion.div
className="bg-white px-6 py-5 rounded-md md:px-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<h3 className="text-xs font-semibold mb-1.5 md:text-[15px] md:mb-2 xl:text-[19px]">
Langkah 3 dari 3
</h3>
<p className="text-[10px] text-neutral-600 mb-5 md:text-xs xl:text-[15px]">
Lengkapi profil kamu agar senpai dapat mengenal kamu lebih baik.
</p>
<div className="space-y-2.5 md:space-y-5">
<div>
<label className={labelClass}>Nomor WhatsApp</label>
<Input className="min-w-full w-full" placeholder="080000000" />
</div>
<div>
<label className={labelClass}>CV (Opsional)</label>
<Input type="file" className="min-w-full w-full" />
</div>
<div>
<label className={labelClass}>Portoflio (Opsional)</label>
<Input className="min-w-full w-full" placeholder="Link Url" />
</div>
</div>
</motion.div>
)
}
@@ -1,55 +0,0 @@
import { For } from "@imphnen-frontend-service/utils"
import { motion } from "framer-motion"
const PAYMENT_STEP = [
'Buka aplikasi e-wallet atau m-banking kamu',
'Cari fitur bayar menggunakan QRIS',
'Scan kode QR di samping',
'Konfimasi pembayaran, dan proses selesai.'
]
export const QrisPaymentStep = () => {
return (
<motion.div
className="bg-white px-6 py-5 rounded-md md:flex md:justify-between md:gap-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<div className="mb-7 md:mb-0">
<h3 className="text-xs font-semibold mb-4 text-neutral-600 text-center md:text-start">
Silahkan ikuti instruksi pembayaran di bawah ini
</h3>
<p className="text-[10px] text-neutral-400 mb-1.5">
Cara melakukan pembayaran
</p>
<ul className="list-decimal pl-2.5 mb-4 md:mb-7">
<For data={PAYMENT_STEP}>
{(step, index) => (
<li key={index} className="text-[10px] text-neutral-400 leading-tight font-medium">
{step}
</li>
)}
</For>
</ul>
<div className="text-center md:text-start">
<p className="text-[10px] text-neutral-400 mb-1.5 font-medium md:text-xs">
Biaya yang harus dibayarkan
</p>
<p className="text-xs font-semibold text-neutral-700 md:text-[15px]">Rp. 52.000</p>
</div>
</div>
<div>
<p className="text-[10px] text-neutral-400 mb-2 font-medium text-center md:text-xs">
QR Code Pembayaran
</p>
<div className="bg-primary-50 p-2.5 rounded-lg size-[120px] mx-auto md:size-[142px] md:me-0">
<img src="/image/sample-qrcode.webp" alt="QR Code" className="w-full" />
</div>
</div>
</motion.div>
)
}
@@ -1,56 +0,0 @@
import { Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils"
import { motion } from "framer-motion"
const placeholder = `Hi [Nama Mentor], Saya [Nama Kamu] & saya berharap dapat memiliki sesi mentoring dengan Anda.
Saat ini, saya tertarik untuk mengejar __. Tujuan saya untuk sesi ini adalah __.
Saya ingin tahu secara khusus tentang ___.
1.Pertanyaan Anda
2. ...
3. ...`
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
export const ScheduleStep = () => {
return (
<motion.div
className="bg-white px-6 py-5 rounded-md md:px-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<h3 className="text-xs font-semibold mb-1.5 md:text-[15px] md:mb-2 xl:text-[19px]">
Langkah 2 dari 3
</h3>
<p className="text-[10px] text-neutral-600 mb-5 md:text-xs xl:text-[15px]">
Pilih jadwal sesuai preferensi kamu & siapkan pertanyaanmu
</p>
<div className="grid gap-2.5 md:grid-cols-2 md:gap-5">
<div>
<label className={labelClass}>Tanggal</label>
<Input type="date" className="min-w-full w-full" />
</div>
<div>
<label className={labelClass}>Waktu</label>
<Input type="time" className="min-w-full w-full" />
</div>
<div className="relative md:col-span-full">
<label className={labelClass}>Lokasi</label>
<Select className="min-w-full w-full">
<option value="online">Online</option>
<option value="offline">Offline</option>
</Select>
</div>
<div className="md:col-span-full">
<label className={labelClass}>Pertanyaan Untuk Senpai</label>
<Textarea
className="min-w-full w-full h-40"
placeholder={placeholder} />
</div>
</div>
</motion.div>
)
}
@@ -1,33 +0,0 @@
import { CheckOutlined } from "@ant-design/icons"
import { cn } from "@imphnen-frontend-service/utils"
import { motion } from "framer-motion"
export const SuccessStep = () => {
return (
<motion.div
className="pb-6 rounded-md md:pb-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<h2 className="text-[15px] font-semibold mb-3 text-primary-500 text-center md:text-[19px] md:mb-7 xl:text-[23px] xl:mb-8">
Booking Senpai Berhasil
</h2>
<div
className={cn(
"mb-3 flex justify-center items-center mx-auto size-[60px] bg-green-200 rounded-full",
"md:size-[100px] md:mb-7 xl:size-[120px] xl:mb-8"
)}
>
<div className="bg-green-500 rounded-full size-10 flex justify-center items-center text-white md:size-16 xl:size-20">
<CheckOutlined className="text-xl md:text-3xl xl:text-5xl" />
</div>
</div>
<p className="text-[10px] text-neutral-400 text-center md:text-xs md:max-w-[220px] md:mx-auto xl:text-[15px]">
Untuk detail dan instruksi berikutnya dapat dilihat di dashboard pada halaman booking
</p>
</motion.div>
)
}
@@ -1,82 +0,0 @@
import { CheckOutlined } from "@ant-design/icons"
import { cn, For, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react"
import { motion } from "framer-motion"
import { TOPICS } from "../../../sections/topics"
type Props = {
selectedTopics: number[]
setSelectedTopics: (topics: number[]) => void
}
export const TopicStep: FC<Props> = ({ selectedTopics, setSelectedTopics }) => {
const handleSelectTopic = (topicId: number) => {
if (selectedTopics.includes(topicId)) {
setSelectedTopics(selectedTopics.filter((id) => id !== topicId))
} else {
setSelectedTopics([...selectedTopics, topicId])
}
}
return (
<>
<motion.div
className="mb-6 flex items-center gap-x-4 bg-white rounded-md overflow-hidden shadow/5 md:gap-x-10"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<div className="w-16 aspect-4/5 overflow-hidden md:w-[134px] xl:w-[140px]">
<img src="/image/testimonial.webp" alt="Mentor" className="w-full object-cover" />
</div>
<div className="flex-1 pe-4">
<h3 className="text-xs font-semibold mb-1 line-clamp-2 md:text-[19px] md:mb-2 xl:text-[23px]">
Muhammad Firdaus Oi Oi Oi, S.H., M.H.
</h3>
<p className="text-[10px] line-clamp-2 text-neutral-600 md:text-[15px] xl:text-[19px]">
UI Designer at Oray orayan Studios
</p>
</div>
</motion.div>
<motion.div
className="bg-white p-5 rounded-md shadow/5 md:px-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<h3 className="text-xs font-semibold mb-1.5 md:text-[15px] md:mb-2 xl:text-[19px]">
Langkah 1 dari 3
</h3>
<p className="text-[10px] text-neutral-600 mb-5 md:text-xs xl:text-[15px]">
Pilih topik yang ingin kamu diskusikan dengan Senpai kamu~
</p>
<div className="grid gap-3 md:grid-cols-2 md:content-center">
<For data={TOPICS}>
{(topic) => (
<div
key={topic.id}
className={cn(
"relative px-2.5 py-2 text-neutral-800 bg-white border border-primary-100 rounded-md shadow text-[10px] font-medium cursor-pointer",
"md:text-xs xl:text-xs xl:font-semibold",
selectedTopics.includes(topic.id) && "bg-primary-50 border-primary-200"
)}
onClick={() => handleSelectTopic(topic.id)}
>
<span>{topic.icon} </span>
<span>{topic.name}</span>
<Show condition={selectedTopics.includes(topic.id)}>
<div className="absolute top-2 right-2 bg-primary-500 size-[14px] rounded-sm flex justify-center items-center text-white">
<CheckOutlined />
</div>
</Show>
</div>
)}
</For>
</div>
</motion.div>
</>
)
}
@@ -1,55 +0,0 @@
import { For } from "@imphnen-frontend-service/utils"
import { motion } from "framer-motion"
const PAYMENT_STEP = [
'Buka aplikasi e-wallet atau m-banking kamu',
'Pergi ke fitur transfer via Virtual Account',
'Pilih bank dan kode VA sesuai dengan yang ada pada halaman ini',
'Konfimasi pembayaran, dan proses selesai.'
]
export const VAPaymentStep = () => {
return (
<motion.div
className="bg-white px-6 py-5 rounded-md md:flex md:items-center md:gap-11"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<div className="mb-6 md:mb-0">
<h3 className="text-xs font-semibold mb-4 text-neutral-600 text-center md:text-start">
Silahkan ikuti instruksi pembayaran di bawah ini
</h3>
<p className="text-[10px] text-neutral-400 mb-1.5">
Cara melakukan pembayaran
</p>
<ul className="list-decimal pl-2.5">
<For data={PAYMENT_STEP}>
{(step, index) => (
<li key={index} className="text-[10px] text-neutral-400 leading-tight font-medium">
{step}
</li>
)}
</For>
</ul>
</div>
<div className="text-center md:text-start">
<div className="w-max mx-auto mb-3 md:ms-0 md:mb-4">
<img src="/image/payment/bca.webp" alt="VA BCA" className="h-5 w-auto" />
</div>
<p className="text-[10px] text-neutral-400 mb-1 font-medium md:text-xs">
Kode Virtual Account
</p>
<p className="text-xs font-semibold text-neutral-700 mb-1.5 md:text-[15px]">
8091239861969812
</p>
<p className="text-[8px] text-neutral-400 md:text-[10px]">
A/N Unknown
</p>
</div>
</motion.div>
)
}
@@ -1,33 +0,0 @@
import { cn, For } from "@imphnen-frontend-service/utils"
const EDUCATION = [
{ name: 'Universitas Widyabakti', major: 'Intern Front End', duration: '2 Months', range: 'Oct 2024 - Present' },
{ name: 'SMKN 99 Banjaran', major: 'Rekayasa Perangkat Lunak', duration: '6 Months', range: 'May 2024 - Oct 2024' },
]
export const EducationSection = () => {
return (
<div className="px-7 py-8 rounded-md shadow-md">
<h2 className="text-xs font-semibold mb-5 md:text-[15px] xl:text-[19px]">Education</h2>
<div className="space-y-4 divide-y">
<For data={EDUCATION}>
{(item, index) => (
<div key={index} className={cn("flex items-center gap-x-4", index !== EDUCATION.length - 1 && "pb-4")}>
<div className="rounded-full size-6 bg-neutral-200 md:size-7 xl:size-8"></div>
<div className='flex-1 text-[10px] font-medium'>
<p className="text-neutral-800 md:text-xs xl:text-[15px]">{item.name}</p>
<p>
<span className="text-neutral-600 xl:text-xs">{item.major}</span>
<span className="text-neutral-300"> · </span>
<span className="text-neutral-400 font-normal">{item.duration}</span>
<span className="text-neutral-300"> · </span>
<span className="text-neutral-400 font-normal xl:font-medium">{item.range}</span>
</p>
</div>
</div>
)}
</For>
</div>
</div>
)
}
@@ -1,34 +0,0 @@
import { cn, For } from "@imphnen-frontend-service/utils"
import { FC } from "react"
const EXPERIENCE = [
{ name: 'Sunday.com', position: 'Intern Front End', duration: '2 Months', range: 'Oct 2024 - Present' },
{ name: 'CodeX Digital', position: 'Intern Front End', duration: '6 Months', range: 'May 2024 - Oct 2024' },
]
export const ExperienceSection: FC = () => {
return (
<div className="px-7 py-8 rounded-md shadow-md">
<h2 className="text-xs font-semibold mb-5 md:text-[15px] xl:text-[19px]">Experience</h2>
<div className="space-y-4 divide-y">
<For data={EXPERIENCE}>
{(item, index) => (
<div key={index} className={cn("flex items-center gap-x-4", index !== EXPERIENCE.length - 1 && "pb-4")}>
<div className="rounded-full size-6 bg-neutral-200 md:size-7 xl:size-8"></div>
<div className='flex-1 text-[10px] font-medium'>
<p className="text-neutral-800 md:text-xs xl:text-[15px]">{item.name}</p>
<p>
<span className="text-neutral-600 xl:text-xs">{item.position}</span>
<span className="text-neutral-300"> · </span>
<span className="text-neutral-400 font-normal">{item.duration}</span>
<span className="text-neutral-300"> · </span>
<span className="text-neutral-400 font-normal xl:font-medium">{item.range}</span>
</p>
</div>
</div>
)}
</For>
</div>
</div>
)
}
@@ -1,93 +0,0 @@
import { StarFilled } from "@ant-design/icons"
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { cn, For } from "@imphnen-frontend-service/utils"
type Props = {
onBook: () => void
}
export const ProfileSection: React.FC<Props> = ({ onBook }) => {
return (
<div className="bg-white px-4 py-5 space-y-6 md:px-8 md:pt-6 md:pb-0 xl:space-y-0 xl:py-[30px] xl:flex xl:gap-x-9 xl:justify-between">
<div
className={cn(
"flex flex-col items-center justify-center",
"md:flex-row md:justify-start md:gap-x-8 md:items-start",
"xl:items-center xl:gap-x-10",
)}
>
<div
className={cn(
"mb-5 h-20 w-auto aspect-4/5 rounded-sm overflow-hidden",
"md:aspect-33/40 md:h-40 md:rounded-md md:border md:border-primary-50 md:mb-0",
"xl:aspect-5/6 md:h-60 md:rounded-lg"
)}
>
<img src="/image/testimonial.webp" alt="Mentor" className="w-full object-cover" />
</div>
<div className="md:max-w-[242px] xl:max-w-[306px]">
<h1
className={cn(
"mb-1 text-[15px] font-semibold text-center leading-snug",
"md:text-[19px] md:mb-2 md:text-start xl:text-[23px]",
)}
>
Muhammad Firdaus Oi Oi Oi, S.H., M.H.
</h1>
<p className="text-xs mb-4 text-neutral-600 md:mb-5 md:text-[15px] xl:text-[19px] xl:mb-5">
UI Designer at Oray orayan Studios
</p>
<div className="flex items-center gap-x-2.5 w-full max-w-max border border-primary-50 p-1.5 rounded-md mx-auto md:ms-0">
<div
className="bg-gradient-to-tr from-primary-500 to-primary-200 rounded-sm text-white size-5 flex justify-center items-center xl:size-[29.4px]"
>
<StarFilled className="text-xs xl:text-sm" />
</div>
<div>
<p className="text-[10px] font-medium mb-1 text-neutral-800 xl:text-xs">Excelent Sensei</p>
<p className="text-[8px] text-neutral-600 xl:text-[10px]">4.8/5.0</p>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-y-3 xl:gap-y-4 xl:max-w-[402px]">
<div>
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Expertise</h2>
<div className="p-4 bg-primary-50 border border-primary-100 rounded-md flex flex-wrap gap-3">
<For data={['UI Design', 'UX Reseacrh']}>
{(item) => (
<div key={item} className="bg-primary-300 text-primary-600 px-3 py-2 rounded-md text-[10px] md:font-medium xl:text-xs xl:font-semibold">
{item}
</div>
)}
</For>
</div>
</div>
<div>
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Soft Skills</h2>
<div className="p-4 bg-primary-50 border border-primary-100 rounded-md flex flex-wrap gap-3">
<For data={['Design Thinking', 'Communication', 'Problem Solving', '19:00 WIB']}>
{(item) => (
<div key={item} className="bg-primary-300 text-primary-600 px-3 py-2 rounded-md text-[10px] md:font-medium xl:text-xs xl:font-semibold">
{item}
</div>
)}
</For>
</div>
</div>
</div>
<div className="hidden md:flex xl:hidden justify-between">
<div>
<p className="text-primary-500 text-[15px] font-semibold">Jum, 4 April 2025</p>
<p className="text-neutral-500 text-xs font-medium">Jum, 4 April 2025</p>
</div>
<Button type="button" size="sm" onClick={onBook}>
Book Your Senpai!
</Button>
</div>
</div>
)
}
@@ -1,75 +0,0 @@
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { cn, For } from "@imphnen-frontend-service/utils"
import { FC } from "react"
const SCHEDULES = [
{
date: "Kamis, 20 Maret 2025",
availableCount: 10,
schedule: [
{ time: "19:00 WIB", available: true },
{ time: "20:00 WIB", available: true },
{ time: "21:00 WIB", available: true },
{ time: "22:00 WIB", available: true },
]
},
{
date: "Jumat, 21 Maret 2025",
availableCount: 1,
schedule: [
{ time: "19:00 WIB", available: true },
{ time: "20:00 WIB", available: true },
{ time: "21:00 WIB", available: false },
{ time: "22:00 WIB", available: true },
]
}
]
type Props = {
onBook: () => void
}
export const SenpaiScheduleSection: FC<Props> = ({ onBook }) => {
return (
<div>
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Senpai Schedule</h2>
<div className="p-4 bg-primary-50 border border-primary-100 rounded-md space-y-5">
<For data={SCHEDULES}>
{(item) => (
<div key={item.date}>
<div className="flex justify-between items-center mb-4">
<p className="text-[15px] font-medium">{item.date}</p>
<p
className={cn(
"rounded-4xl py-1 px-2.5 text-xs font-medium",
item.availableCount > 5 ? "bg-primary-200 text-primary-500" : "bg-danger-100 text-danger-600"
)}
>
{item.availableCount} slot tersisa
</p>
</div>
<div className="flex gap-x-3">
<For data={item.schedule}>
{(schedule, index) => (
<div
key={index}
className="bg-primary-300 text-primary-600 px-3 py-2 text-xs rounded-md font-semibold"
>
{schedule.time}
</div>
)}
</For>
</div>
</div>
)}
</For>
<hr className="border-primary-200" />
<Button type="button" size="sm" className="w-full" onClick={onBook}>
Book Your Senpai!
</Button>
</div>
</div>
)
}
@@ -1,34 +0,0 @@
import { StarFilled } from "@ant-design/icons";
import { For } from "@imphnen-frontend-service/utils";
import { FC } from "react";
const SENPAI_STATISTIC = [
{ name: 'Total Sessions', count: 8 },
{ name: 'Mentee Impact', count: 1000 },
{ name: 'Response Time', count: '30 Minute' }
]
export const StatisticsSection: FC = () => {
return (
<div className="md:mb-10">
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Senpai Statistics</h2>
<div className="grid gap-4 xl:flex">
<For data={SENPAI_STATISTIC}>
{(item, index) => (
<div key={index} className="px-2.5 py-2 border border-primary-50 rounded-md shadow flex items-center gap-x-2.5">
<div
className="bg-gradient-to-tr from-primary-500 to-primary-200 rounded-sm text-white size-7 flex justify-center items-center xl:size-[29.4px]"
>
<StarFilled className="text-sm" />
</div>
<div>
<p className="text-[10px] text-neutral-600 font-medium md:text-xs xl:font-semibold">{item.count}</p>
<p className="text-[8px] text-neutral-800 md:text-[10px]">{item.name}</p>
</div>
</div>
)}
</For>
</div>
</div>
)
};
@@ -1,33 +0,0 @@
import { For } from "@imphnen-frontend-service/utils"
import { FC } from "react"
export const TOPICS = [
{ id: 1, icon: '💼', name: 'Career & Self Development' },
{ id: 2, icon: '🏢', name: 'Industry Insight' },
{ id: 3, icon: '🗂️', name: 'Project Management & IT Tools' },
{ id: 4, icon: '🖥️', name: 'Basic IT' },
{ id: 5, icon: '💻', name: 'Programming/Software Dev' },
{ id: 6, icon: '🗃️', name: 'Data & Database' },
{ id: 7, icon: <span className="font-bold text-primary-500">AI</span>, name: 'AI Tips' }
]
export const TopicsSection: FC = () => {
return (
<div>
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Topics</h2>
<div className="p-5 bg-primary-50 border border-primary-100 rounded-md flex flex-wrap gap-2.5">
<For data={TOPICS}>
{(item, index) => (
<div
key={index}
className="px-2.5 py-2 text-neutral-800 bg-white border border-primary-100 rounded-md shadow text-[10px] font-medium"
>
<span>{item.icon} </span>
<span>{item.name}</span>
</div>
)}
</For>
</div>
</div>
)
}
@@ -1,52 +0,0 @@
import { FC, useState } from 'react'
import { ProfileSection } from './_components/sections/profile'
import { StatisticsSection } from './_components/sections/senpai-statistics'
import { TopicsSection } from './_components/sections/topics'
import { ExperienceSection } from './_components/sections/experience'
import { EducationSection } from './_components/sections/education'
import { SenpaiScheduleSection } from './_components/sections/senpai-schedule'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { AppointmentModal } from './_components/modals/appointment'
export const Components: FC = () => {
const [open, setOpen] = useState(false)
return (
<main>
<section className="w-full p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20">
<div className="max-w-7xl mx-auto space-y-8 md:bg-white xl:bg-transparent">
<ProfileSection onBook={() => setOpen(true)} />
<Button type="button" size="sm" className="w-full md:hidden" onClick={() => setOpen(true)}>
Book Your Senpai!
</Button>
<div className="bg-white px-4 py-5 md:px-8 md:pb-6 md:pt-0 xl:py-7 xl:flex xl:gap-x-10">
<div className="space-y-10 md:space-y-7 xl:flex-1">
<StatisticsSection />
<TopicsSection />
<div className="px-6 py-8 rounded-md shadow-md">
<h2 className="text-xs text-neutral-800 font-semibold mb-5 md:text-[15px] xl:text-[19px]">Senpai Resume</h2>
<p className="text-[10px] font-medium text-neutral-600 text-pretty md:text-[15px]">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
</p>
</div>
<ExperienceSection />
<EducationSection />
</div>
<div className="hidden xl:block xl:w-[400px]">
<SenpaiScheduleSection onBook={() => setOpen(true)} />
</div>
</div>
</div>
</section>
<AppointmentModal open={open} setOpen={setOpen} />
</main>
)
}
export default Components
@@ -1,70 +0,0 @@
import { cn } from '@imphnen-frontend-service/utils';
import { FC, useRef } from 'react';
import { motion, useInView, Variants } from 'framer-motion';
export const BannerSection: FC = () => {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}
const childVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
return (
<section ref={ref} className="w-full p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20">
<motion.div
className={cn(
"relative max-w-7xl mx-auto bg-white p-6 rounded-lg overflow-hidden shadow-md",
"md:flex md:justify-between md:items-center md:px-5 md:rounded-xl lg:px-16 lg:py-10",
)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<motion.div
className="hidden absolute -left-9 -top-24 w-[17rem] md:block lg:w-[28rem] lg:-left-14 lg:-top-52"
initial={{ x: -120, opacity: 0, rotate: -6 }}
animate={{ x: 0, opacity: 1, rotate: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<img src="/image/mentoring-banner.webp" alt="" className="w-full" />
</motion.div>
<motion.div
className="text-center md:text-start md:max-w-[calc(100%-14rem)] md:ms-auto lg:max-w-[calc(100%-20rem)]"
variants={containerVariants}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
viewport={{ once: true }}
>
<motion.h1 className="mb-4 text-xl font-semibold text-primary-500 leading-tight md:text-2xl lg:text-5xl lg:font-bold lg:mb-6" variants={childVariants}>
Find your perfect sensei
</motion.h1>
<motion.p className="text-xs font-semibold text-neutral-500 md:text-[15px] md:font-medium lg:text-xl" variants={childVariants}>
Di sini, kamu nggak cuma grinding sendiriansensei dari dunia nyata siap membimbingmu, dan AI mentor bakal jadi support system terbaikmu. Saatnya jadi protagonist dalam perjalanan karier IT-mu!
</motion.p>
</motion.div>
</motion.div>
</section>
)
}
@@ -1,41 +0,0 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { FC } from 'react';
import { Link } from 'react-router-dom';
export const MentorCard: FC = () => {
return (
<div className="p-2.5 rounded-md bg-white shadow flex gap-x-4 items-start md:p-4 md:flex-col md:rounded-lg md:gap-y-4">
<div className="size-[60px] rounded-md overflow-hidden md:w-full md:h-auto md:aspect-square">
<img src="/image/testimonial.webp" alt="Mentor" className="w-full object-cover" />
</div>
<div>
<Button variant="text" size="sm" className="bg-primary-100 h-auto px-1.5 py-1 text-[8px] font-normal mb-2 hover:bg-primary-100 md:text-[10px] md:font-medium md:mb-4">
3-5 Years Experience
</Button>
<h2 className="mb-1">
<Link to="/mentoring/detail" className="text-xs font-semibold text-primary-500 md:text-[15px] md:font-semibold lg:text-[19px]">
Fullname
</Link>
</h2>
<p className="text-[8px] text-neutral-500 mb-3 md:text-[10px] md:font-medium md:mb-4 lg:text-xs">
Full Stack Enjoyer at name company
</p>
<div>
<p className="text-[8px] text-neutral-500 mb-1 md:mb-2 lg:text-[10px]">Soft Skills :</p>
<div className="space-x-2">
<Button variant="text" size="sm" className="bg-primary-200 h-auto px-1.5 py-1 text-[8px] font-normal hover:bg-primary-200 lg:text-[10px]">
Communication
</Button>
<Button variant="text" size="sm" className="bg-primary-200 h-auto px-1.5 py-1 text-[8px] font-normal hidden hover:bg-primary-200 md:inline-block lg:text-[10px]">
Communication
</Button>
<Button variant="text" size="sm" className="h-auto px-1.5 py-1 text-[8px] font-normal text-neutral-500 hover:bg-transparent lg:text-[10px]">
+2
</Button>
</div>
</div>
</div>
</div>
)
}
@@ -1,73 +0,0 @@
import { CloudServerOutlined, CodeOutlined, DatabaseOutlined, ProjectOutlined, TabletOutlined } from '@ant-design/icons'
import { Icon } from '@iconify/react'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { For } from '@imphnen-frontend-service/utils'
import { FC, ReactElement, useRef } from 'react'
import { motion, useInView, Variants } from 'framer-motion'
const TOPICS: {
name: string
icon: React.ReactNode
}[] = [
{ name: 'UI/UX & Design', icon: <TabletOutlined /> },
{ name: 'Software/Web Dev', icon: <CodeOutlined /> },
{ name: 'Data & AI', icon: <DatabaseOutlined /> },
{ name: 'Cloud & DevOps', icon: <CloudServerOutlined /> },
{ name: 'Cybersecurity', icon: <Icon icon="mage:security-shield" /> },
{ name: 'IT & Networking', icon: <Icon icon="carbon:data-structured" /> },
{ name: 'Project Management', icon: <ProjectOutlined /> },
{ name: 'QA & Testing', icon: <Icon icon="carbon:exam-mode" /> }
]
export const Topics: FC = (): ReactElement => {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.3,
delay: 0.4,
},
},
}
const childVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
return (
<div ref={ref}>
<motion.div
className="grid grid-cols-4 gap-6 px-3 md:px-12 md:gap-x-10 md:gap-y-14 lg:grid-cols-8 lg:px-0"
variants={containerVariants}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
>
<For data={TOPICS}>
{(topic) => (
<motion.div key={topic.name} className="flex flex-col items-center" variants={childVariants}>
<Button variant="secondary" size='sm' className="size-7 mb-3 p-0 md:mb-4 md:size-8 md:text-base lg:size-10 lg:text-lg">
{topic.icon}
</Button>
<p className="text-[8px] text-neutral-700 text-center break-words md:text-xs md:font-medium lg:text-[15px]">
{topic.name}
</p>
</motion.div>
)}
</For>
</motion.div>
</div>
)
}
@@ -1,121 +0,0 @@
import { FC, ReactElement, useRef, useState } from 'react';
import { BannerSection } from './_components/banner-section';
import { Topics } from './_components/topics';
import { Input } from '@imphnen-frontend-service/ui/atoms';
import { SearchOutlined } from '@ant-design/icons';
import { For } from '@imphnen-frontend-service/utils';
import { MentorCard } from './_components/mentor-card';
import { Pagination } from '@imphnen-frontend-service/ui/molecules';
import { getCoreRowModel, getPaginationRowModel, PaginationState, useReactTable } from '@tanstack/react-table';
import { motion, useInView, Variants } from 'framer-motion';
const TEMP_DATA = [
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'John Doe' },
{ id: 3, name: 'John Doe' },
{ id: 4, name: 'John Doe' },
]
export const Components: FC = (): ReactElement => {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 3,
});
const table = useReactTable({
data: TEMP_DATA,
columns: [],
state: { pagination },
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
});
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.3,
},
},
}
const childVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
return (
<main>
<BannerSection />
<section className="w-full px-8 md:px-[60px] md:pb-[60px] lg:px-20">
<motion.div
className="max-w-7xl mx-auto bg-white rounded-lg py-6 md:rounded-4xl"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
>
<div className="mb-10 md:px-6 lg:mb-[60px]">
<motion.h1
className="text-center text-[15px] text-primary-500 font-semibold mb-8 md:text-2xl md:mb-10 lg:text-3xl lg:mb-[60px]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.3 }}
>
Choose Your Topics
</motion.h1>
<Topics />
</div>
<motion.div ref={ref} className="md:px-6">
<motion.div
className="relative mb-6 md:px-12 lg:px-0 lg:w-[480px] lg:mx-auto"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
>
<Input
placeholder="Cari berdasarkan nama, posisi/peran"
className="relative min-w-full w-full"
/>
<SearchOutlined className="absolute right-2.5 top-1/2 -translate-y-1/2 text-primary-500 size-2.5 cursor-text md:me-12 lg:me-0" />
</motion.div>
<motion.div
className="grid gap-2 mb-10 md:grid-cols-2 md:gap-6 lg:grid-cols-4"
variants={containerVariants}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
>
<For data={Array.from({ length: 8 })}>
{(_, index) => (
<motion.div key={index} variants={childVariants}>
<MentorCard />
</motion.div>
)}
</For>
</motion.div>
<Pagination table={table} />
</motion.div>
</motion.div>
</section>
</main>
)
}
export default Components
@@ -1,166 +0,0 @@
'use client';
import { FC, ReactElement, useState } from 'react';
import { useParams } from 'react-router-dom';
import { ProfileForm, ProfileSidebar, ProfileHeader } from '../_components';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { NotificationModal, NotificationType } from '../_components/modals/notification-modal';
import { ProfileProvider, useProfile } from '../_components/contexts/profile-context';
import { EditProfileModal } from '../_components/modals/edit-profile-modal';
const ProfileByIdPage: FC = (): ReactElement => {
const params = useParams();
const id = (params && params.id) ? params.id as string : undefined;
if (!id) {
return (
<main className="min-h-screen flex items-center justify-center">
<div className="text-center">
<p className="text-red-600 text-lg">Profile ID not found.</p>
</div>
</main>
);
}
return (
<ProfileProvider profileId={id} profileType="user">
<ProfileByIdContent />
</ProfileProvider>
);
};
const ProfileByIdContent: FC = (): ReactElement => {
const { profileData, isLoading, error, profileType } = useProfile();
const [notification, setNotification] = useState<{
isOpen: boolean;
type: 'success' | 'error';
title: string;
message?: string;
}>({
isOpen: false,
type: 'success',
title: '',
message: ''
});
const [isEditProfileModalOpen, setIsEditProfileModalOpen] = useState(false);
const getProfileTitle = () => {
if (profileData?.fullname) {
return `${profileData.fullname}'s Profile`;
}
return profileType === 'user' ? 'User Profile' : 'Mentor Profile';
};
const showNotification = (type: NotificationType['type'], title: string, message?: string) => {
setNotification({
isOpen: true,
type,
title,
message
});
};
const hideNotification = () => {
setNotification(prev => ({ ...prev, isOpen: false }));
};
const openEditProfileModal = () => {
setIsEditProfileModalOpen(true);
};
const closeEditProfileModal = () => {
setIsEditProfileModalOpen(false);
};
// Set isViewOnly to true for this page
const isViewOnly = true;
if (isLoading) {
return (
<main className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading profile...</p>
</div>
</main>
);
}
if (error) {
return (
<main className="min-h-screen flex items-center justify-center">
<div className="text-center">
<p className="text-red-600 text-lg">Failed to load profile</p>
<p className="text-gray-600 mt-2">Profile not found or you don't have permission to view it.</p>
</div>
</main>
);
}
return (
<main className="min-h-screen">
<div className="">
<div className="w-full px-8 md:px-[60px] lg:px-20 py-4">
<div className="max-w-7xl mx-auto">
<Button variant="primary" className="flex items-center gap-2">
<ArrowLeftOutlined />
Kembali ke Dashboard
</Button>
</div>
</div>
</div>
<div className="w-full px-8 md:px-[60px] lg:px-20 py-6">
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl font-semibold text-gray-900">
{getProfileTitle()}
</h1>
</div>
</div>
<div className="w-full px-8 md:px-[60px] lg:px-20 pb-12">
<div className="max-w-7xl mx-auto">
<div className="grid gap-8 lg:grid-cols-12">
<div className="lg:col-span-12">
<ProfileHeader onEditProfileClick={openEditProfileModal} isViewOnly={isViewOnly} />
</div>
<div className="lg:col-span-8 order-1">
<ProfileForm
showNotification={showNotification}
isViewOnly={isViewOnly}
/>
</div>
<div className="lg:col-span-4 order-2">
<ProfileSidebar
showNotification={showNotification}
isViewOnly={isViewOnly}
/>
</div>
</div>
</div>
</div>
<NotificationModal
isOpen={notification.isOpen}
onClose={hideNotification}
type={notification.type}
title={notification.title}
message={notification.message}
header="Profile"
/>
{/* Only render EditProfileModal if not in view-only mode */}
{!isViewOnly && (
<EditProfileModal
isOpen={isEditProfileModalOpen}
onClose={closeEditProfileModal}
showNotification={showNotification}
/>
)}
</main>
);
};
export default ProfileByIdPage;
@@ -1,25 +0,0 @@
import { FC } from 'react';
import { EditOutlined } from '@ant-design/icons';
interface EditSectionButtonProps {
onClick: () => void;
disabled?: boolean;
}
export const EditSectionButton: FC<EditSectionButtonProps> = ({ onClick, disabled = false }) => {
return (
<button
onClick={onClick}
disabled={disabled}
className={`flex items-center gap-1 px-3 py-1 text-sm transition-colors duration-200 rounded-md ${
disabled
? 'text-gray-400 cursor-not-allowed opacity-50'
: 'text-[#23A1EB] hover:text-[#1e90d6] hover:bg-[#23A1EB]/10'
}`}
aria-label="Edit section"
>
<span>Edit</span>
<EditOutlined className="w-3 h-3" />
</button>
);
};
@@ -1,2 +0,0 @@
export { EditSectionButton } from './edit-section-button';
export { ModalButton } from './modal-button';
@@ -1,76 +0,0 @@
import { FC, ReactNode } from 'react';
type ButtonVariant = 'primary' | 'secondary' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';
interface ModalButtonProps {
children: ReactNode;
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
variant?: ButtonVariant;
size?: ButtonSize;
disabled?: boolean;
loading?: boolean;
className?: string;
}
const getVariantClasses = (variant: ButtonVariant): string => {
switch (variant) {
case 'primary':
return 'bg-[#23A1EB] hover:bg-[#1e90d6] text-white shadow-lg hover:shadow-xl';
case 'secondary':
return 'bg-white hover:bg-gray-200 text-[#23A1EB] shadow-md hover:shadow-lg';
case 'danger':
return 'bg-red-600 hover:bg-red-500 text-white shadow-lg hover:shadow-xl';
default:
return 'bg-[#23A1EB] hover:bg-[#1e90d6] text-white shadow-lg hover:shadow-xl';
}
};
const getSizeClasses = (size: ButtonSize): string => {
switch (size) {
case 'sm':
return 'px-3 py-1.5 text-sm';
case 'md':
return 'px-4 py-2 text-sm';
case 'lg':
return 'px-6 py-3 text-base';
default:
return 'px-4 py-2 text-sm';
}
};
export const ModalButton: FC<ModalButtonProps> = ({
children,
onClick,
type = 'button',
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
className = '',
}) => {
const baseClasses = 'font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#23A1EB] disabled:opacity-50 disabled:cursor-not-allowed';
const variantClasses = getVariantClasses(variant);
const sizeClasses = getSizeClasses(size);
const combinedClasses = `${baseClasses} ${variantClasses} ${sizeClasses} ${className}`;
return (
<button
type={type}
onClick={onClick}
disabled={disabled || loading}
className={combinedClasses}
>
{loading ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"></div>
<span>Loading...</span>
</div>
) : (
children
)}
</button>
);
};
@@ -1 +0,0 @@
export * from './profile-context';
@@ -1,249 +0,0 @@
'use client';
import React, { createContext, useContext, useMemo, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import {
useAuthStore,
useUserMe,
useUserById,
useUpdateUserMe,
useUpdateUserById,
UserDetailResponseDto,
UserUpdateRequestDto,
useMentorMe,
useMentorById,
useUpdateMentorMe,
useUpdateMentorById,
MentorDetailResponseDto,
MentorUpdateRequestDto
} from '@imphnen-frontend-service/service';
type ProfileData = UserDetailResponseDto | MentorDetailResponseDto;
type ProfileUpdateData = UserUpdateRequestDto | MentorUpdateRequestDto;
const canAccessMentorFeatures = (user: { role?: { name?: string; permissions?: Array<{ name?: string }> } } | null) => {
if (!user?.role) return false;
const roleName = user.role.name?.toLowerCase() || '';
const isMentorRole = roleName.includes('mentor') || roleName.includes('admin');
if (isMentorRole) return true;
const permissions = user.role.permissions || [];
const hasMentorPermission = permissions.some((permission: { name?: string }) =>
permission.name?.toLowerCase().includes('mentor')
);
return hasMentorPermission;
};
interface ProfileContextType {
profileData: ProfileData | undefined;
isLoading: boolean;
error: unknown;
isOwnProfile: boolean;
profileId: string | null;
profileType: 'user' | 'mentor';
updateProfile: (data: ProfileUpdateData) => Promise<void>;
isUpdating: boolean;
canAccessMentor: boolean;
}const ProfileContext = createContext<ProfileContextType | undefined>(undefined);
interface ProfileProviderProps {
children: React.ReactNode;
profileId?: string;
profileType?: 'user' | 'mentor';
}
export const ProfileProvider: React.FC<ProfileProviderProps> = ({
children,
profileId,
profileType: forcedProfileType
}) => {
const params = useParams();
const { session } = useAuthStore();
const queryClient = useQueryClient();
const canAccessMentor = useMemo(() => {
return canAccessMentorFeatures(session?.user || null);
}, [session?.user]);
const isMentorRole = useMemo(() => {
const roleName = session?.user?.role?.name?.toLowerCase() || '';
return roleName === 'mentor';
}, [session?.user?.role?.name]);
const profileType: 'user' | 'mentor' = useMemo(() => {
if (forcedProfileType) {
if (forcedProfileType === 'mentor' && !isMentorRole) {
return 'user';
}
return forcedProfileType;
}
if ((params?.mentor || (typeof window !== 'undefined' && window.location.pathname.includes('/mentor'))) && isMentorRole) {
return 'mentor';
}
return 'user';
}, [forcedProfileType, params, isMentorRole]);
const id = profileId || (params?.id as string) || undefined;
const isOwnProfile = !id;
const userMeQuery = useUserMe({
queryKey: ['user-me'],
enabled: isOwnProfile && profileType === 'user',
});
const userByIdQuery = useUserById(id || '', {
queryKey: ['user-by-id', id],
enabled: !isOwnProfile && !!id && profileType === 'user',
});
const updateUserMeMutation = useUpdateUserMe();
const updateUserByIdMutation = useUpdateUserById();
const mentorMeQuery = useMentorMe({
queryKey: ['mentor-me'],
enabled: isOwnProfile && profileType === 'mentor' && canAccessMentor,
});
const mentorByIdQuery = useMentorById(id || '', {
queryKey: ['mentor-by-id', id],
enabled: !isOwnProfile && !!id && profileType === 'mentor' && canAccessMentor,
});
const updateMentorMeMutation = useUpdateMentorMe();
const updateMentorByIdMutation = useUpdateMentorById();
const selectedUserQuery = isOwnProfile ? userMeQuery : userByIdQuery;
const selectedMentorQuery = isOwnProfile ? mentorMeQuery : mentorByIdQuery;
const {
data: profileData,
isLoading,
error
} = useMemo(() => {
if (canAccessMentor && profileType === 'mentor') {
return selectedMentorQuery;
}
return selectedUserQuery;
}, [profileType, canAccessMentor, selectedUserQuery, selectedMentorQuery]);
const selectedUserMutation = isOwnProfile ? updateUserMeMutation : updateUserByIdMutation;
const selectedMentorMutation = isOwnProfile ? updateMentorMeMutation : updateMentorByIdMutation;
const updateMutation = useMemo(() => {
if (canAccessMentor && profileType === 'mentor') {
return selectedMentorMutation;
}
return selectedUserMutation;
}, [profileType, canAccessMentor, selectedUserMutation, selectedMentorMutation]);
const updateProfile = useCallback(async (data: ProfileUpdateData) => {
try {
if (canAccessMentor && profileType === 'mentor') {
if (isOwnProfile) {
await updateMentorMeMutation.mutateAsync(data as MentorUpdateRequestDto);
await queryClient.invalidateQueries({ queryKey: ['mentor-me'] });
} else if (id) {
await updateMentorByIdMutation.mutateAsync({ id, data: data as MentorUpdateRequestDto });
await queryClient.invalidateQueries({ queryKey: ['mentor-by-id', id] });
}
} else if (isOwnProfile) {
await updateUserMeMutation.mutateAsync(data as UserUpdateRequestDto);
await queryClient.invalidateQueries({ queryKey: ['user-me'] });
} else if (id) {
await updateUserByIdMutation.mutateAsync({ id, data: data as UserUpdateRequestDto });
await queryClient.invalidateQueries({ queryKey: ['user-by-id', id] });
}
} catch (error: unknown) {
console.error('Failed to update profile:', error);
let apiMessage = '';
if (typeof error === 'object' && error !== null) {
const errObj = error as { response?: { data?: unknown } };
const data = errObj.response?.data;
if (data) {
try {
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
if (parsed && typeof parsed.message === 'string') {
apiMessage = parsed.message;
}
} catch {
apiMessage = typeof data === 'string' ? data : '';
}
}
}
if (apiMessage) {
throw new Error(apiMessage);
}
throw error;
}
}, [
profileType,
isOwnProfile,
canAccessMentor,
id,
queryClient,
updateUserMeMutation,
updateUserByIdMutation,
updateMentorMeMutation,
updateMentorByIdMutation
]); const isUpdating = updateMutation.isPending;
const value: ProfileContextType = useMemo(() => ({
profileData,
isLoading,
error,
isOwnProfile,
profileId: isOwnProfile ? null : (id || null),
profileType,
updateProfile,
isUpdating,
canAccessMentor
}), [profileData, isLoading, error, isOwnProfile, id, profileType, updateProfile, isUpdating, canAccessMentor]);
return (
<ProfileContext.Provider value={value}>
{children}
</ProfileContext.Provider>
);
};
export const useProfile = (): ProfileContextType => {
const context = useContext(ProfileContext);
if (!context) {
throw new Error('useProfile must be used within a ProfileProvider');
}
return context;
};
export type { ProfileContextType };
export type { ProfileData, ProfileUpdateData };
@@ -1,27 +0,0 @@
'use client';
import React from 'react';
import { Guard } from '@imphnen-frontend-service/utils';
interface MentorGuardProps {
children: React.ReactNode;
fallback?: React.ReactNode;
}
export const MentorGuard: React.FC<MentorGuardProps> = ({
children,
fallback = (
<div className="p-4 text-center text-red-500">
<p>Akses ditolak: Anda tidak memiliki izin untuk mengakses fitur mentor.</p>
</div>
)
}) => {
return (
<Guard
permissions={['mentor', 'admin']}
fallback={fallback}
>
{children}
</Guard>
);
};
@@ -1,17 +0,0 @@
export * from './profile';
export * from './sections';
export * from './buttons';
export * from './shared';
export * from './modals';
export * from './contexts';
@@ -1,187 +0,0 @@
import { FC, useState, useEffect } from 'react';
import { ModalButton } from '../buttons/modal-button';
import { useUploadCV } from '@imphnen-frontend-service/service';
import { FileUploader } from '../shared/file-uploader';
interface CVData {
fileName: string;
fileUrl?: string;
}
interface CVModalProps {
isOpen: boolean;
onClose: () => void;
initialValue: CVData;
onSave: (value: CVData) => Promise<void>;
isLoading?: boolean;
}
export const CVModal: FC<CVModalProps> = ({
isOpen,
onClose,
initialValue,
onSave,
isLoading = false,
}) => {
const [cvData, setCvData] = useState(initialValue);
const [isUploading, setIsUploading] = useState(false);
const uploadCVMutation = useUploadCV();
useEffect(() => {
setCvData(initialValue);
}, [initialValue]);
const handleSave = async () => {
try {
await onSave(cvData);
onClose();
} catch (error) {
console.error('Save failed:', error);
}
};
const handleCancel = () => {
setCvData(initialValue);
onClose();
};
const handleFileSelect = async (file: File) => {
try {
setIsUploading(true);
if (!file.type.includes('pdf')) {
throw new Error('Please select a PDF file');
}
const uploadResult = await uploadCVMutation.mutateAsync(file);
console.log('CV upload response:', uploadResult);
interface UploadData {
original_filename?: string;
filename?: string;
url?: string;
}
const uploadData = ('data' in uploadResult ? (uploadResult as { data: UploadData }).data : uploadResult as UploadData);
setCvData({
fileName: uploadData.original_filename || uploadData.filename || file.name,
fileUrl: uploadData.url || '',
});
console.log('CV uploaded successfully, URL:', uploadData.url);
} catch (error) {
console.error('CV upload error:', error);
const fileInput = document.getElementById('cv-upload') as HTMLInputElement;
if (fileInput) fileInput.value = '';
} finally {
setIsUploading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
onClick={handleCancel}
aria-label="Close modal"
type="button"
/>
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto">
<div className="p-6 pb-4 border-b border-gray-200">
<div className="flex">
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit CV/Resume</h2>
</div>
</div>
<div className="px-6 py-6 space-y-6">
{}
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">
Upload CV/Resume
</h3>
<FileUploader
accept=".pdf"
maxSize={10 * 1024 * 1024}
onFileSelect={handleFileSelect}
isLoading={isUploading}
dragAndDrop={true}
description="Klik atau tarik file PDF yang ingin di upload"
className="w-full"
/>
<p className="text-xs text-gray-500 mt-2">
Format yang didukung: PDF Maksimal ukuran: 10MB
</p>
</div>
{}
{cvData.fileName && (
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="text-sm font-medium text-blue-900 mb-3">File Terpilih</h4>
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center">
<span className="text-white text-xs font-bold">PDF</span>
</div>
<div className="flex-1">
<p className="font-medium text-blue-900 text-sm">{cvData.fileName}</p>
<p className="text-xs text-blue-600">Siap untuk disimpan</p>
</div>
{cvData.fileUrl && (
<a
href={cvData.fileUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 text-sm font-medium underline"
>
Preview
</a>
)}
</div>
</div>
)}
</div>
</div>
{}
<div className="flex gap-3 p-6 pt-4 border-t border-gray-100">
<ModalButton
variant="secondary"
onClick={handleCancel}
className="flex-1"
disabled={isLoading || isUploading}
>
Batal
</ModalButton>
<ModalButton
variant="primary"
onClick={handleSave}
className="flex-1"
disabled={isLoading || isUploading || !cvData.fileName}
loading={isLoading}
>
{isLoading ? 'Menyimpan...' : 'Simpan CV'}
</ModalButton>
</div>
</div>
</div>
);
};

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