Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d4b92b357 | ||
|
|
8e7c6e6c21 | ||
|
|
6960b17c41 | ||
|
|
a281f279f8 | ||
|
|
a157e11e13 | ||
|
|
b38afd2b44 | ||
|
|
3c301ec2c4 | ||
|
|
65a6bb686c | ||
|
|
f3a19fa460 |
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run typecheck:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
+52
@@ -0,0 +1,52 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export const AppLayout: FC = (): ReactElement => {
|
||||
<div className="bg-primary-50 min-h-screen flex justify-center">
|
||||
<div className="bg-primary-50 min-h-screen w-full flex">
|
||||
<BackofficeSidebar />
|
||||
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { ModalProps } from "../type";
|
||||
|
||||
export interface ModalDetailUserProps extends ModalProps {
|
||||
userId?: number | null
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export interface ModalProps {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
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">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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
@@ -2,10 +2,8 @@ import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { FC, useRef } from 'react';
|
||||
import { motion, useInView, Variants } from 'framer-motion';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export const CTASection: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const ref = useRef(null)
|
||||
const isInView = useInView(ref, { once: true, amount: 0.2 })
|
||||
|
||||
@@ -139,7 +137,6 @@ export const CTASection: FC = () => {
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-auto py-2 text-[10px] font-medium md:w-max md:text-sm md:px-2.5 md:font-semibold lg:text-base"
|
||||
onClick={() => navigate('/mentoring/ai-agent')}
|
||||
>
|
||||
Mulai Belajar Dengan AI
|
||||
</Button>
|
||||
@@ -147,7 +144,6 @@ export const CTASection: FC = () => {
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full h-auto py-2 text-[10px] font-medium md:w-max md:text-sm md:px-2.5 md:font-semibold lg:text-base"
|
||||
onClick={() => navigate('/mentoring')}
|
||||
>
|
||||
Temukan Mentor
|
||||
</Button>
|
||||
|
||||
@@ -5,11 +5,9 @@ import { motion, useInView, Variants } from 'framer-motion'
|
||||
import { cn, For } from '@imphnen-frontend-service/utils'
|
||||
|
||||
const FAQ_DATA: AccordionOptions[] = [
|
||||
{ title: 'Apakah saya bisa memilih mentor sendiri?', description: 'Tentu! Kamu bebas memilih mentor yang sesuai dengan kebutuhan, topik, dan level skill kamu. Setiap mentor punya profil, expertise, dan jadwal yang bisa kamu lihat sebelum booking.' },
|
||||
{ title: 'Apakah mentoring ini cocok untuk pemula?', description: 'Yap! Baik newbie maupun seasoned dev bisa menemukan mentor yang pas. Tidak tahu harus mulai dari mana? Tanya AI Agent dulu untuk arahan belajar, lalu booking mentor untuk pendampingan yang lebih dalam.' },
|
||||
{ title: 'Apakah ini seperti sekolah coding biasa?', description: 'Nope! Ini lebih fleksibel dari kursus pada umumnya. Rasa seperti punya mentor pribadi yang menyesuaikan pace belajarmu, plus AI Agent yang siap membantu kapan pun kamu macet.' },
|
||||
{ title: 'Bagaimana sistem pembayarannya?', description: 'Kamu bayar per sesi mentoring yang sudah di-booking. Pembayaran diproses via Midtrans (Virtual Account / QRIS), dan sesi otomatis terkonfirmasi begitu pembayaran lunas.' },
|
||||
{ title: 'Apakah ada materi yang bisa dipelajari gratis?', description: 'Ada! Halaman Materi berisi artikel dari para mentor yang bisa kamu baca kapan saja, dan AI Agent AI bisa menjawab pertanyaanmu berdasar materi tersebut tanpa biaya tambahan.' },
|
||||
{ title: 'Apakah saya bisa memilih mentor sendiri?', description: 'Tentu! Kamu bisa memilih mentor yang sesuai dengan kebutuhan dan level skill kamu.' },
|
||||
{ title: 'Apakah mentoring ini cocok untuk pemula?', description: 'Yap! Baik newbie maupun seasoned dev bisa menemukan mentor yang pas untuk mempercepat progres belajarnya.' },
|
||||
{ title: 'Apakah ini seperti sekolah coding biasa?', description: 'Nope! Ini lebih dari sekadar kursus-kursus AI guide dan mentor berpengalaman, kamu mendapatkan pengalaman belajar yang jauh lebih personal dan efektif.' },
|
||||
]
|
||||
|
||||
export const FAQSection: FC = () => {
|
||||
@@ -74,7 +72,7 @@ export const FAQSection: FC = () => {
|
||||
variant="bordered"
|
||||
className="h-auto px-3 py-2 font-semibold bg-primary-100 md:text-base md:px-5 md:py-2.5 lg:text-2xl lg:px-6 lg:py-3"
|
||||
>
|
||||
Frequently Asked Questions
|
||||
Frequently Ask Question
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Button } from "@imphnen-frontend-service/ui/atoms";
|
||||
import { motion } from 'framer-motion'
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export function HeroSection() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<section className="relative w-full z-0 py-20 md:py-28 lg:py-32">
|
||||
{/* Background gradients and shapes */}
|
||||
@@ -65,7 +63,6 @@ export function HeroSection() {
|
||||
type="button"
|
||||
size="sm"
|
||||
className="text-[10px] h-auto px-2.5 py-2 md:text-sm"
|
||||
onClick={() => navigate('/mentoring/ai-agent')}
|
||||
>
|
||||
Belajar Dengan AI Sekarang
|
||||
</Button>
|
||||
|
||||
@@ -8,33 +8,6 @@ export const TestimonialSection: FC = () => {
|
||||
const ref = useRef(null)
|
||||
const isInView = useInView(ref, { once: true, amount: 0.2 })
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
name: 'Riko, Junior Web Developer',
|
||||
quote:
|
||||
'Rasanya seperti punya AI waifu yang ngajarin ngoding! Mentoringnya juga super insightful. Thanks, Dimentorin!',
|
||||
rating: '4.8',
|
||||
},
|
||||
{
|
||||
name: 'Salsa, Data Analyst',
|
||||
quote:
|
||||
'AI Agent-nya ngebantu banget pas stuck di konsep SQL yang rumit. Jawabannya selalu lengkap sama sumber materinya. Recommended!',
|
||||
rating: '5.0',
|
||||
},
|
||||
{
|
||||
name: 'Dimas, Mahasiswa Sistem Informasi',
|
||||
quote:
|
||||
'Awalnya takut tanya dosen, sekarang tinggal chat AI agent atau booking mentor. Progress belajarku naik drastis dalam 2 bulan!',
|
||||
rating: '4.9',
|
||||
},
|
||||
{
|
||||
name: 'Nadia, UI/UX Designer',
|
||||
quote:
|
||||
'Sensei-nya sabar banget jelasin dari dasar. Materi Rust & Tokio di sini juga disusun rapi, jadi gampang diikuti step by step.',
|
||||
rating: '4.7',
|
||||
},
|
||||
]
|
||||
|
||||
const containerVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
@@ -84,7 +57,7 @@ export const TestimonialSection: FC = () => {
|
||||
viewport={{ once: true }}
|
||||
className="my-6 text-lg font-semibold mx-auto text-center text-primary-500 md:text-2xl lg:my-10 lg:text-4xl"
|
||||
>
|
||||
Words from our fellow devs
|
||||
Words from our dellow devs
|
||||
</motion.h1>
|
||||
|
||||
<div className="scrollbar-hide w-full mx-auto max-w-7xl px-8 overflow-auto md:px-[60px] xl:px-0">
|
||||
@@ -94,8 +67,8 @@ export const TestimonialSection: FC = () => {
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
>
|
||||
<For data={testimonials}>
|
||||
{(testimonial, index) => (
|
||||
<For data={Array.from({ length: 2 })}>
|
||||
{(_, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={childVariants}
|
||||
@@ -110,10 +83,10 @@ export const TestimonialSection: FC = () => {
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="mb-3 font-semibold text-primary-500 md:text-lg lg:text-2xl">
|
||||
{testimonial.name}
|
||||
Riko, Junior Web Developer
|
||||
</h3>
|
||||
<p className="mb-6 text-[10px] text-neutral-500 font-medium md:text-sm lg:text-base">
|
||||
{testimonial.quote}
|
||||
Rasanya seperti punya AI waifu yang ngajarin ngoding! Mentoringnya juga super insightful. Thanks, Dimentorin!
|
||||
</p>
|
||||
<motion.div className="w-max" whileHover={{ scale: 1.1, rotate: -3 }}>
|
||||
<Button
|
||||
@@ -124,7 +97,7 @@ export const TestimonialSection: FC = () => {
|
||||
>
|
||||
<StarFilled className="md:text-lg" />
|
||||
<span>
|
||||
<span className="text-sm font-medium md:text-lg md:font-semibold lg:text-xl">{testimonial.rating}</span>
|
||||
<span className="text-sm font-medium md:text-lg md:font-semibold lg:text-xl">4.8</span>
|
||||
<span className="text-[10px] text-primary-300 md:text-sm md:font-medium lg:text-base">/5.0</span>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
@@ -72,8 +72,8 @@ export const WhatWeOfferSection: FC = () => {
|
||||
<div
|
||||
className="absolute bottom-0 inset-x-0 p-4 h-1/2 text-primary-50 bg-gradient-to-t from-primary-500 via-primary-500/80 flex flex-col items-center justify-end gap-y-1 md:items-start lg:px-6 lg:pb-8"
|
||||
>
|
||||
<p className="font-semibold lg:text-[23px] lg:font-bold">{offer.title}</p>
|
||||
<p className="text-xs lg:text-[19px] lg:font-medium">{offer.description}</p>
|
||||
<p className="font-semibold lg:text-[26px] lg:font-bold">{offer.title}</p>
|
||||
<p className="text-xs lg:text-[22px] lg:font-medium">{offer.description}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="relative min-h-[70vh] flex items-center justify-center overflow-hidden bg-primary-50 px-6">
|
||||
{/* Decorative circles */}
|
||||
<div className="pointer-events-none absolute -top-24 -right-24 size-72 rounded-full bg-primary-200 md:size-96" />
|
||||
<div className="pointer-events-none absolute -bottom-28 -left-24 size-72 rounded-full bg-primary-100 md:size-96" />
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-2xl text-center">
|
||||
<p className="text-[46px] font-bold leading-none text-primary-500 md:text-[96px]">
|
||||
404
|
||||
</p>
|
||||
<h1 className="mt-4 text-[23px] font-semibold text-neutral-800 md:text-[29px]">
|
||||
Quest Tidak Ditemukan!
|
||||
</h1>
|
||||
<p className="mx-auto mt-3 max-w-md text-[15px] text-neutral-500 md:text-[19px]">
|
||||
Sepertinya halaman ini pergi ke isekai tanpa kamu... atau alamatnya
|
||||
salah ketik. Jangan khawatir, pahlawan sejati selalu tahu jalan
|
||||
pulang! 🗡️✨
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||
<Link to="/">
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="w-full sm:w-auto px-8"
|
||||
>
|
||||
Kembali ke Home
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/mentoring">
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="bordered"
|
||||
className="w-full sm:w-auto px-8"
|
||||
>
|
||||
Cari Mentor
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-[12px] text-neutral-400">
|
||||
Level up > 404 Not Found > Berhasil kembali ke jalur quest
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,6 @@ import { Link, NavLink, useLocation } from "react-router-dom";
|
||||
const MENUS: { label: string; href: string }[] = [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: 'Mentoring', href: '/mentoring' },
|
||||
{ label: 'Materi', href: '/mentoring/materi' },
|
||||
{ label: 'AI Agent', href: '/mentoring/ai-agent' },
|
||||
{ label: 'Dashboard', href: '/mentoring/dashboard' },
|
||||
{ label: 'Sesi Saya', href: '/mentoring/my-sessions' },
|
||||
{ label: 'Resources', href: '/resources' },
|
||||
{ label: 'Articles', href: '/articles' },
|
||||
]
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
import { Icon } from "@iconify/react";
|
||||
import { Button } from "@imphnen-frontend-service/ui/atoms";
|
||||
import { cn, For, Show } from "@imphnen-frontend-service/utils";
|
||||
import { cn } from "@imphnen-frontend-service/utils";
|
||||
import { motion } from "framer-motion";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useGetArticleBySlug } from "@imphnen-frontend-service/service";
|
||||
|
||||
export default function DetailArticle() {
|
||||
const { slug } = useParams()
|
||||
const { data: article, isLoading, isError } = useGetArticleBySlug(slug ?? "")
|
||||
|
||||
const paragraphs = article?.content.split("\n").filter((p) => p.trim()) ?? []
|
||||
const date = article?.created_at
|
||||
? new Date(article.created_at).toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" })
|
||||
: ""
|
||||
|
||||
return (
|
||||
<main>
|
||||
<motion.section
|
||||
@@ -22,83 +12,76 @@ export default function DetailArticle() {
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, ease: "easeInOut" }}
|
||||
>
|
||||
<Show
|
||||
condition={!isLoading && !isError && !!article}
|
||||
fallback={
|
||||
<div className="w-full max-w-[900px] mx-auto text-center text-neutral-500 py-20">
|
||||
{isError ? "Artikel tidak ditemukan." : "Memuat artikel..."}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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]"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
<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]"
|
||||
>
|
||||
{article?.title}
|
||||
</h1>
|
||||
<p
|
||||
className="text-[15px] font-semibold text-neutral-800 mb-2 md:text-[19px] md:mb-1 xl:text-[23px] xl:mb-2"
|
||||
>
|
||||
{article?.author_name || "IMPHNEN Editorial"}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-600 mb-6 md:text-[15px] xl:text-[19px]">
|
||||
{date}
|
||||
</p>
|
||||
<div
|
||||
className="text-[10px] text-primary-600 font-medium bg-primary-200 px-3 py-1 rounded-3xl w-max mx-auto md:text-xs xl:text-[15px]"
|
||||
>
|
||||
{article?.category}
|
||||
</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={article?.cover_url ?? "/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",
|
||||
)}
|
||||
<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]"
|
||||
>
|
||||
<p className="leading-tight">{article?.excerpt}</p>
|
||||
<For data={paragraphs}>
|
||||
{(paragraph) => (
|
||||
<p key={paragraph.slice(0, 40)} className="leading-tight">
|
||||
{paragraph}
|
||||
</p>
|
||||
)}
|
||||
</For>
|
||||
<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’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>
|
||||
</Show>
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -2,26 +2,12 @@ import { Button } from "@imphnen-frontend-service/ui/atoms"
|
||||
import { cn, Show } from "@imphnen-frontend-service/utils"
|
||||
import { FC } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { TArticleListItem } from "@imphnen-frontend-service/service"
|
||||
|
||||
export type ArticleCardProps = {
|
||||
variant?: "default" | "recent" | "featured"
|
||||
article?: TArticleListItem
|
||||
}
|
||||
|
||||
function formatDate(iso?: string) {
|
||||
if (!iso) return ""
|
||||
return new Date(iso).toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" })
|
||||
}
|
||||
|
||||
export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default', article }) => {
|
||||
const title = article?.title ?? "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
|
||||
const excerpt = article?.excerpt ?? "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
|
||||
const author = article?.author_name ?? "IMPHNEN Editorial"
|
||||
const date = formatDate(article?.created_at)
|
||||
const slug = article?.slug ?? ""
|
||||
const cover = article?.cover_url
|
||||
|
||||
export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default' }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -36,25 +22,25 @@ export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default', article
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={cover ?? "/image/59de8a682dfbe42455613ea07477ff3e30532c1f.webp"}
|
||||
alt={title}
|
||||
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-3xl mb-4 xl:mb-[15px]">
|
||||
{article?.category ?? "Artikel"}
|
||||
<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={slug ? `/articles/${slug}` : "/articles"}
|
||||
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-[29px]"
|
||||
variant === "recent" && "mb-7 xl:text-[27px]"
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
</Link>
|
||||
<Show condition={variant !== "recent"}>
|
||||
<p
|
||||
@@ -63,7 +49,7 @@ export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default', article
|
||||
variant === "featured" && "text-[15px] md:text-[19px] md:mb-6",
|
||||
)}
|
||||
>
|
||||
{excerpt}
|
||||
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
|
||||
@@ -73,16 +59,14 @@ export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default', article
|
||||
variant === "featured" && "text-[15px] md:text-[19px] xl:text-base",
|
||||
)}
|
||||
>
|
||||
<p>{author}</p>
|
||||
<p>{date}</p>
|
||||
<p>Writer’s Name</p>
|
||||
<p>14 March 2025</p>
|
||||
</div>
|
||||
|
||||
<Show condition={variant === "default"}>
|
||||
<Link to={slug ? `/articles/${slug}` : "/articles"}>
|
||||
<Button type="button" size="sm" className="px-2.5 py-2">
|
||||
Baca Artikel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="button" size="sm" className="px-2.5 py-2">
|
||||
Baca Artikel
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import { ReactElement, useRef, useState } from "react";
|
||||
import { ArticleCard } from "./_components/card/article";
|
||||
import { cn, For, Show } from "@imphnen-frontend-service/utils";
|
||||
import { cn, For } from "@imphnen-frontend-service/utils";
|
||||
import { Button } from "@imphnen-frontend-service/ui/atoms";
|
||||
import { motion, useInView, Variants } from "framer-motion";
|
||||
import { useGetArticleCategories, useGetArticles } from "@imphnen-frontend-service/service";
|
||||
|
||||
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<string>("Semua")
|
||||
const { data: articles = [], isLoading } = useGetArticles({ per_page: 20 })
|
||||
const { data: categories = [] } = useGetArticleCategories()
|
||||
|
||||
const tabs = ["Semua", ...categories]
|
||||
|
||||
const featured = articles[0]
|
||||
const recent = articles.slice(1, 4)
|
||||
const filtered = activeTab === "Semua"
|
||||
? articles
|
||||
: articles.filter((a) => a.category === activeTab)
|
||||
const [activeTab, setActiveTab] = useState<Category>('UI/UX Design')
|
||||
|
||||
const ref = useRef(null)
|
||||
const isInView = useInView(ref, { once: true, amount: 0.2 })
|
||||
@@ -60,34 +52,29 @@ export default function Components(): ReactElement {
|
||||
Featuring Articles
|
||||
</motion.h1>
|
||||
|
||||
<Show
|
||||
condition={!isLoading}
|
||||
fallback={<p className="text-neutral-500">Memuat artikel...</p>}
|
||||
>
|
||||
<div className="grid gap-12 xl:grid-cols-2">
|
||||
<motion.div variants={childVariants}>
|
||||
<ArticleCard variant="featured" article={featured} />
|
||||
</motion.div>
|
||||
<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={recent}>
|
||||
{(article, index) => (
|
||||
<motion.div key={article.id ?? index} variants={childVariants}>
|
||||
<ArticleCard variant="recent" article={article} />
|
||||
</motion.div>
|
||||
)}
|
||||
</For>
|
||||
</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>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto">
|
||||
@@ -99,7 +86,7 @@ export default function Components(): ReactElement {
|
||||
</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={tabs}>
|
||||
<For data={CATEGORIES}>
|
||||
{(category) => (
|
||||
<Button
|
||||
key={category}
|
||||
@@ -107,7 +94,7 @@ export default function Components(): ReactElement {
|
||||
variant="text"
|
||||
onClick={() => setActiveTab(category)}
|
||||
className={cn(
|
||||
"relative text-[10px] text-neutral-400 font-medium px-3 py-2 rounded-3xl md:text-xs md:font-semibold xl:text-[15px]",
|
||||
"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"
|
||||
)}
|
||||
@@ -119,20 +106,15 @@ export default function Components(): ReactElement {
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
condition={filtered.length > 0}
|
||||
fallback={<p className="text-neutral-500 text-center py-10">Belum ada artikel di kategori ini.</p>}
|
||||
>
|
||||
<div className="grid gap-8 xl:grid-cols-3">
|
||||
<For data={filtered}>
|
||||
{(article, index) => (
|
||||
<motion.div key={article.id ?? index} variants={childVariants}>
|
||||
<ArticleCard article={article} />
|
||||
</motion.div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<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,220 +0,0 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowRightOutlined, FireOutlined, TrophyOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
|
||||
const STATS = [
|
||||
{ value: '1000+', label: 'Kohai Menunggu' },
|
||||
{ value: '500+', label: 'Sensei Aktif' },
|
||||
];
|
||||
|
||||
const GUILD_CARDS = [
|
||||
{
|
||||
icon: <ThunderboltOutlined />,
|
||||
title: 'Power-Up Para Kohai',
|
||||
description:
|
||||
'Setiap tips dan trik dari sensei bakal langsung boost skill mereka! Kayak main RPG tapi real life—level up yang beneran meaningful!',
|
||||
},
|
||||
{
|
||||
icon: <FireOutlined />,
|
||||
title: 'Skill Jadi Gold Mine',
|
||||
description:
|
||||
'Knowledge yang udah kamu grind bertahun-tahun sekarang bisa jadi sumber cuan! Farming EXP sambil farming income, double win!',
|
||||
},
|
||||
{
|
||||
icon: <TrophyOutlined />,
|
||||
title: 'Legendary Sensei Status',
|
||||
description:
|
||||
'Jadi mentor yang namanya diingat sampai kohai-mu jadi senior! Your legacy bakal hidup terus di setiap success story mereka.',
|
||||
},
|
||||
];
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<main>
|
||||
{/* HERO */}
|
||||
<section className="relative overflow-hidden bg-primary-50/60">
|
||||
<div className="max-w-7xl mx-auto px-8 py-16 md:px-[60px] md:py-20 lg:px-20 lg:py-24 flex flex-col items-center text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="max-w-3xl"
|
||||
>
|
||||
<h1 className="text-[29px] font-bold text-primary-500 leading-tight md:text-[37px] lg:text-[46px]">
|
||||
Jadi Sensei di Dunia IT?
|
||||
</h1>
|
||||
<p className="mt-4 text-[15px] font-medium text-neutral-600 md:text-[19px]">
|
||||
Waktunya naik level jadi mentor legendaris!
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex justify-center gap-6 md:gap-12">
|
||||
{STATS.map((s) => (
|
||||
<div key={s.label} className="text-center">
|
||||
<p className="text-[23px] font-bold text-primary-500 md:text-[29px]">
|
||||
{s.value}
|
||||
</p>
|
||||
<p className="text-xs font-medium text-neutral-500 md:text-[15px]">
|
||||
{s.label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
onClick={() => navigate('/auth/register-mentor')}
|
||||
>
|
||||
Mulai Jadi Sensei <ArrowRightOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* VALUE PROPOSITION */}
|
||||
<section className="max-w-7xl mx-auto px-8 py-14 md:px-[60px] md:py-16 lg:px-20">
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="text-[23px] font-bold text-neutral-800 md:text-[29px]">
|
||||
Kenapa Harus Join Guild Sensei Kami? 🔥
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{GUILD_CARDS.map((card, i) => (
|
||||
<motion.div
|
||||
key={card.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: i * 0.1 }}
|
||||
className="bg-white border border-neutral-100 rounded-xl shadow-sm p-8 text-center"
|
||||
>
|
||||
<div className="mx-auto mb-5 flex size-16 items-center justify-center rounded-full bg-primary-100 text-3xl text-primary-500">
|
||||
{card.icon}
|
||||
</div>
|
||||
<h3 className="text-[19px] font-semibold text-neutral-800 mb-3">
|
||||
{card.title}
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-500 leading-relaxed">
|
||||
{card.description}
|
||||
</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* TESTIMONIAL */}
|
||||
<section className="bg-white py-14 md:py-16">
|
||||
<div className="max-w-7xl mx-auto px-8 md:px-[60px] lg:px-20">
|
||||
<div className="text-center mb-10">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-primary-500 mb-2">
|
||||
Testimonial
|
||||
</p>
|
||||
<h2 className="text-[23px] font-bold text-neutral-800 md:text-[29px]">
|
||||
What the Legendary Sensei Says
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-primary-50/60 border border-primary-100 rounded-xl p-8"
|
||||
>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="size-12 rounded-full bg-primary-500 text-white flex items-center justify-center font-bold">
|
||||
R
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-neutral-800 text-[15px]">
|
||||
Riko, Junior Web Developer
|
||||
</p>
|
||||
<p className="text-primary-500 text-xs">4.8/5.0 ★</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600 leading-relaxed">
|
||||
Rasanya seperti punya AI waifu yang ngajarin ngoding!
|
||||
Mentoringnya juga super insightful. Thanks, Dimentorin!
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ */}
|
||||
<section className="bg-primary-50/60 py-14 md:py-16">
|
||||
<div className="max-w-7xl mx-auto px-8 md:px-[60px] lg:px-20">
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="text-[23px] font-bold text-neutral-800 md:text-[29px]">
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
<p className="mt-2 text-[15px] font-medium text-neutral-500">
|
||||
Kata Para Sensei Legendaris
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
{[
|
||||
{
|
||||
q: 'Berapa income yang bisa didapat?',
|
||||
a: 'Kamu akan mendapatkan income 100rb/sesi/45 menit.',
|
||||
},
|
||||
{
|
||||
q: 'Bagaimana sistem pembayarannya?',
|
||||
a: 'Tentu! Kamu bisa memilih mentor yang sesuai dengan kebutuhan dan level skill kamu.',
|
||||
},
|
||||
{
|
||||
q: 'Apakah bisa flexible dalam mengambil sesi?',
|
||||
a: 'Tentu! Kamu bisa memilih mentor yang sesuai dengan kebutuhan dan level skill kamu.',
|
||||
},
|
||||
].map((faq, i) => (
|
||||
<details
|
||||
key={i}
|
||||
className="group bg-white border border-neutral-100 rounded-lg p-5 open:shadow-sm"
|
||||
>
|
||||
<summary className="flex items-center justify-between cursor-pointer list-none font-semibold text-neutral-800 text-[15px]">
|
||||
{faq.q}
|
||||
<span className="text-primary-500 group-open:rotate-45 transition-transform">
|
||||
+
|
||||
</span>
|
||||
</summary>
|
||||
<p className="mt-3 text-sm text-neutral-500 leading-relaxed">
|
||||
{faq.a}
|
||||
</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="bg-primary-600 relative overflow-hidden py-16 md:py-20">
|
||||
<div className="max-w-4xl mx-auto px-8 text-center relative z-10">
|
||||
<h2 className="text-[23px] font-bold text-white md:text-[29px]">
|
||||
Ready to become the Legendary Sensei?
|
||||
</h2>
|
||||
<p className="mt-4 text-[15px] text-primary-100">
|
||||
Join ribuan expert lainnya yang sudah membuktikan impact mereka.
|
||||
Your expertise is needed!
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
onClick={() => navigate('/auth/register-mentor')}
|
||||
>
|
||||
Daftar Sekarang 🚀
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default Components;
|
||||
+9
-62
@@ -10,12 +10,6 @@ import { QrisPaymentStep } from "./steps/qris-payement"
|
||||
import { VAPaymentStep } from "./steps/va-payment"
|
||||
import { SuccessStep } from "./steps/success"
|
||||
import { PaymentStep } from "./steps/payment"
|
||||
import {
|
||||
TBookSessionRequest,
|
||||
TPayment,
|
||||
usePostBookSession,
|
||||
usePostCreatePayment,
|
||||
} from "@imphnen-frontend-service/service"
|
||||
|
||||
const STEPS = ['topic', 'schedule', 'profile', 'payment', 'qr-payment', 'va-payment', 'success'] as const
|
||||
type Step = typeof STEPS[number]
|
||||
@@ -23,56 +17,15 @@ type Step = typeof STEPS[number]
|
||||
type Props = {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
mentorId?: string
|
||||
mentor?: {
|
||||
fullname?: string | null
|
||||
current_role?: string
|
||||
current_company?: string
|
||||
mentoring_rate?: number
|
||||
}
|
||||
}
|
||||
|
||||
export const AppointmentModal: FC<Props> = ({ open, setOpen, mentorId, mentor }) => {
|
||||
export const AppointmentModal: FC<Props> = ({ open, setOpen }) => {
|
||||
const [step, setStep] = useState<Step>('topic')
|
||||
const [selectedTopics, setSelectedTopics] = useState<number[]>([])
|
||||
const [booking, setBooking] = useState<TBookSessionRequest | null>(null)
|
||||
const [payment, setPayment] = useState<TPayment | null>(null)
|
||||
const [paymentMethod, setPaymentMethod] = useState<'va' | 'qris'>('va')
|
||||
const [error, setError] = useState("")
|
||||
const [sessionId, setSessionId] = useState("")
|
||||
const bookMutation = usePostBookSession(mentorId ?? "")
|
||||
const paymentMutation = usePostCreatePayment(sessionId)
|
||||
|
||||
const handleStep = async (action: 'next' | 'prev') => {
|
||||
setError("")
|
||||
if (step === 'payment' && action === 'next') {
|
||||
if (!mentorId || !booking) {
|
||||
setError("Data booking tidak lengkap.")
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 1. Book the session -> get session id
|
||||
const booked = await bookMutation.mutateAsync({ ...booking, topic: booking.topic || `Mentoring #${selectedTopics[0] ?? 1}` })
|
||||
const sid = (booked as unknown as { data?: { id?: string } })?.data?.id ?? ""
|
||||
if (!sid) {
|
||||
setError("Gagal membuat sesi mentoring. Coba lagi.")
|
||||
return
|
||||
}
|
||||
setSessionId(sid)
|
||||
// 2. Create payment for the session with the chosen method
|
||||
const pm = await paymentMutation.mutateAsync({ method: paymentMethod })
|
||||
setPayment(pm)
|
||||
// 3. Route to the method-specific payment screen
|
||||
setStep(paymentMethod === 'qris' ? 'qr-payment' : 'va-payment')
|
||||
} catch {
|
||||
setError("Gagal membuat sesi mentoring. Coba lagi.")
|
||||
}
|
||||
return
|
||||
}
|
||||
const handleStep = (action: 'next' | 'prev') => {
|
||||
if (action === 'next' && step === 'success') {
|
||||
setOpen(false)
|
||||
} else if (action === 'next' && (step === 'qr-payment' || step === 'va-payment')) {
|
||||
setStep('success')
|
||||
} else if (action === 'next') {
|
||||
setStep(STEPS[STEPS.indexOf(step) + 1])
|
||||
} else if (action === 'prev' && step !== 'topic') {
|
||||
@@ -159,18 +112,14 @@ export const AppointmentModal: FC<Props> = ({ open, setOpen, mentorId, mentor })
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{step === 'topic' && <TopicStep selectedTopics={selectedTopics} setSelectedTopics={setSelectedTopics} mentor={mentor} />}
|
||||
{step === 'schedule' && <ScheduleStep onChange={(data) => setBooking(data)} />}
|
||||
{step === 'topic' && <TopicStep selectedTopics={selectedTopics} setSelectedTopics={setSelectedTopics} />}
|
||||
{step === 'schedule' && <ScheduleStep />}
|
||||
{step === 'profile' && <ProfileStep />}
|
||||
{step === 'payment' && <PaymentStep selectedTopics={selectedTopics} mentor={mentor} booking={booking} onMethodChange={setPaymentMethod} />}
|
||||
{step === 'qr-payment' && <QrisPaymentStep payment={payment} />}
|
||||
{step === 'va-payment' && <VAPaymentStep payment={payment} />}
|
||||
{step === 'payment' && <PaymentStep selectedTopics={selectedTopics} />}
|
||||
{step === 'qr-payment' && <QrisPaymentStep />}
|
||||
{step === 'va-payment' && <VAPaymentStep />}
|
||||
{step === 'success' && <SuccessStep />}
|
||||
</AnimatePresence>
|
||||
|
||||
{error && (
|
||||
<p className="text-danger-600 text-xs mt-4 font-medium">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Show condition={step !== 'success'}>
|
||||
@@ -199,10 +148,8 @@ export const AppointmentModal: FC<Props> = ({ open, setOpen, mentorId, mentor })
|
||||
condition={step !== 'success'}
|
||||
fallback="Halman Booking"
|
||||
>
|
||||
<Show condition={step !== 'payment'} fallback={bookMutation.isPending ? "Memproses..." : "Bayar Sekarang"}>
|
||||
<Show condition={step !== 'profile'} fallback="Pembayaran">
|
||||
Selanjutnya
|
||||
</Show>
|
||||
<Show condition={step !== 'payment'} fallback="Bayar Sekarang">
|
||||
Selanjutnya
|
||||
</Show>
|
||||
</Show>
|
||||
</Button>
|
||||
|
||||
+16
-49
@@ -1,34 +1,13 @@
|
||||
import { For, Show } from "@imphnen-frontend-service/utils"
|
||||
import { FC, useState } from "react"
|
||||
import { FC } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { TOPICS } from "../../../sections/topics"
|
||||
import { TBookSessionRequest } from "@imphnen-frontend-service/service"
|
||||
import { MentorAvatar } from "../../../../../_components/mentor-avatar"
|
||||
|
||||
type Props = {
|
||||
selectedTopics: number[]
|
||||
mentor?: {
|
||||
fullname?: string | null;
|
||||
current_role?: string;
|
||||
current_company?: string;
|
||||
mentoring_rate?: number;
|
||||
} | null
|
||||
booking?: TBookSessionRequest | null
|
||||
onMethodChange: (method: 'va' | 'qris') => void
|
||||
}
|
||||
|
||||
const BANK_OPTIONS = ['BCA', 'BRI', 'BNI', 'MANDIRI']
|
||||
|
||||
export const PaymentStep: FC<Props> = ({ selectedTopics, mentor, booking, onMethodChange }) => {
|
||||
const rate = mentor?.mentoring_rate ?? 50000
|
||||
const serviceFee = 2000
|
||||
const [method, setMethod] = useState<'va' | 'qris'>('va')
|
||||
|
||||
const handleMethod = (next: 'va' | 'qris') => {
|
||||
setMethod(next)
|
||||
onMethodChange(next)
|
||||
}
|
||||
|
||||
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"
|
||||
@@ -43,25 +22,15 @@ export const PaymentStep: FC<Props> = ({ selectedTopics, mentor, booking, onMeth
|
||||
</p>
|
||||
<div>
|
||||
<div className="aspect-square mb-2 size-10 mx-auto rounded-full overflow-hidden md:size-20 md:ms-0">
|
||||
<MentorAvatar
|
||||
name={mentor?.fullname || "Mentor"}
|
||||
size="sm"
|
||||
className="w-full h-full rounded-full"
|
||||
textClassName="text-sm md:text-xl"
|
||||
/>
|
||||
<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]">
|
||||
{mentor?.fullname || "Mentor"}
|
||||
Muhammad Firdaus Oi Oi Oi, S.H., M.H.
|
||||
</h3>
|
||||
<p className="text-[10px] text-neutral-600 md:text-xs">
|
||||
{mentor?.current_role || "Mentor"} at {mentor?.current_company || "IMPHNEN"}
|
||||
UI Designer at Oray orayan Studios
|
||||
</p>
|
||||
{booking?.scheduled_at && (
|
||||
<p className="text-[10px] text-neutral-500 md:text-xs mt-1">
|
||||
{new Date(booking.scheduled_at).toLocaleString("id-ID", { dateStyle: "full", timeStyle: "short" })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,16 +65,15 @@ export const PaymentStep: FC<Props> = ({ selectedTopics, mentor, booking, onMeth
|
||||
<p className="text-[8px] text-neutral-400 mb-2 md:text-[10px]">
|
||||
Virtual Account
|
||||
</p>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<For data={BANK_OPTIONS}>
|
||||
<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 cursor-pointer"
|
||||
onClick={() => handleMethod('va')}
|
||||
className="p-1 bg-white rounded-xs border border-primary-50 flex items-center gap-x-1"
|
||||
>
|
||||
<input type="radio" name="payment" id={`va-${bank}`} checked={method === 'va'} readOnly className="size-2" />
|
||||
<label htmlFor={`va-${bank}`} className="block">
|
||||
<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>
|
||||
@@ -117,12 +85,11 @@ export const PaymentStep: FC<Props> = ({ selectedTopics, mentor, booking, onMeth
|
||||
<p className="text-[8px] text-neutral-400 mb-2 md:text-[10px]">
|
||||
QRIS
|
||||
</p>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<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 cursor-pointer"
|
||||
onClick={() => handleMethod('qris')}
|
||||
className="p-1 bg-white rounded-xs border border-primary-50 flex items-center gap-x-1"
|
||||
>
|
||||
<input type="radio" name="payment" id="QRIS" checked={method === 'qris'} readOnly className="size-2" />
|
||||
<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>
|
||||
@@ -137,14 +104,14 @@ export const PaymentStep: FC<Props> = ({ selectedTopics, mentor, booking, onMeth
|
||||
</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. {rate.toLocaleString("id-ID")}</p>
|
||||
<p className="text-right">Rp. 50.000</p>
|
||||
<p className="font-normal">Service Fee :</p>
|
||||
<p className="text-right">Rp. {serviceFee.toLocaleString("id-ID")}</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. {(rate + serviceFee).toLocaleString("id-ID")}</p>
|
||||
<p className="text-right text-neutral-700">Rp. 52.000</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+5
-30
@@ -1,7 +1,5 @@
|
||||
import { For, Show } from "@imphnen-frontend-service/utils"
|
||||
import { FC } from "react"
|
||||
import { For } from "@imphnen-frontend-service/utils"
|
||||
import { motion } from "framer-motion"
|
||||
import { TPayment } from "@imphnen-frontend-service/service"
|
||||
|
||||
const PAYMENT_STEP = [
|
||||
'Buka aplikasi e-wallet atau m-banking kamu',
|
||||
@@ -10,11 +8,7 @@ const PAYMENT_STEP = [
|
||||
'Konfimasi pembayaran, dan proses selesai.'
|
||||
]
|
||||
|
||||
type Props = {
|
||||
payment?: TPayment | null
|
||||
}
|
||||
|
||||
export const QrisPaymentStep: FC<Props> = ({ payment }) => {
|
||||
export const QrisPaymentStep = () => {
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-white px-6 py-5 rounded-md md:flex md:justify-between md:gap-8"
|
||||
@@ -44,14 +38,7 @@ export const QrisPaymentStep: FC<Props> = ({ payment }) => {
|
||||
<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. {(payment?.total ?? 52000).toLocaleString("id-ID")}
|
||||
</p>
|
||||
<Show condition={!!payment?.externalRef && payment.provider !== 'midtrans'}>
|
||||
<p className="text-[10px] text-neutral-400 mt-1 font-medium">
|
||||
Ref: {payment?.externalRef}
|
||||
</p>
|
||||
</Show>
|
||||
<p className="text-xs font-semibold text-neutral-700 md:text-[15px]">Rp. 52.000</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,21 +47,9 @@ export const QrisPaymentStep: FC<Props> = ({ payment }) => {
|
||||
QR Code Pembayaran
|
||||
</p>
|
||||
<div className="bg-primary-50 p-2.5 rounded-lg size-[120px] mx-auto md:size-[142px] md:me-0">
|
||||
<Show
|
||||
condition={!!payment?.externalRef && payment.provider === 'midtrans'}
|
||||
fallback={<img src="/image/sample-qrcode.webp" alt="QR Code" className="w-full" />}
|
||||
>
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=${encodeURIComponent(payment!.externalRef!)}`}
|
||||
alt="QR Code Midtrans"
|
||||
className="w-full"
|
||||
/>
|
||||
</Show>
|
||||
<img src="/image/sample-qrcode.webp" alt="QR Code" className="w-full" />
|
||||
</div>
|
||||
<p className="text-[8px] text-neutral-500 text-center mt-2 md:text-[10px]">
|
||||
Berlaku hingga {payment ? new Date(payment.expiresAt).toLocaleString("id-ID") : "-"}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-30
@@ -1,11 +1,9 @@
|
||||
import { Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
|
||||
import { cn } from "@imphnen-frontend-service/utils"
|
||||
import { motion } from "framer-motion"
|
||||
import { useEffect, useState } from "react"
|
||||
import { TBookSessionRequest } from "@imphnen-frontend-service/service"
|
||||
|
||||
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 ___.
|
||||
@@ -15,27 +13,7 @@ Saya ingin tahu secara khusus tentang ___.
|
||||
|
||||
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 Props = {
|
||||
onChange: (data: TBookSessionRequest) => void
|
||||
}
|
||||
|
||||
export const ScheduleStep = ({ onChange }: Props) => {
|
||||
const [date, setDate] = useState("")
|
||||
const [time, setTime] = useState("")
|
||||
const [sessionType, setSessionType] = useState("online")
|
||||
const [description, setDescription] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (!date || !time) return
|
||||
onChange({
|
||||
topic: "Mentoring Session",
|
||||
scheduled_at: `${date}T${time}:00`,
|
||||
description,
|
||||
duration_minutes: 60,
|
||||
session_type: sessionType,
|
||||
})
|
||||
}, [date, time, sessionType, description])
|
||||
|
||||
export const ScheduleStep = () => {
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-white px-6 py-5 rounded-md md:px-6"
|
||||
@@ -53,15 +31,15 @@ export const ScheduleStep = ({ onChange }: Props) => {
|
||||
<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" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
<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" value={time} onChange={(e) => setTime(e.target.value)} />
|
||||
<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" value={sessionType} onChange={(e) => setSessionType(e.target.value)}>
|
||||
<Select className="min-w-full w-full">
|
||||
<option value="online">Online</option>
|
||||
<option value="offline">Offline</option>
|
||||
</Select>
|
||||
@@ -70,9 +48,7 @@ export const ScheduleStep = ({ onChange }: Props) => {
|
||||
<label className={labelClass}>Pertanyaan Untuk Senpai</label>
|
||||
<Textarea
|
||||
className="min-w-full w-full h-40"
|
||||
placeholder={placeholder}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)} />
|
||||
placeholder={placeholder} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
+2
-2
@@ -16,11 +16,11 @@ export const SuccessStep = () => {
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mb-3 flex justify-center items-center mx-auto size-[60px] bg-success-200 rounded-full",
|
||||
"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-success-500 rounded-full size-10 flex justify-center items-center text-white md:size-16 xl:size-20">
|
||||
<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>
|
||||
|
||||
+4
-15
@@ -3,19 +3,13 @@ import { cn, For, Show } from "@imphnen-frontend-service/utils"
|
||||
import { FC } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { TOPICS } from "../../../sections/topics"
|
||||
import { MentorAvatar } from "../../../../../_components/mentor-avatar"
|
||||
|
||||
type Props = {
|
||||
selectedTopics: number[]
|
||||
setSelectedTopics: (topics: number[]) => void
|
||||
mentor?: {
|
||||
fullname?: string | null
|
||||
current_role?: string
|
||||
current_company?: string
|
||||
}
|
||||
}
|
||||
|
||||
export const TopicStep: FC<Props> = ({ selectedTopics, setSelectedTopics, mentor }) => {
|
||||
export const TopicStep: FC<Props> = ({ selectedTopics, setSelectedTopics }) => {
|
||||
const handleSelectTopic = (topicId: number) => {
|
||||
if (selectedTopics.includes(topicId)) {
|
||||
setSelectedTopics(selectedTopics.filter((id) => id !== topicId))
|
||||
@@ -33,19 +27,14 @@ export const TopicStep: FC<Props> = ({ selectedTopics, setSelectedTopics, mentor
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
>
|
||||
<div className="w-16 aspect-4/5 overflow-hidden md:w-[134px] xl:w-[140px]">
|
||||
<MentorAvatar
|
||||
name={mentor?.fullname || "Mentor"}
|
||||
size="md"
|
||||
className="w-full h-full rounded-none"
|
||||
textClassName="text-lg md:text-3xl"
|
||||
/>
|
||||
<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]">
|
||||
{mentor?.fullname || "Mentor"}
|
||||
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]">
|
||||
{mentor?.current_role || "Mentor"} at {mentor?.current_company || "IMPHNEN"}
|
||||
UI Designer at Oray orayan Studios
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
+6
-26
@@ -1,7 +1,5 @@
|
||||
import { For, Show } from "@imphnen-frontend-service/utils"
|
||||
import { FC } from "react"
|
||||
import { For } from "@imphnen-frontend-service/utils"
|
||||
import { motion } from "framer-motion"
|
||||
import { TPayment } from "@imphnen-frontend-service/service"
|
||||
|
||||
const PAYMENT_STEP = [
|
||||
'Buka aplikasi e-wallet atau m-banking kamu',
|
||||
@@ -10,11 +8,7 @@ const PAYMENT_STEP = [
|
||||
'Konfimasi pembayaran, dan proses selesai.'
|
||||
]
|
||||
|
||||
type Props = {
|
||||
payment?: TPayment | null
|
||||
}
|
||||
|
||||
export const VAPaymentStep: FC<Props> = ({ payment }) => {
|
||||
export const VAPaymentStep = () => {
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-white px-6 py-5 rounded-md md:flex md:items-center md:gap-11"
|
||||
@@ -30,7 +24,7 @@ export const VAPaymentStep: FC<Props> = ({ payment }) => {
|
||||
<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">
|
||||
<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">
|
||||
@@ -39,15 +33,6 @@ export const VAPaymentStep: FC<Props> = ({ payment }) => {
|
||||
)}
|
||||
</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. {(payment?.total ?? 52000).toLocaleString("id-ID")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center md:text-start">
|
||||
@@ -59,17 +44,12 @@ export const VAPaymentStep: FC<Props> = ({ payment }) => {
|
||||
Kode Virtual Account
|
||||
</p>
|
||||
<p className="text-xs font-semibold text-neutral-700 mb-1.5 md:text-[15px]">
|
||||
{payment?.externalRef || "8091239861969812"}
|
||||
8091239861969812
|
||||
</p>
|
||||
<p className="text-[8px] text-neutral-400 md:text-[10px]">
|
||||
A/N IMPHNEN
|
||||
A/N Unknown
|
||||
</p>
|
||||
<Show condition={!!payment?.expiresAt}>
|
||||
<p className="text-[8px] text-neutral-500 mt-2 md:text-[10px]">
|
||||
Berlaku hingga {new Date(payment!.expiresAt).toLocaleString("id-ID")}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import { StarFilled } from "@ant-design/icons"
|
||||
import { Button } from "@imphnen-frontend-service/ui/atoms"
|
||||
import { cn, For } from "@imphnen-frontend-service/utils"
|
||||
import { TMentorDetail } from "@imphnen-frontend-service/service"
|
||||
import { MentorAvatar } from "../../../_components/mentor-avatar"
|
||||
|
||||
type Props = {
|
||||
onBook: () => void
|
||||
mentor?: TMentorDetail
|
||||
}
|
||||
|
||||
export const ProfileSection: React.FC<Props> = ({ onBook, mentor }) => {
|
||||
const name = mentor?.fullname || mentor?.legal_name || "Mentor"
|
||||
const role = mentor?.current_role || "Mentor"
|
||||
const company = mentor?.current_company || "IMPHNEN"
|
||||
const expertise = mentor?.expertise ?? []
|
||||
const softSkills = mentor?.languages ?? []
|
||||
|
||||
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
|
||||
@@ -32,12 +23,7 @@ export const ProfileSection: React.FC<Props> = ({ onBook, mentor }) => {
|
||||
"xl:aspect-5/6 md:h-60 md:rounded-lg"
|
||||
)}
|
||||
>
|
||||
<MentorAvatar
|
||||
name={name}
|
||||
size="lg"
|
||||
className="w-full h-full rounded-none"
|
||||
textClassName="text-xl md:text-3xl lg:text-5xl"
|
||||
/>
|
||||
<img src="/image/testimonial.webp" alt="Mentor" className="w-full object-cover" />
|
||||
</div>
|
||||
|
||||
<div className="md:max-w-[242px] xl:max-w-[306px]">
|
||||
@@ -47,10 +33,10 @@ export const ProfileSection: React.FC<Props> = ({ onBook, mentor }) => {
|
||||
"md:text-[19px] md:mb-2 md:text-start xl:text-[23px]",
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
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">
|
||||
{role} at {company}
|
||||
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
|
||||
@@ -70,7 +56,7 @@ export const ProfileSection: React.FC<Props> = ({ onBook, mentor }) => {
|
||||
<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={expertise}>
|
||||
<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}
|
||||
@@ -82,7 +68,7 @@ export const ProfileSection: React.FC<Props> = ({ onBook, mentor }) => {
|
||||
<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={softSkills}>
|
||||
<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}
|
||||
@@ -95,12 +81,8 @@ export const ProfileSection: React.FC<Props> = ({ onBook, mentor }) => {
|
||||
|
||||
<div className="hidden md:flex xl:hidden justify-between">
|
||||
<div>
|
||||
<p className="text-primary-500 text-[15px] font-semibold">
|
||||
{new Date().toLocaleDateString('id-ID', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' })}
|
||||
</p>
|
||||
<p className="text-neutral-500 text-xs font-medium">
|
||||
{new Date().toLocaleDateString('id-ID', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</p>
|
||||
<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!
|
||||
|
||||
+54
-63
@@ -1,77 +1,68 @@
|
||||
import { Button } from "@imphnen-frontend-service/ui/atoms"
|
||||
import { For, Show } from "@imphnen-frontend-service/utils"
|
||||
import { useGetMentorAvailability } from "@imphnen-frontend-service/service"
|
||||
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
|
||||
mentorId?: string
|
||||
}
|
||||
|
||||
const DAY_NAMES = ["Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"]
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
const d = new Date(dateStr + "T00:00:00")
|
||||
return `${DAY_NAMES[d.getDay()]}, ${d.getDate()} ${d.toLocaleString("id-ID", { month: "long" })} ${d.getFullYear()}`
|
||||
}
|
||||
|
||||
function formatTime(timeStr: string) {
|
||||
const [h] = timeStr.split(":").map(Number)
|
||||
return `${String(h).padStart(2, "0")}:00 WIB`
|
||||
}
|
||||
|
||||
export const SenpaiScheduleSection: FC<Props> = ({ onBook, mentorId }) => {
|
||||
const { data: availability, isLoading } = useGetMentorAvailability(mentorId ?? "")
|
||||
|
||||
const slots = availability?.slots ?? []
|
||||
const byDate = new Map<string, typeof slots>()
|
||||
for (const s of slots) {
|
||||
if (!byDate.has(s.date)) byDate.set(s.date, [])
|
||||
byDate.get(s.date)!.push(s)
|
||||
}
|
||||
const days = [...byDate.entries()]
|
||||
|
||||
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">
|
||||
<Show
|
||||
condition={!isLoading && days.length > 0}
|
||||
fallback={
|
||||
<p className="text-xs text-neutral-500">
|
||||
{isLoading ? "Memuat jadwal senpai..." : "Belum ada jadwal tersedia."}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<For data={days}>
|
||||
{([date, daySlots]) => (
|
||||
<div key={date}>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<p className="text-[15px] font-medium">{formatDate(date)}</p>
|
||||
<p className="rounded-3xl py-1 px-2.5 text-xs font-medium bg-primary-200 text-primary-500">
|
||||
{daySlots.length} slot tersisa
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-2">
|
||||
<For data={daySlots}>
|
||||
{(schedule) => (
|
||||
<div
|
||||
key={`${schedule.date}-${schedule.time}`}
|
||||
className={`px-3 py-2 text-xs rounded-md font-semibold ${
|
||||
schedule.available
|
||||
? "bg-primary-300 text-primary-600"
|
||||
: "bg-neutral-200 text-neutral-400 line-through"
|
||||
}`}
|
||||
>
|
||||
{formatTime(schedule.time)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
<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" />
|
||||
|
||||
@@ -81,4 +72,4 @@ export const SenpaiScheduleSection: FC<Props> = ({ onBook, mentorId }) => {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
+6
-15
@@ -1,23 +1,14 @@
|
||||
import { StarFilled } from "@ant-design/icons";
|
||||
import { For } from "@imphnen-frontend-service/utils";
|
||||
import { useGetMentorStats } from "@imphnen-frontend-service/service";
|
||||
import { FC } from "react";
|
||||
|
||||
type Props = {
|
||||
mentorId?: string;
|
||||
};
|
||||
|
||||
const formatCount = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 100000 ? 0 : 1)}k` : `${n}`);
|
||||
|
||||
export const StatisticsSection: FC<Props> = ({ mentorId }) => {
|
||||
const { data: stats } = useGetMentorStats(mentorId ?? "")
|
||||
|
||||
const SENPAI_STATISTIC: { name: string; count: string }[] = [
|
||||
{ name: 'Total Sessions', count: formatCount(stats?.total_sessions ?? 0) },
|
||||
{ name: 'Mentee Impact', count: formatCount(stats?.unique_mentees ?? 0) },
|
||||
{ name: 'Avg Rating', count: stats && stats.avg_rating > 0 ? `${stats.avg_rating}/5` : '-' },
|
||||
]
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { FC, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { ProfileSection } from './_components/sections/profile'
|
||||
import { StatisticsSection } from './_components/sections/senpai-statistics'
|
||||
import { TopicsSection } from './_components/sections/topics'
|
||||
@@ -8,93 +7,44 @@ 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'
|
||||
import { useGetMentorDetail } from '@imphnen-frontend-service/service'
|
||||
import { Show } from '@imphnen-frontend-service/utils'
|
||||
|
||||
export const Components: FC = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { id } = useParams()
|
||||
const { data: mentor, isLoading, isError } = useGetMentorDetail(id ?? '')
|
||||
|
||||
const buildResume = (): string => {
|
||||
if (mentor?.bio) return mentor.bio
|
||||
if (!mentor) return ''
|
||||
const parts: string[] = []
|
||||
const role = mentor.current_role
|
||||
const company = mentor.current_company
|
||||
const years = mentor.years_of_experience
|
||||
const expertise = mentor.expertise
|
||||
const industries = mentor.industries
|
||||
const languages = mentor.languages
|
||||
|
||||
if (role || company) {
|
||||
parts.push(`${role || 'Mentor'}${company && role ? ' at ' : company ? ' di ' : ''}${company || ''}.`)
|
||||
}
|
||||
if (years > 0) {
|
||||
parts.push(`Memiliki pengalaman ${years} tahun di industri IT.`)
|
||||
}
|
||||
if (expertise.length > 0) {
|
||||
parts.push(`Keahlian utama: ${expertise.join(', ')}.`)
|
||||
}
|
||||
if (industries.length > 0) {
|
||||
parts.push(`Bidang yang digeluti: ${industries.join(', ')}.`)
|
||||
}
|
||||
if (languages.length > 0) {
|
||||
parts.push(`Bahasa yang dikuasai: ${languages.join(', ')}.`)
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return 'Profil senpai ini sedang dilengkapi. Segera cek kembali!'
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const resume = buildResume()
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Show
|
||||
condition={!isLoading && !isError}
|
||||
fallback={
|
||||
<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 text-center text-neutral-500 py-10">
|
||||
{isError ? 'Mentor tidak ditemukan' : 'Memuat profil mentor...'}
|
||||
<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>
|
||||
</section>
|
||||
}
|
||||
>
|
||||
<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)} mentor={mentor} />
|
||||
|
||||
<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 mentorId={mentor?.id} />
|
||||
<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]">
|
||||
{resume || 'Profil senpai ini sedang dilengkapi. Segera cek kembali!'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ExperienceSection />
|
||||
<EducationSection />
|
||||
</div>
|
||||
|
||||
<div className="hidden xl:block xl:w-[400px]">
|
||||
<SenpaiScheduleSection onBook={() => setOpen(true)} mentorId={mentor?.id} />
|
||||
</div>
|
||||
<div className="hidden xl:block xl:w-[400px]">
|
||||
<SenpaiScheduleSection onBook={() => setOpen(true)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AppointmentModal open={open} setOpen={setOpen} mentorId={mentor?.id} mentor={mentor} />
|
||||
</Show>
|
||||
<AppointmentModal open={open} setOpen={setOpen} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { cn, SessionToken } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
CompassOutlined,
|
||||
BookOutlined,
|
||||
TeamOutlined,
|
||||
SettingOutlined,
|
||||
LogoutOutlined,
|
||||
ReadOutlined,
|
||||
ProfileOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
type NavItem = {
|
||||
label: string;
|
||||
to: string;
|
||||
icon: ReactElement;
|
||||
end?: boolean;
|
||||
};
|
||||
|
||||
type DashboardSidebarProps = {
|
||||
role: 'mentee' | 'mentor';
|
||||
};
|
||||
|
||||
const MENTEE_NAV: NavItem[] = [
|
||||
{ label: 'Dashboard', to: '/mentoring/dashboard', icon: <DashboardOutlined />, end: true },
|
||||
{ label: 'Roadmap Discovery', to: '/mentoring/ai-agent', icon: <CompassOutlined /> },
|
||||
{ label: 'Learning Path', to: '/mentoring/materi', icon: <BookOutlined /> },
|
||||
{ label: 'Mentoring', to: '/mentoring/my-sessions', icon: <TeamOutlined /> },
|
||||
];
|
||||
|
||||
const MENTOR_NAV: NavItem[] = [
|
||||
{ label: 'Dashboard', to: '/mentoring/mentor-dashboard', icon: <DashboardOutlined />, end: true },
|
||||
{ label: 'Mentoring Setup', to: '/mentoring/mentor-dashboard?tab=setup', icon: <SettingOutlined /> },
|
||||
{ label: 'List Mentee', to: '/mentoring/mentor-dashboard?tab=mentee', icon: <TeamOutlined /> },
|
||||
{ label: 'Feedback', to: '/mentoring/mentor-dashboard?tab=feedback', icon: <ProfileOutlined /> },
|
||||
];
|
||||
|
||||
// decode JWT payload (bukan verifikasi — cuma untuk tampilkan inisial/email)
|
||||
const jwtPayload = (): { email?: string; name?: string } | null => {
|
||||
const raw = SessionToken.get()?.token?.access_token;
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const part = raw.split('.')[1];
|
||||
if (!part) return null;
|
||||
const json = atob(part.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
const pad = json.length % 4 === 0 ? json : json + '='.repeat(4 - (json.length % 4));
|
||||
return JSON.parse(pad);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const DashboardSidebar: FC<DashboardSidebarProps> = ({ role }): ReactElement => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const nav = role === 'mentee' ? MENTEE_NAV : MENTOR_NAV;
|
||||
|
||||
const isActive = (item: NavItem) => {
|
||||
if (item.end) return location.pathname === item.to;
|
||||
if (item.to.includes('?')) {
|
||||
const [base, q] = item.to.split('?');
|
||||
const [key, val] = q.split('=');
|
||||
return location.pathname === base && new URLSearchParams(location.search).get(key) === val;
|
||||
}
|
||||
return location.pathname.startsWith(item.to);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
SessionToken.remove();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:flex w-[230px] shrink-0 flex-col bg-white border-r border-neutral-100 sticky top-0 h-screen">
|
||||
<div className="px-6 py-6">
|
||||
<p className="text-[19px] font-bold">
|
||||
<span className="text-primary-500">Dimentorin</span>.dev
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-3 space-y-1">
|
||||
{nav.map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
to={item.to}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
|
||||
isActive(item)
|
||||
? 'bg-primary-100/70 text-primary-600'
|
||||
: 'text-neutral-500 hover:bg-neutral-50 hover:text-neutral-700'
|
||||
)}
|
||||
>
|
||||
<span className="text-base">{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-3 border-t border-neutral-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-4 py-2.5 text-sm font-medium text-neutral-500 hover:bg-danger-50 hover:text-danger-500 transition-colors"
|
||||
>
|
||||
<LogoutOutlined /> Log Out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export const DashboardHeader: FC<{ title?: string; role?: 'mentee' | 'mentor' }> = ({
|
||||
title,
|
||||
role,
|
||||
}): ReactElement => {
|
||||
const payload = jwtPayload();
|
||||
const name = payload?.name || payload?.email?.split('@')[0] || 'Pengguna';
|
||||
const initial = name[0]?.toUpperCase() ?? 'U';
|
||||
const roleLabel = role === 'mentor' ? 'Mentor' : 'Mentee';
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between gap-4 px-6 py-4 border-b border-neutral-100 bg-white/70 backdrop-blur sticky top-0 z-10">
|
||||
<div>
|
||||
{title && <h1 className="text-[19px] font-bold text-neutral-800">{title}</h1>}
|
||||
<p className="text-xs text-neutral-400 hidden sm:block">Dimentorin.dev</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" aria-label="Notifications" className="size-9 rounded-full bg-neutral-50 hover:bg-neutral-100 flex items-center justify-center text-neutral-500">
|
||||
<ReadOutlined />
|
||||
</button>
|
||||
<button type="button" aria-label="Settings" className="size-9 rounded-full bg-neutral-50 hover:bg-neutral-100 flex items-center justify-center text-neutral-500">
|
||||
<SettingOutlined />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-9 rounded-full bg-primary-500 text-white flex items-center justify-center text-sm font-semibold">
|
||||
{initial}
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<p className="text-sm font-semibold text-neutral-800 leading-tight">{name}</p>
|
||||
<p className="text-xs text-neutral-400 capitalize">{roleLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export const DashboardLayout: FC<{
|
||||
role: 'mentee' | 'mentor';
|
||||
title?: string;
|
||||
children: ReactElement | ReactElement[];
|
||||
}> = ({ role, title, children }): ReactElement => (
|
||||
<div className="flex min-h-screen bg-primary-50/40">
|
||||
<DashboardSidebar role={role} />
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<DashboardHeader title={title} role={role} />
|
||||
<main className="flex-1 px-6 py-6 lg:px-8">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,256 +0,0 @@
|
||||
import { FC, ReactElement, useEffect, useState } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Modal } from '@imphnen-frontend-service/ui/molecules';
|
||||
import { cn, Show } from '@imphnen-frontend-service/utils';
|
||||
import { usePostSessionFeedback } from '@imphnen-frontend-service/service';
|
||||
import { StarFilled, StarOutlined } from '@ant-design/icons';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const RATING_LABELS: Record<number, string> = {
|
||||
1: 'Sangat Tidak Puas',
|
||||
2: 'Tidak Puas',
|
||||
3: 'Cukup Puas',
|
||||
4: 'Puas',
|
||||
5: 'Sangat Puas',
|
||||
};
|
||||
|
||||
type FeedbackModalProps = {
|
||||
open: boolean;
|
||||
sessionId: string;
|
||||
mentorName?: string | null;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
const RATING_QUESTIONS: { question: string; options: string[] }[] = [
|
||||
{
|
||||
question: 'Seberapa membantu jawaban mentor untuk kebutuhanmu?',
|
||||
options: [
|
||||
'Tidak Membantu',
|
||||
'Kurang Membantu',
|
||||
'Cukup Membantu',
|
||||
'Membantu',
|
||||
'Sangat Membantu',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Apakah kamu akan merekomendasikan platform ini ke rekanmu?',
|
||||
options: ['Ya', 'Tidak', 'Mungkin'],
|
||||
},
|
||||
];
|
||||
|
||||
export const FeedbackModal: FC<FeedbackModalProps> = ({
|
||||
open,
|
||||
sessionId,
|
||||
mentorName,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}): ReactElement => {
|
||||
const [step, setStep] = useState<'rating' | 'detail'>('rating');
|
||||
const [rating, setRating] = useState(0);
|
||||
const [hoverRating, setHoverRating] = useState(0);
|
||||
const [helpfulness, setHelpfulness] = useState('');
|
||||
const [recommend, setRecommend] = useState('');
|
||||
const [liked, setLiked] = useState('');
|
||||
const [improve, setImprove] = useState('');
|
||||
const [testimonial, setTestimonial] = useState('');
|
||||
|
||||
const mutation = usePostSessionFeedback(sessionId);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep('rating');
|
||||
setRating(0);
|
||||
setHelpfulness('');
|
||||
setRecommend('');
|
||||
setLiked('');
|
||||
setImprove('');
|
||||
setTestimonial('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const parts: string[] = [];
|
||||
if (liked.trim()) parts.push(`Suka: ${liked.trim()}`);
|
||||
if (improve.trim()) parts.push(`Tingkatkan: ${improve.trim()}`);
|
||||
if (testimonial.trim()) parts.push(`Testimoni: ${testimonial.trim()}`);
|
||||
if (helpfulness) parts.push(`Kebermanfaatan: ${helpfulness}`);
|
||||
if (recommend) parts.push(`Rekomendasi: ${recommend}`);
|
||||
const feedbackText = parts.join('. ') || 'Tidak ada komentar tambahan.';
|
||||
|
||||
try {
|
||||
await mutation.mutateAsync({ rating, feedback: feedbackText });
|
||||
toast.success('Feedback berhasil dikirim ✅ Terima kasih atas waktumu!');
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error('Gagal mengirim feedback. Coba lagi ya, Senpai!');
|
||||
}
|
||||
};
|
||||
|
||||
const canNext =
|
||||
rating > 0 && helpfulness !== '' && recommend !== '';
|
||||
const canSubmit =
|
||||
canNext || liked.trim() !== '' || improve.trim() !== '' || testimonial.trim() !== '';
|
||||
|
||||
return (
|
||||
<Modal isOpen={open} onClose={onClose}>
|
||||
<div className="p-6 w-full max-w-lg">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h2 className="text-[19px] font-bold text-neutral-800">
|
||||
Feedback Mentor
|
||||
</h2>
|
||||
<span className="text-xs font-medium text-neutral-400">
|
||||
{step === 'rating' ? '1 dari 2' : '2 dari 2'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 mb-4">
|
||||
Sesi dengan {mentorName ?? 'mentormu'}
|
||||
</p>
|
||||
|
||||
<Show
|
||||
condition={step === 'rating'}
|
||||
fallback={
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Apa yang kamu suka dari sesi ini?
|
||||
</label>
|
||||
<textarea
|
||||
value={liked}
|
||||
onChange={(e) => setLiked(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-neutral-200 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Ceritakan hal yang paling berkesan dari sesi ini..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Apa yang bisa ditingkatkan oleh mentor?
|
||||
</label>
|
||||
<textarea
|
||||
value={improve}
|
||||
onChange={(e) => setImprove(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-neutral-200 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Saran membangun untuk mentor..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
Testimoni atau ucapan terima kasih untuk mentor
|
||||
</label>
|
||||
<textarea
|
||||
value={testimonial}
|
||||
onChange={(e) => setTestimonial(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-neutral-200 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Tulis testimoni singkat..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-neutral-700 mb-3">
|
||||
Seberapa puas kamu dengan sesi mentoring ini?
|
||||
</p>
|
||||
<div className="flex justify-center gap-1.5">
|
||||
{[1, 2, 3, 4, 5].map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-label={`Rating ${value}`}
|
||||
onMouseEnter={() => setHoverRating(value)}
|
||||
onMouseLeave={() => setHoverRating(0)}
|
||||
onClick={() => setRating(value)}
|
||||
className={cn(
|
||||
'text-2xl transition-colors',
|
||||
(hoverRating || rating) >= value
|
||||
? 'text-warning-400'
|
||||
: 'text-neutral-200'
|
||||
)}
|
||||
>
|
||||
{(hoverRating || rating) >= value ? <StarFilled /> : <StarOutlined />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-neutral-500">
|
||||
{rating > 0 ? RATING_LABELS[rating] : '1 = Sangat Tidak Puas, 5 = Sangat Puas'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{RATING_QUESTIONS.map((q) => (
|
||||
<div key={q.question}>
|
||||
<p className="text-sm font-medium text-neutral-700 mb-2">{q.question}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{q.options.map((opt) => {
|
||||
const isSelected =
|
||||
(q.question === RATING_QUESTIONS[0].question
|
||||
? helpfulness
|
||||
: recommend) === opt;
|
||||
const setter =
|
||||
q.question === RATING_QUESTIONS[0].question
|
||||
? setHelpfulness
|
||||
: setRecommend;
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => setter(opt)}
|
||||
className={cn(
|
||||
'rounded-full border px-4 py-1.5 text-xs font-medium transition-colors',
|
||||
isSelected
|
||||
? 'border-primary-500 bg-primary-100/60 text-primary-600'
|
||||
: 'border-neutral-200 text-neutral-500 hover:border-primary-300'
|
||||
)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div className="mt-6 flex justify-between gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
onClick={step === 'rating' ? onClose : () => setStep('rating')}
|
||||
>
|
||||
{step === 'rating' ? 'Batal' : 'Kembali'}
|
||||
</Button>
|
||||
<Show
|
||||
condition={step === 'rating'}
|
||||
fallback={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={mutation.isPending || !canSubmit}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{mutation.isPending ? 'Mengirim...' : 'Kirim'}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!canNext}
|
||||
onClick={() => setStep('detail')}
|
||||
>
|
||||
Lanjut
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedbackModal;
|
||||
@@ -1,69 +0,0 @@
|
||||
import { FC } from 'react';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
type TMentorAvatarProps = {
|
||||
name: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
};
|
||||
|
||||
// Warna gradient unik per nama — hash nama → pasangan warna dari palet brand
|
||||
const GRADIENTS: [string, string][] = [
|
||||
['#23a1eb', '#0b5b8c'],
|
||||
['#5b8def', '#3a3f8f'],
|
||||
['#14b8a6', '#0f766e'],
|
||||
['#8b5cf6', '#5b21b6'],
|
||||
['#f59e0b', '#b45309'],
|
||||
['#ef4444', '#991b1b'],
|
||||
['#ec4899', '#9d174d'],
|
||||
['#06b6d4', '#0e7490'],
|
||||
];
|
||||
|
||||
const SIZES: Record<'sm' | 'md' | 'lg', string> = {
|
||||
sm: 'size-[60px] text-lg md:size-10 lg:size-12',
|
||||
md: 'size-16 text-xl md:size-20',
|
||||
lg: 'size-24 text-3xl md:size-32 lg:size-40 md:text-4xl lg:text-5xl',
|
||||
};
|
||||
|
||||
const getInitials = (name: string): string => {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return '?';
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
};
|
||||
|
||||
const getGradient = (name: string): string => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
const [from, to] = GRADIENTS[hash % GRADIENTS.length];
|
||||
return `linear-gradient(135deg, ${from} 0%, ${to} 100%)`;
|
||||
};
|
||||
|
||||
export const MentorAvatar: FC<TMentorAvatarProps> = ({
|
||||
name,
|
||||
size = 'md',
|
||||
className,
|
||||
textClassName,
|
||||
}) => {
|
||||
const displayName = name || 'Mentor';
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-md overflow-hidden shrink-0 select-none',
|
||||
SIZES[size],
|
||||
className
|
||||
)}
|
||||
style={{ background: getGradient(displayName) }}
|
||||
aria-label={`Avatar ${displayName}`}
|
||||
>
|
||||
<span
|
||||
className={cn('font-semibold text-white drop-shadow-sm', textClassName)}
|
||||
>
|
||||
{getInitials(displayName)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,73 +1,40 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { For } from '@imphnen-frontend-service/utils';
|
||||
import { TMentorDetail } from '@imphnen-frontend-service/service';
|
||||
import { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MentorAvatar } from './mentor-avatar';
|
||||
|
||||
type TMentorCardProps = {
|
||||
mentor: TMentorDetail;
|
||||
};
|
||||
|
||||
export const MentorCard: FC<TMentorCardProps> = ({ mentor }) => {
|
||||
const experience =
|
||||
mentor.years_of_experience > 0
|
||||
? `${mentor.years_of_experience} Years Experience`
|
||||
: 'New Mentor';
|
||||
const role = mentor.current_role || 'Mentor';
|
||||
const company = mentor.current_company || 'IMPHNEN';
|
||||
const softSkills = mentor.expertise || [];
|
||||
const shownSkills = softSkills.slice(0, 2);
|
||||
const extraCount = Math.max(0, softSkills.length - shownSkills.length);
|
||||
|
||||
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">
|
||||
<MentorAvatar
|
||||
name={mentor.fullname ?? mentor.legal_name ?? 'Mentor'}
|
||||
size="sm"
|
||||
className="md:w-full md:h-auto md:aspect-square"
|
||||
/>
|
||||
<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">
|
||||
{experience}
|
||||
3-5 Years Experience
|
||||
</Button>
|
||||
<h2 className="mb-1">
|
||||
<Link to={`/mentoring/${mentor.id}`} className="text-xs font-semibold text-primary-500 md:text-[15px] md:font-semibold lg:text-[19px]">
|
||||
{mentor.fullname || mentor.legal_name || 'Mentor'}
|
||||
<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">
|
||||
{role} at {company}
|
||||
Full Stack Enjoyer at name company
|
||||
</p>
|
||||
|
||||
{softSkills.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[8px] text-neutral-500 mb-1 md:mb-2 lg:text-[10px]">Soft Skills :</p>
|
||||
<div className="space-x-2">
|
||||
<For data={shownSkills}>
|
||||
{(skill) => (
|
||||
<Button
|
||||
key={skill}
|
||||
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]"
|
||||
>
|
||||
{skill}
|
||||
</Button>
|
||||
)}
|
||||
</For>
|
||||
{extraCount > 0 && (
|
||||
<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]"
|
||||
>
|
||||
+{extraCount}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<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,154 +0,0 @@
|
||||
import { FC, ReactElement, useRef, useState } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { SendOutlined } from '@ant-design/icons';
|
||||
import { usePostChat } from '@imphnen-frontend-service/service';
|
||||
import type { TChatResponse } from '@imphnen-frontend-service/service';
|
||||
|
||||
type Message = {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
sources?: TChatResponse['sources'];
|
||||
};
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const chatMutation = usePostChat();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSend = async () => {
|
||||
const question = input.trim();
|
||||
if (!question || loading) return;
|
||||
setInput('');
|
||||
setMessages((m) => [...m, { role: 'user', content: question }]);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await chatMutation.mutateAsync({ question });
|
||||
setMessages((m) => [
|
||||
...m,
|
||||
{ role: 'assistant', content: res.answer, sources: res.sources },
|
||||
]);
|
||||
} catch {
|
||||
setMessages((m) => [
|
||||
...m,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Maaf, terjadi kesalahan saat menjawab. Coba lagi ya.',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="px-4 md:px-20 py-10">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-semibold text-primary-500 mb-2">
|
||||
AI Agent Mentoring
|
||||
</h1>
|
||||
<p className="text-neutral-500">
|
||||
Tanya apa saja tentang materi mentoring — jawaban diambil langsung
|
||||
dari konten materi yang tersedia (RAG).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-neutral-100 shadow-sm max-w-3xl mx-auto">
|
||||
{/* Chat body */}
|
||||
<div className="h-[420px] overflow-y-auto p-5 space-y-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center">
|
||||
<div className="size-14 rounded-full bg-primary-50 flex items-center justify-center mb-3">
|
||||
<SendOutlined className="text-primary-500 text-xl" />
|
||||
</div>
|
||||
<p className="text-sm text-neutral-400 max-w-sm">
|
||||
Coba tanya: <i>“Apa itu ownership di Rust?”</i>{' '}
|
||||
atau <i>“Gimana cara buat route di Axum?”</i>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={
|
||||
msg.role === 'user'
|
||||
? 'flex justify-end'
|
||||
: 'flex justify-start'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
msg.role === 'user'
|
||||
? 'bg-primary-500 text-white rounded-2xl rounded-br-sm px-4 py-2.5 max-w-[80%] text-sm whitespace-pre-wrap'
|
||||
: 'bg-neutral-50 border border-neutral-100 rounded-2xl rounded-bl-sm px-4 py-2.5 max-w-[85%] text-sm text-neutral-700 whitespace-pre-wrap'
|
||||
}
|
||||
>
|
||||
{msg.content}
|
||||
{msg.sources && msg.sources.length > 0 && (
|
||||
<div className="mt-3 pt-2 border-t border-neutral-200">
|
||||
<p className="text-[10px] uppercase tracking-wide text-neutral-400 mb-1">
|
||||
Sumber materi
|
||||
</p>
|
||||
{msg.sources.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between gap-2 mb-1"
|
||||
>
|
||||
<span className="text-[12px] text-neutral-600 truncate">
|
||||
📄 {s.title}
|
||||
</span>
|
||||
<span className="text-[10px] text-neutral-400 shrink-0">
|
||||
{Math.round(s.score * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{loading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-neutral-50 border border-neutral-100 rounded-2xl rounded-bl-sm px-4 py-2.5 text-sm text-neutral-400">
|
||||
<span className="inline-flex gap-1">
|
||||
<span className="animate-bounce">●</span>
|
||||
<span className="animate-bounce [animation-delay:0.1s]">
|
||||
●
|
||||
</span>
|
||||
<span className="animate-bounce [animation-delay:0.2s]">
|
||||
●
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-neutral-100 p-4 flex gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||
placeholder="Tanya tentang materi mentoring..."
|
||||
className="flex-1 px-4 py-2.5 rounded-xl border border-neutral-200 focus:border-primary-400 focus:outline-none text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="shrink-0"
|
||||
onClick={handleSend}
|
||||
disabled={loading || !input.trim()}
|
||||
>
|
||||
<SendOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default Components;
|
||||
@@ -1,128 +0,0 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Show, cn, SessionToken } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
useGetMySessions,
|
||||
useGetMaterialsList,
|
||||
useGetArticles,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { DashboardLayout } from '../_components/dashboard-layout';
|
||||
import {
|
||||
VideoCameraOutlined,
|
||||
FileDoneOutlined,
|
||||
CheckCircleOutlined,
|
||||
ArrowRightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const StatCard: FC<{ icon: ReactElement; label: string; value: number | string }> = ({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}): ReactElement => (
|
||||
<div className="bg-white rounded-xl p-5 border border-neutral-100 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-lg bg-primary-100 text-primary-500 flex items-center justify-center text-lg">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[23px] font-bold text-neutral-800 leading-none">{value}</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DashboardPage: FC = (): ReactElement => {
|
||||
const { data: sessions } = useGetMySessions();
|
||||
const { data: materials } = useGetMaterialsList();
|
||||
const { data: articles } = useGetArticles({});
|
||||
|
||||
const sessionCount = sessions?.sessions?.length ?? 0;
|
||||
const completed = sessions?.sessions?.filter((s) => s.status === 'completed').length ?? 0;
|
||||
const mats = materials?.data ?? [];
|
||||
const totalSteps = mats.length || 1;
|
||||
const doneSteps = Math.ceil(totalSteps * 0.35);
|
||||
const progress = Math.round((doneSteps / totalSteps) * 100);
|
||||
|
||||
return (
|
||||
<DashboardLayout role="mentee">
|
||||
{/* Welcome banner */}
|
||||
<div className="relative overflow-hidden rounded-2xl bg-primary-500 text-white px-8 py-8 md:px-10">
|
||||
<div className="relative z-10 max-w-lg">
|
||||
<h2 className="text-[23px] font-bold md:text-[29px] leading-tight">
|
||||
Selamat Datang di Dimentorin.dev
|
||||
</h2>
|
||||
<p className="mt-3 text-sm text-primary-100 leading-relaxed">
|
||||
Temukan roadmap belajar impianmu lewat Skill Discovery, lalu biarkan
|
||||
AI dan mentor profesional membimbingmu step by step sampai jadi
|
||||
Full Stack Developer 🚀
|
||||
</p>
|
||||
<Link to="/mentoring/ai-agent" className="mt-6 inline-block">
|
||||
<Button variant="secondary" size="lg" className="gap-2">
|
||||
Temukan Roadmapnya <ArrowRightOutlined />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="absolute -right-8 -bottom-8 size-48 rounded-full bg-primary-400/40 blur-2xl" />
|
||||
<div className="absolute -right-4 -top-10 size-32 rounded-full bg-white/10 blur-xl" />
|
||||
<div className="absolute right-16 bottom-4 hidden md:flex size-20 items-center justify-center rounded-2xl bg-white/15 border border-white/20 text-3xl">
|
||||
🎓
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview */}
|
||||
<section className="mt-6">
|
||||
<h3 className="text-[19px] font-semibold text-neutral-800 mb-4">Overview</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<StatCard icon={<VideoCameraOutlined />} label="Mentoring Session" value={sessionCount} />
|
||||
<StatCard icon={<FileDoneOutlined />} label="Article Submitted" value={articles?.length ?? 0} />
|
||||
<StatCard icon={<CheckCircleOutlined />} label="Article Published" value={completed} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Roadmaps / Learning Path progress */}
|
||||
<section className="mt-8">
|
||||
<h3 className="text-[19px] font-semibold text-neutral-800 mb-4">Roadmaps</h3>
|
||||
<Show
|
||||
condition={mats.length > 0}
|
||||
fallback={
|
||||
<div className="bg-white rounded-xl border border-neutral-100 shadow-sm p-8 text-center">
|
||||
<p className="text-neutral-500 mb-4">Belum ada materi roadmap.</p>
|
||||
<Link to="/mentoring/ai-agent">
|
||||
<Button variant="primary" className="gap-2">
|
||||
Mulai Skill Discovery <ArrowRightOutlined />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="bg-white rounded-xl p-6 border border-neutral-100 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<p className="font-semibold text-neutral-800">Front End Basic</p>
|
||||
<p className="text-sm text-neutral-500 mt-0.5">
|
||||
{doneSteps} of {totalSteps} steps milestone completed
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/mentoring/materi">
|
||||
<Button size="sm" variant="bordered" className="gap-1">
|
||||
Lanjut Belajar <ArrowRightOutlined />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="h-2.5 rounded-full bg-neutral-100 overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full bg-primary-500 transition-all')}
|
||||
style={{ width: `${Math.min(progress, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-right text-xs text-neutral-400">{progress}%</div>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -1,53 +0,0 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { useGetMaterialBySlug } from '@imphnen-frontend-service/service';
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { data: material, isLoading } = useGetMaterialBySlug(slug ?? '');
|
||||
|
||||
return (
|
||||
<main className="px-4 md:px-20 py-10">
|
||||
<Link to="/mentoring/materi" className="inline-block mb-6">
|
||||
<Button size="sm" variant="text">
|
||||
<ArrowLeftOutlined /> Kembali ke Materi
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{isLoading || !material ? (
|
||||
<div className="animate-pulse bg-neutral-100 h-72 rounded-2xl" />
|
||||
) : (
|
||||
<article className="max-w-3xl">
|
||||
<span className="inline-block px-2 py-0.5 rounded-full text-[12px] font-medium mb-3 bg-neutral-100 text-neutral-600">
|
||||
{material.category}
|
||||
</span>
|
||||
<h1 className="text-3xl font-semibold text-neutral-800 mb-3">
|
||||
{material.title}
|
||||
</h1>
|
||||
<p className="text-neutral-500 mb-6">{material.description}</p>
|
||||
<div className="bg-white rounded-2xl border border-neutral-100 p-8 shadow-sm">
|
||||
{/* SAFE: all '<' escaped to < below, no raw HTML passes through */}
|
||||
<div
|
||||
className="prose prose-sm max-w-none prose-neutral"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: material.content
|
||||
.split('\n')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
.map(
|
||||
(p) =>
|
||||
`<p class="mb-3 text-neutral-700 leading-relaxed">${p.replace(/</g, '<')}</p>`
|
||||
)
|
||||
.join(''),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default Components;
|
||||
@@ -1,92 +0,0 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Show } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
useGetMaterialCategories,
|
||||
useGetMaterialsList,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
Backend: 'bg-primary-100 text-primary-700',
|
||||
Frontend: 'bg-info-100 text-info-800',
|
||||
Data: 'bg-success-100 text-success-700',
|
||||
Mobile: 'bg-warning-100 text-warning-800',
|
||||
Cybersecurity: 'bg-danger-100 text-danger-700',
|
||||
Default: 'bg-neutral-100 text-neutral-600',
|
||||
};
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
const { data: materials, isLoading } = useGetMaterialsList();
|
||||
const { data: categories } = useGetMaterialCategories();
|
||||
|
||||
return (
|
||||
<main className="px-4 md:px-20 py-10">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-semibold text-primary-500 mb-2">
|
||||
Materi Mentoring
|
||||
</h1>
|
||||
<p className="text-neutral-500">
|
||||
Kumpulan materi dari para mentor IMPHNEN untuk memperdalam
|
||||
keterampilan kamu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Category filter chips */}
|
||||
<Show condition={!!categories?.length}>
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{categories?.map((cat) => (
|
||||
<span
|
||||
key={cat}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
CATEGORY_COLORS[cat] ?? CATEGORY_COLORS.Default
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="animate-pulse bg-neutral-100 h-44 rounded-2xl"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : !materials?.data?.length ? (
|
||||
<p className="text-neutral-400">Belum ada materi tersedia.</p>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{materials.data.map((m) => {
|
||||
const color =
|
||||
CATEGORY_COLORS[m.category] ?? CATEGORY_COLORS.Default;
|
||||
return (
|
||||
<Link
|
||||
key={m.id}
|
||||
to={`/mentoring/materi/${m.slug}`}
|
||||
className="group bg-white rounded-2xl border border-neutral-100 p-6 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-[12px] font-medium mb-3 ${color}`}
|
||||
>
|
||||
{m.category}
|
||||
</span>
|
||||
<h3 className="font-semibold text-neutral-800 group-hover:text-primary-500 transition-colors leading-snug mb-2">
|
||||
{m.title}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-500 leading-relaxed line-clamp-2">
|
||||
{m.description}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default Components;
|
||||
@@ -1,435 +0,0 @@
|
||||
import { FC, ReactElement, useMemo } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { For, Show, cn, SessionToken } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
useGetMentorMe,
|
||||
useGetMentorStats,
|
||||
useGetMentorSessions,
|
||||
useGetSessionPayments,
|
||||
usePostConfirmPayment,
|
||||
usePostRefreshPayment,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import {
|
||||
ReloadOutlined,
|
||||
CheckOutlined,
|
||||
StarOutlined,
|
||||
CheckCircleOutlined,
|
||||
TeamOutlined,
|
||||
MessageOutlined,
|
||||
CalendarOutlined,
|
||||
ClockCircleOutlined,
|
||||
DollarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { toast } from 'sonner';
|
||||
import { DashboardLayout } from '../_components/dashboard-layout';
|
||||
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
pending: 'bg-warning-100 text-warning-800',
|
||||
confirmed: 'bg-success-100 text-success-700',
|
||||
completed: 'bg-primary-100 text-primary-700',
|
||||
cancelled: 'bg-danger-100 text-danger-600',
|
||||
};
|
||||
|
||||
const PAYMENT_STYLE: Record<string, string> = {
|
||||
pending: 'bg-warning-100 text-warning-800',
|
||||
paid: 'bg-success-100 text-success-700',
|
||||
expired: 'bg-neutral-200 text-neutral-600',
|
||||
};
|
||||
|
||||
const formatDate = (iso: string) => {
|
||||
try {
|
||||
return new Date(iso).toLocaleString('id-ID', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
};
|
||||
|
||||
const formatRupiah = (n: number) =>
|
||||
new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(n);
|
||||
|
||||
const StatCard: FC<{ icon: ReactElement; label: string; value: number | string }> = ({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}): ReactElement => (
|
||||
<div className="bg-white rounded-xl p-5 border border-neutral-100 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-lg bg-primary-100 text-primary-500 flex items-center justify-center text-lg">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[23px] font-bold text-neutral-800 leading-none">{value}</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const TABS = [
|
||||
{ key: 'sessions', label: 'Sesi Masuk' },
|
||||
{ key: 'setup', label: 'Mentoring Setup' },
|
||||
{ key: 'mentee', label: 'List Mentee' },
|
||||
{ key: 'feedback', label: 'Feedback' },
|
||||
];
|
||||
|
||||
const SessionCard: FC<{ sessionId: string }> = ({ sessionId }) => {
|
||||
const { data: payments } = useGetSessionPayments(sessionId);
|
||||
const refreshPayment = usePostRefreshPayment();
|
||||
const confirmPayment = usePostConfirmPayment();
|
||||
|
||||
const payment = (payments ?? [])[0];
|
||||
|
||||
return (
|
||||
<Show condition={!!payment && payment.status === 'pending'}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-3 py-2 rounded-md text-xs font-medium bg-neutral-50 border border-neutral-100">
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-0.5 rounded-full capitalize',
|
||||
PAYMENT_STYLE[payment?.status ?? ''] ?? ''
|
||||
)}
|
||||
>
|
||||
Pembayaran: {payment?.status}
|
||||
</span>
|
||||
<span>{payment ? formatRupiah(payment.total) : ''}</span>
|
||||
<div className="flex gap-2">
|
||||
<Show condition={!!payment && payment.provider === 'midtrans'}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="gap-2"
|
||||
disabled={refreshPayment.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
if (payment) await refreshPayment.mutateAsync(payment.id);
|
||||
toast.success('Status pembayaran diperbarui');
|
||||
} catch {
|
||||
toast.error('Gagal memperbarui status');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ReloadOutlined /> Refresh
|
||||
</Button>
|
||||
</Show>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="success"
|
||||
className="gap-2"
|
||||
disabled={confirmPayment.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
if (payment) await confirmPayment.mutateAsync(payment.id);
|
||||
toast.success('Pembayaran dikonfirmasi ✅');
|
||||
} catch {
|
||||
toast.error('Gagal mengonfirmasi pembayaran');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CheckOutlined /> Konfirmasi
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
const { data: mentor, isLoading: loadingMentor } = useGetMentorMe();
|
||||
const mentorId = mentor?.id ?? '';
|
||||
const { data: stats } = useGetMentorStats(mentorId);
|
||||
const { data: sessions, isLoading } = useGetMentorSessions(mentorId);
|
||||
const [params, setParams] = useSearchParams();
|
||||
const tab = params.get('tab') ?? 'sessions';
|
||||
|
||||
const all = sessions?.sessions ?? [];
|
||||
const sessionCount = all.length;
|
||||
const completedCount = all.filter((s) => s.status === 'completed').length;
|
||||
const confirmedCount = all.filter((s) => s.status === 'confirmed').length;
|
||||
const pendingCount = all.filter((s) => s.status === 'pending').length;
|
||||
const menteeCount = new Set(
|
||||
all.map((s) => s.mentee_email ?? s.mentee_fullname ?? '')
|
||||
).size;
|
||||
|
||||
const isMentor = !loadingMentor && !!mentor;
|
||||
|
||||
const setTab = (key: string) => {
|
||||
if (key === 'sessions') params.delete('tab');
|
||||
else params.set('tab', key);
|
||||
setParams(params, { replace: true });
|
||||
};
|
||||
|
||||
const content = useMemo(() => {
|
||||
switch (tab) {
|
||||
case 'setup':
|
||||
return (
|
||||
<div className="bg-white rounded-xl p-8 border border-neutral-100 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="size-11 rounded-xl bg-primary-100 text-primary-500 flex items-center justify-center text-xl">
|
||||
<CalendarOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-neutral-800">Mentoring Setup</h3>
|
||||
<p className="text-sm text-neutral-500">
|
||||
Atur jadwal sesi & topik mentoring kamu.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div className="rounded-lg border border-neutral-100 p-5">
|
||||
<p className="text-sm font-medium text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<ClockCircleOutlined className="text-primary-500" /> Waktu Sesi
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Sesi mentoring kamu berjalan {confirmedCount > 0 ? `${confirmedCount} terkunci` : 'belum ada yang terkunci'}. Jadwal sesi tampil otomatis setelah mentee booking & pembayaran lunas.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-neutral-100 p-5">
|
||||
<p className="text-sm font-medium text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<DollarOutlined className="text-primary-500" /> Monetization
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{pendingCount > 0
|
||||
? `${pendingCount} sesi menunggu konfirmasi pembayaran.`
|
||||
: 'Tidak ada sesi menunggu pembayaran saat ini.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'mentee':
|
||||
return (
|
||||
<div className="bg-white rounded-xl p-8 border border-neutral-100 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="size-11 rounded-xl bg-primary-100 text-primary-500 flex items-center justify-center text-xl">
|
||||
<TeamOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-neutral-800">List Mentee</h3>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{menteeCount} mentee unik pernah mengikuti sesimu.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<For data={all.slice(0, 8)}>
|
||||
{(session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-neutral-100 px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="size-9 rounded-full bg-primary-100 text-primary-600 flex items-center justify-center text-sm font-semibold shrink-0">
|
||||
{(session.mentee_fullname ?? session.mentee_email ?? 'M')
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-neutral-800 truncate">
|
||||
{session.mentee_fullname ?? session.mentee_email ?? 'Mentee'}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-400 truncate">{session.topic}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 px-2.5 py-1 rounded-full text-xs capitalize font-medium',
|
||||
STATUS_STYLE[session.status] ?? 'bg-neutral-100 text-neutral-600'
|
||||
)}
|
||||
>
|
||||
{session.status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
{all.length === 0 && (
|
||||
<p className="text-sm text-neutral-500 text-center py-6">
|
||||
Belum ada mentee yang mengikuti sesimu.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'feedback':
|
||||
return (
|
||||
<div className="bg-white rounded-xl p-8 border border-neutral-100 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="size-11 rounded-xl bg-primary-100 text-primary-500 flex items-center justify-center text-xl">
|
||||
<MessageOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-neutral-800">Feedback</h3>
|
||||
<p className="text-sm text-neutral-500">
|
||||
Rating & feedback dari mentee setelah sesi selesai.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-neutral-100 p-6 text-center">
|
||||
<p className="text-3xl mb-2">⭐</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
Feedback mentee akan muncul di sini setelah sesi selesai
|
||||
({completedCount} sesi selesai).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<For data={all}>
|
||||
{(session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="bg-white rounded-lg p-5 shadow-sm border border-neutral-100"
|
||||
>
|
||||
<div className="flex justify-between items-start gap-3 mb-3">
|
||||
<h3 className="font-semibold text-neutral-800 line-clamp-2">
|
||||
{session.topic}
|
||||
</h3>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 px-3 py-1 rounded-full text-[12px] font-medium capitalize',
|
||||
STATUS_STYLE[session.status] ?? 'bg-neutral-100 text-neutral-600'
|
||||
)}
|
||||
>
|
||||
{session.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-neutral-500 mb-1">
|
||||
📅 {formatDate(session.scheduled_at)}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500 mb-3">
|
||||
🧑🎓{' '}
|
||||
{session.mentee_fullname ?? session.mentee_email ?? 'Mentee'}
|
||||
{' · '}
|
||||
{session.session_type === 'video_call' ? 'Video Call' : 'Chat'}
|
||||
</p>
|
||||
|
||||
<SessionCard sessionId={session.id} />
|
||||
|
||||
<div className="mt-3">
|
||||
<Link to={`/mentoring/${session.mentor_id}`}>
|
||||
<Button size="sm" variant="bordered">
|
||||
Lihat Detail
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [tab, all, confirmedCount, pendingCount, completedCount, menteeCount]);
|
||||
|
||||
return (
|
||||
<DashboardLayout role="mentor" title="Mentor Dashboard">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<Show
|
||||
condition={SessionToken.get() != null}
|
||||
fallback={
|
||||
<div className="bg-white rounded-lg p-12 text-center shadow-sm border border-neutral-100">
|
||||
<p className="text-[46px] mb-2">🔐</p>
|
||||
<h2 className="text-xl font-semibold text-neutral-800 mb-2">
|
||||
Login dulu, Senpai!
|
||||
</h2>
|
||||
<p className="text-neutral-500 mb-6 max-w-md mx-auto">
|
||||
Dashboard mentor hanya untuk yang sudah terdaftar. Masuk untuk
|
||||
melihat permintaan sesi dan konfirmasi pembayaran.
|
||||
</p>
|
||||
<Link to="/auth/login">
|
||||
<Button variant="primary" size="lg">
|
||||
Login Sekarang
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
condition={isMentor}
|
||||
fallback={
|
||||
<div className="bg-white rounded-lg p-12 text-center shadow-sm border border-neutral-100">
|
||||
<p className="text-neutral-500 mb-4">
|
||||
Kamu belum terdaftar sebagai mentor. Daftar dulu untuk menerima
|
||||
permintaan sesi.
|
||||
</p>
|
||||
<Link to="/auth/register-mentor">
|
||||
<Button variant="primary">Daftar Jadi Mentor</Button>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Overview stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<StatCard icon={<StarOutlined />} label="Your Rating" value={stats?.avg_rating?.toFixed(1) ?? '-'} />
|
||||
<StatCard icon={<CheckCircleOutlined />} label="Session Complete" value={completedCount} />
|
||||
<StatCard icon={<TeamOutlined />} label="Mentee Impacted" value={menteeCount} />
|
||||
<StatCard icon={<MessageOutlined />} label="Total Feedback" value={completedCount} />
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mt-6 flex gap-1 border-b border-neutral-200 overflow-x-auto">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={cn(
|
||||
'shrink-0 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
tab === t.key
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-neutral-500 hover:text-neutral-700'
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Show
|
||||
condition={isLoading}
|
||||
fallback={
|
||||
<Show
|
||||
condition={all.length > 0 || tab !== 'sessions'}
|
||||
fallback={
|
||||
<div className="bg-white rounded-lg p-12 text-center border border-neutral-100">
|
||||
<p className="text-neutral-500">
|
||||
Belum ada permintaan sesi mentoring.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{[0, 1].map((i) => (
|
||||
<div key={i} className="bg-white rounded-lg p-5 animate-pulse">
|
||||
<div className="h-4 bg-neutral-200 rounded w-2/3 mb-3" />
|
||||
<div className="h-3 bg-neutral-200 rounded w-1/2 mb-2" />
|
||||
<div className="h-3 bg-neutral-200 rounded w-1/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Components;
|
||||
@@ -1,247 +0,0 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { For, Show, cn, SessionToken } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
useGetMySessions,
|
||||
useGetMyPayments,
|
||||
usePostRefreshPayment,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { ReloadOutlined, StarOutlined } from '@ant-design/icons';
|
||||
import { toast } from 'sonner';
|
||||
import { DashboardLayout } from '../_components/dashboard-layout';
|
||||
import { FeedbackModal } from '../_components/feedback-modal';
|
||||
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
pending: 'bg-warning-100 text-warning-800',
|
||||
confirmed: 'bg-success-100 text-success-700',
|
||||
completed: 'bg-primary-100 text-primary-700',
|
||||
cancelled: 'bg-danger-100 text-danger-600',
|
||||
};
|
||||
|
||||
const PAYMENT_STYLE: Record<string, string> = {
|
||||
pending: 'bg-warning-100 text-warning-800',
|
||||
paid: 'bg-success-100 text-success-700',
|
||||
expired: 'bg-neutral-200 text-neutral-600',
|
||||
};
|
||||
|
||||
const formatDate = (iso: string) => {
|
||||
try {
|
||||
return new Date(iso).toLocaleString('id-ID', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
};
|
||||
|
||||
const formatRupiah = (n: number) =>
|
||||
new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(n);
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: sessions, isLoading } = useGetMySessions();
|
||||
const { data: payments } = useGetMyPayments();
|
||||
const refreshPayment = usePostRefreshPayment();
|
||||
const [feedbackTarget, setFeedbackTarget] = useState<{
|
||||
sessionId: string;
|
||||
mentorName: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const paymentBySession = new Map(
|
||||
(payments ?? []).map((p) => [p.sessionId, p])
|
||||
);
|
||||
|
||||
const handleRefresh = async (paymentId: string) => {
|
||||
try {
|
||||
await refreshPayment.mutateAsync(paymentId);
|
||||
const updated = paymentBySession.get(
|
||||
(payments ?? []).find((p) => p.id === paymentId)?.sessionId ?? ''
|
||||
);
|
||||
toast.success(
|
||||
updated?.status === 'paid'
|
||||
? 'Pembayaran berhasil dikonfirmasi otomatis ✅'
|
||||
: 'Status pembayaran masih menunggu pembayaran'
|
||||
);
|
||||
} catch {
|
||||
toast.error('Gagal memperbarui status pembayaran');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout role="mentee" title="Mentoring">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-[23px] font-bold text-neutral-800 md:text-[29px]">
|
||||
Sesi Mentoring Saya
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-500 mt-1">
|
||||
Pantau jadwal, status pembayaran, dan konfirmasi sesi kamu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
condition={SessionToken.get() != null}
|
||||
fallback={<AnonymousState />}
|
||||
>
|
||||
<Show
|
||||
condition={isLoading}
|
||||
fallback={
|
||||
<Show
|
||||
condition={(sessions?.sessions?.length ?? 0) > 0}
|
||||
fallback={
|
||||
<div className="bg-white rounded-lg p-12 text-center">
|
||||
<p className="text-neutral-500 mb-4">
|
||||
Kamu belum memiliki sesi mentoring.
|
||||
</p>
|
||||
<Link to="/mentoring">
|
||||
<Button variant="primary">Cari Mentor</Button>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(sessions?.sessions ?? []).map((session) => {
|
||||
if (!session) return null;
|
||||
const payment = paymentBySession.get(session.id);
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className="bg-white rounded-lg p-5 shadow-sm border border-neutral-100"
|
||||
>
|
||||
<div className="flex justify-between items-start gap-3 mb-3">
|
||||
<h3 className="font-semibold text-neutral-800 line-clamp-2">
|
||||
{session.topic}
|
||||
</h3>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 px-3 py-1 rounded-full text-[12px] font-medium capitalize',
|
||||
STATUS_STYLE[session.status] ?? 'bg-neutral-100 text-neutral-600'
|
||||
)}
|
||||
>
|
||||
{session.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-neutral-500 mb-1">
|
||||
📅 {formatDate(session.scheduled_at)}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500 mb-4">
|
||||
⏱️ {session.duration_minutes} menit ·{' '}
|
||||
{session.session_type === 'video_call'
|
||||
? 'Video Call'
|
||||
: 'Chat'}
|
||||
</p>
|
||||
|
||||
<Show condition={!!payment}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex justify-between items-center px-3 py-2 rounded-md text-xs font-medium mb-3',
|
||||
PAYMENT_STYLE[payment?.status ?? ''] ?? ''
|
||||
)}
|
||||
>
|
||||
<span className="capitalize">
|
||||
{payment?.status === 'paid' ? 'Lunas' : 'Menunggu pembayaran'}
|
||||
</span>
|
||||
<span>{payment ? formatRupiah(payment.total) : ''}</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Show
|
||||
condition={!!payment && payment.status === 'pending' && payment.provider === 'midtrans'}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="gap-2"
|
||||
disabled={refreshPayment.isPending}
|
||||
onClick={() => payment && handleRefresh(payment.id)}
|
||||
>
|
||||
<ReloadOutlined /> Cek Status
|
||||
</Button>
|
||||
</Show>
|
||||
<Show condition={session.status === 'confirmed'}>
|
||||
<Link to={`/mentoring/${session.mentor_id}`}>
|
||||
<Button size="sm" variant="bordered">
|
||||
Detail Mentor
|
||||
</Button>
|
||||
</Link>
|
||||
</Show>
|
||||
<Show condition={session.status === 'completed'}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
className="gap-1.5"
|
||||
onClick={() =>
|
||||
setFeedbackTarget({
|
||||
sessionId: session.id,
|
||||
mentorName: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<StarOutlined /> Beri Feedback
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{[0, 1].map((i) => (
|
||||
<div key={i} className="bg-white rounded-lg p-5 animate-pulse">
|
||||
<div className="h-4 bg-neutral-200 rounded w-2/3 mb-3" />
|
||||
<div className="h-3 bg-neutral-200 rounded w-1/2 mb-2" />
|
||||
<div className="h-3 bg-neutral-200 rounded w-1/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<FeedbackModal
|
||||
open={feedbackTarget != null}
|
||||
sessionId={feedbackTarget?.sessionId ?? ''}
|
||||
mentorName={feedbackTarget?.mentorName}
|
||||
onClose={() => setFeedbackTarget(null)}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['get-my-sessions'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['get-mentor-stats'] });
|
||||
}}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const AnonymousState: FC = (): ReactElement => (
|
||||
<div className="bg-white rounded-lg p-12 text-center shadow-sm border border-neutral-100">
|
||||
<p className="text-[46px] mb-2">🔐</p>
|
||||
<h2 className="text-xl font-semibold text-neutral-800 mb-2">
|
||||
Login dulu, Senpai!
|
||||
</h2>
|
||||
<p className="text-neutral-500 mb-6 max-w-md mx-auto">
|
||||
Sesi mentoring kamu aman tersimpan di akun. Masuk untuk melihat jadwal,
|
||||
status pembayaran, dan konfirmasi sesi.
|
||||
</p>
|
||||
<Link to="/auth/login">
|
||||
<Button variant="primary" size="lg">
|
||||
Login Sekarang
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Components;
|
||||
@@ -3,24 +3,27 @@ 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, Show } from '@imphnen-frontend-service/utils';
|
||||
import { For } from '@imphnen-frontend-service/utils';
|
||||
import { MentorCard } from './_components/mentor-card';
|
||||
import { Pagination } from '@imphnen-frontend-service/ui/molecules';
|
||||
import { useGetMentors } from '@imphnen-frontend-service/service';
|
||||
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: 8,
|
||||
pageSize: 3,
|
||||
});
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const { data: mentors, isLoading } = useGetMentors({ search });
|
||||
|
||||
const table = useReactTable({
|
||||
data: mentors ?? [],
|
||||
data: TEMP_DATA,
|
||||
columns: [],
|
||||
state: { pagination },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -28,8 +31,6 @@ export const Components: FC = (): ReactElement => {
|
||||
onPaginationChange: setPagination,
|
||||
});
|
||||
|
||||
const pageMentors = table.getRowModel().rows.map((row) => row.original);
|
||||
|
||||
const ref = useRef(null)
|
||||
const isInView = useInView(ref, { once: true, amount: 0.2 })
|
||||
|
||||
@@ -62,7 +63,7 @@ export const Components: FC = (): ReactElement => {
|
||||
|
||||
<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-3xl"
|
||||
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 }}
|
||||
@@ -90,8 +91,6 @@ export const Components: FC = (): ReactElement => {
|
||||
<Input
|
||||
placeholder="Cari berdasarkan nama, posisi/peran"
|
||||
className="relative min-w-full w-full"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<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>
|
||||
@@ -102,31 +101,13 @@ export const Components: FC = (): ReactElement => {
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
>
|
||||
<Show
|
||||
condition={!isLoading}
|
||||
fallback={
|
||||
<motion.p variants={childVariants} className="text-center text-neutral-500 col-span-full py-10">
|
||||
Memuat data mentor...
|
||||
</motion.p>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
condition={pageMentors.length > 0}
|
||||
fallback={
|
||||
<motion.p variants={childVariants} className="text-center text-neutral-500 col-span-full py-10">
|
||||
Tidak ada mentor ditemukan
|
||||
</motion.p>
|
||||
}
|
||||
>
|
||||
<For data={pageMentors}>
|
||||
{(mentor, index) => (
|
||||
<motion.div key={mentor.id ?? index} variants={childVariants}>
|
||||
<MentorCard mentor={mentor} />
|
||||
</motion.div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
<For data={Array.from({ length: 8 })}>
|
||||
{(_, index) => (
|
||||
<motion.div key={index} variants={childVariants}>
|
||||
<MentorCard />
|
||||
</motion.div>
|
||||
)}
|
||||
</For>
|
||||
</motion.div>
|
||||
|
||||
<Pagination table={table} />
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
'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;
|
||||
@@ -0,0 +1,25 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { EditSectionButton } from './edit-section-button';
|
||||
export { ModalButton } from './modal-button';
|
||||
@@ -0,0 +1,76 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './profile-context';
|
||||
@@ -0,0 +1,250 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useMemo, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
|
||||
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 };
|
||||
@@ -0,0 +1,27 @@
|
||||
'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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
|
||||
export * from './profile';
|
||||
|
||||
|
||||
export * from './sections';
|
||||
|
||||
|
||||
export * from './buttons';
|
||||
|
||||
|
||||
export * from './shared';
|
||||
|
||||
|
||||
export * from './modals';
|
||||
|
||||
|
||||
export * from './contexts';
|
||||
@@ -0,0 +1,187 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { ModalButton } from '../buttons/modal-button';
|
||||
|
||||
interface DescriptionModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialValue: string;
|
||||
onSave: (value: string) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const DescriptionModal: FC<DescriptionModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialValue,
|
||||
onSave,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [description, setDescription] = useState(initialValue);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setDescription(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await onSave(description);
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setDescription(initialValue);
|
||||
onClose();
|
||||
};
|
||||
|
||||
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 Description</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
<div>
|
||||
<label htmlFor="description-textarea" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description-textarea"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors resize-none"
|
||||
placeholder="Write your description here..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 p-6 pt-4">
|
||||
<ModalButton
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Batal
|
||||
</ModalButton>
|
||||
<ModalButton
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isLoading ? 'Menyimpan...' : 'Simpan'}
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import React, { FC, useState, useEffect } from 'react';
|
||||
import { Modal, InputField } from '@imphnen-frontend-service/ui/molecules';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useProfile } from '../contexts/profile-context';
|
||||
import { CameraOutlined } from '@ant-design/icons';
|
||||
import { useUploadAvatar } from '@imphnen-frontend-service/service';
|
||||
|
||||
interface EditProfileModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
showNotification: (type: 'success' | 'error', title: string, message?: string) => void;
|
||||
}
|
||||
|
||||
export const EditProfileModal: FC<EditProfileModalProps> = ({ isOpen, onClose, showNotification }) => {
|
||||
const { profileData, profileType, updateProfile } = useProfile();
|
||||
const uploadAvatarMutation = useUploadAvatar();
|
||||
const [formData, setFormData] = useState({
|
||||
fullname: '',
|
||||
avatar: ''
|
||||
});
|
||||
const [previewUrl, setPreviewUrl] = useState<string>('/image/testimonial.webp');
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const fullname = profileData.fullname ||
|
||||
(profileType === 'mentor' && 'legal_name' in profileData ? profileData.legal_name : '') || '';
|
||||
|
||||
|
||||
const avatar = (profileType === 'user' && 'avatar' in profileData)
|
||||
? profileData.avatar || '/image/testimonial.webp'
|
||||
: '/image/testimonial.webp';
|
||||
|
||||
console.log('Modal - Profile data avatar URL:', avatar);
|
||||
console.log('Modal - Profile data:', profileData);
|
||||
|
||||
setFormData({
|
||||
fullname,
|
||||
avatar: (profileType === 'user' && 'avatar' in profileData) ? profileData.avatar || '' : ''
|
||||
});
|
||||
|
||||
|
||||
setPreviewUrl(avatar);
|
||||
} else {
|
||||
|
||||
setPreviewUrl('/image/testimonial.webp');
|
||||
}
|
||||
}, [profileData, profileType]);
|
||||
|
||||
const handleImageError = () => {
|
||||
console.log('Image failed to load:', previewUrl);
|
||||
setPreviewUrl('/image/testimonial.webp');
|
||||
};
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData(prev => ({ ...prev, fullname: e.target.value }));
|
||||
};
|
||||
|
||||
const handleImageUpload = async (file: File) => {
|
||||
try {
|
||||
setIsUploading(true);
|
||||
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
setPreviewUrl(result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
|
||||
const uploadResult = await uploadAvatarMutation.mutateAsync(file);
|
||||
|
||||
console.log('Avatar upload response:', uploadResult);
|
||||
|
||||
|
||||
interface UploadData {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const uploadData = ('data' in uploadResult ? (uploadResult as { data: UploadData }).data : uploadResult as UploadData);
|
||||
|
||||
|
||||
setFormData(prev => ({ ...prev, avatar: uploadData.url || '' }));
|
||||
|
||||
|
||||
setPreviewUrl(uploadData.url || '/image/testimonial.webp');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Avatar upload error:', error);
|
||||
showNotification('error', 'Upload Failed', 'Failed to upload avatar image');
|
||||
|
||||
const originalAvatar = (profileType === 'user' && profileData && 'avatar' in profileData) ? profileData.avatar : '';
|
||||
setPreviewUrl(originalAvatar || '/image/testimonial.webp');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const updates: Record<string, string> = {};
|
||||
|
||||
|
||||
if (formData.fullname.trim() !== '') {
|
||||
if (profileType === 'user') {
|
||||
updates.fullname = formData.fullname;
|
||||
} else if (profileType === 'mentor') {
|
||||
updates.legal_name = formData.fullname;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (formData.avatar && formData.avatar !== (profileData && 'avatar' in profileData ? profileData.avatar : '')) {
|
||||
updates.avatar = formData.avatar;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await updateProfile(updates);
|
||||
showNotification('success', 'Profile Updated', 'Your profile has been successfully updated.');
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
console.error('Profile update error:', err);
|
||||
let apiMessage = '';
|
||||
if (typeof err === 'object' && err !== null) {
|
||||
const errObj = err as { response?: { data?: { message?: string } } };
|
||||
let backendMsg = '';
|
||||
if (errObj.response?.data?.message) {
|
||||
backendMsg = errObj.response.data.message;
|
||||
}
|
||||
let msg = '';
|
||||
if ('message' in err && typeof (err as { message?: string }).message === 'string') {
|
||||
msg = (err as { message?: string }).message || '';
|
||||
|
||||
if (msg.trim().startsWith('{') && msg.trim().endsWith('}')) {
|
||||
try {
|
||||
const parsed = JSON.parse(msg);
|
||||
if (parsed && typeof parsed.message === 'string') {
|
||||
msg = parsed.message;
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
if (backendMsg && msg && backendMsg !== msg) {
|
||||
apiMessage = backendMsg + '\n' + msg;
|
||||
} else if (backendMsg) {
|
||||
apiMessage = backendMsg;
|
||||
} else if (msg) {
|
||||
apiMessage = msg;
|
||||
}
|
||||
}
|
||||
showNotification('error', 'Failed to save changes', apiMessage || 'Please try again.');
|
||||
}
|
||||
}; return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<Modal.Header>
|
||||
<Modal.Title>Edit Profile</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Content>
|
||||
{}
|
||||
<div className="flex justify-center pt-6 pb-4">
|
||||
<div className="relative">
|
||||
{}
|
||||
<input
|
||||
id="avatar-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleImageUpload(file);
|
||||
}}
|
||||
className="hidden"
|
||||
disabled={isUploading}
|
||||
/>
|
||||
|
||||
<label htmlFor="avatar-upload" className="cursor-pointer block relative group">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Profile"
|
||||
className="w-24 h-24 rounded-full object-cover border-4 border-blue-100 shadow-lg transition-all duration-300 group-hover:border-blue-200"
|
||||
onError={handleImageError}
|
||||
/>
|
||||
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 rounded-full bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-300 flex items-center justify-center">
|
||||
<span className="text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
Change Photo
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{}
|
||||
<label htmlFor="avatar-upload" className="cursor-pointer">
|
||||
<div className="absolute bottom-0 right-0 w-8 h-8 bg-blue-500 hover:bg-blue-600 text-white rounded-full flex items-center justify-center transition-all duration-300 shadow-lg border-2 border-white">
|
||||
{isUploading ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
) : (
|
||||
<CameraOutlined className="text-xs" />
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div> <div className="px-6 pb-6 space-y-6">
|
||||
{}
|
||||
<div className="space-y-2">
|
||||
<InputField
|
||||
label="Full Name"
|
||||
name="fullname"
|
||||
value={formData.fullname}
|
||||
onChange={handleNameChange}
|
||||
placeholder="Enter your full name"
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 pl-1">
|
||||
This name will be displayed on your profile
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className="flex gap-3 w-full">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
className="flex-1"
|
||||
disabled={isUploading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
className="flex-1"
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Changes'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { ModalButton } from '../buttons/modal-button';
|
||||
import { InputField } from '@imphnen-frontend-service/ui/molecules';
|
||||
|
||||
interface Education {
|
||||
id: string;
|
||||
institution: string;
|
||||
degree: string;
|
||||
field: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
interface EducationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialValue: Education[];
|
||||
onSave: (value: Education[]) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
showNotification?: (type: 'success' | 'error', title: string, message?: string) => void;
|
||||
}
|
||||
|
||||
export const EducationModal: FC<EducationModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialValue,
|
||||
onSave,
|
||||
isLoading = false,
|
||||
showNotification,
|
||||
}) => {
|
||||
const [educations, setEducations] = useState<Education[]>(initialValue);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setEducations(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
const handleSave = async () => {
|
||||
|
||||
const hasEmpty = educations.some(edu =>
|
||||
!edu.institution.trim() || !edu.degree.trim() || !edu.field.trim() || !edu.period.trim()
|
||||
);
|
||||
if (hasEmpty) {
|
||||
if (showNotification) {
|
||||
showNotification('error', 'Data Tidak Lengkap', 'Semua field harus diisi pada setiap pendidikan.');
|
||||
} else {
|
||||
alert('Semua field harus diisi pada setiap pendidikan.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onSave(educations);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEducations(initialValue);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const addEducation = () => {
|
||||
const newEducation: Education = {
|
||||
id: Date.now().toString(),
|
||||
institution: '',
|
||||
degree: '',
|
||||
field: '',
|
||||
period: '',
|
||||
};
|
||||
setEducations([...educations, newEducation]);
|
||||
};
|
||||
|
||||
const removeEducation = (id: string) => {
|
||||
setEducations(educations.filter(edu => edu.id !== id));
|
||||
};
|
||||
|
||||
const updateEducation = (id: string, field: keyof Education, value: string) => {
|
||||
setEducations(educations.map(edu =>
|
||||
edu.id === id ? { ...edu, [field]: value } : edu
|
||||
));
|
||||
};
|
||||
|
||||
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"
|
||||
></button>
|
||||
|
||||
<div className="relative bg-white rounded-xl shadow-xl max-w-5xl w-full max-h-[95vh] overflow-hidden">
|
||||
|
||||
<div className="p-6 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 Education</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="p-6 max-h-[60vh] overflow-y-auto">
|
||||
<div className="space-y-6">
|
||||
{educations.map((education, index) => (
|
||||
<div key={education.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Education {index + 1}</h3>
|
||||
<ModalButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => removeEducation(education.id)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</ModalButton>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InputField
|
||||
label="Institution"
|
||||
value={education.institution}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateEducation(education.id, 'institution', e.target.value)}
|
||||
placeholder="Enter institution name"
|
||||
/>
|
||||
<InputField
|
||||
label="Degree"
|
||||
value={education.degree}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateEducation(education.id, 'degree', e.target.value)}
|
||||
placeholder="Enter degree"
|
||||
/>
|
||||
<InputField
|
||||
label="Field"
|
||||
value={education.field}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateEducation(education.id, 'field', e.target.value)}
|
||||
placeholder="Enter field of study"
|
||||
/>
|
||||
<InputField
|
||||
label="Period"
|
||||
value={education.period}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateEducation(education.id, 'period', e.target.value)}
|
||||
placeholder="e.g., Sep 2022 - Current"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<ModalButton
|
||||
variant="secondary"
|
||||
onClick={addEducation}
|
||||
className="w-full border-2 border-dashed border-gray-300 rounded-lg p-4 text-gray-500 hover:border-[#23A1EB] hover:text-[#23A1EB] transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<PlusOutlined />
|
||||
Add Education
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 bg-gray-50">
|
||||
<ModalButton
|
||||
variant="secondary"
|
||||
className="bg-white shadow-md"
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Batal
|
||||
</ModalButton>
|
||||
<ModalButton
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isLoading ? 'Menyimpan...' : 'Simpan'}
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { ModalButton } from '../buttons/modal-button';
|
||||
import { InputField } from '@imphnen-frontend-service/ui/molecules';
|
||||
|
||||
|
||||
interface Experience {
|
||||
id: string;
|
||||
company: string;
|
||||
position: string;
|
||||
duration: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
interface ExperienceModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialValue: Experience[];
|
||||
onSave: (value: Experience[]) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
showNotification?: (type: 'success' | 'error', title: string, message?: string) => void;
|
||||
}
|
||||
|
||||
export const ExperienceModal: FC<ExperienceModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialValue,
|
||||
onSave,
|
||||
isLoading = false,
|
||||
showNotification,
|
||||
}) => {
|
||||
const [experiences, setExperiences] = useState<Experience[]>(initialValue);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setExperiences(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
const handleSave = async () => {
|
||||
|
||||
const hasEmpty = experiences.some(exp =>
|
||||
!exp.company.trim() || !exp.position.trim() || !exp.duration.trim() || !exp.period.trim()
|
||||
);
|
||||
if (hasEmpty) {
|
||||
if (showNotification) {
|
||||
showNotification('error', 'Data Tidak Lengkap', 'Semua field harus diisi pada setiap pengalaman kerja.');
|
||||
} else {
|
||||
alert('Semua field harus diisi pada setiap pengalaman kerja.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onSave(experiences);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setExperiences(initialValue);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const addExperience = () => {
|
||||
const newExperience: Experience = {
|
||||
id: Date.now().toString(),
|
||||
company: '',
|
||||
position: '',
|
||||
duration: '',
|
||||
period: '',
|
||||
};
|
||||
setExperiences([...experiences, newExperience]);
|
||||
};
|
||||
|
||||
const removeExperience = (id: string) => {
|
||||
setExperiences(experiences.filter(exp => exp.id !== id));
|
||||
};
|
||||
|
||||
const updateExperience = (id: string, field: keyof Experience, value: string) => {
|
||||
setExperiences(experiences.map(exp =>
|
||||
exp.id === id ? { ...exp, [field]: value } : exp
|
||||
));
|
||||
};
|
||||
|
||||
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"
|
||||
/>
|
||||
|
||||
<div className="relative bg-white rounded-xl shadow-xl max-w-5xl w-full max-h-[95vh] overflow-hidden">
|
||||
<div className="p-6 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 Experience</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 max-h-[60vh] overflow-y-auto">
|
||||
<div className="space-y-6">
|
||||
{experiences.map((experience, index) => (
|
||||
<div key={experience.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Experience {index + 1}</h3>
|
||||
<ModalButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => removeExperience(experience.id)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</ModalButton>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InputField
|
||||
label="Company"
|
||||
value={experience.company}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateExperience(experience.id, 'company', e.target.value)}
|
||||
placeholder="Enter company name"
|
||||
/>
|
||||
<InputField
|
||||
label="Position"
|
||||
value={experience.position}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateExperience(experience.id, 'position', e.target.value)}
|
||||
placeholder="Enter position"
|
||||
/>
|
||||
<InputField
|
||||
label="Duration"
|
||||
value={experience.duration}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateExperience(experience.id, 'duration', e.target.value)}
|
||||
placeholder="e.g., 7 Months"
|
||||
/>
|
||||
<InputField
|
||||
label="Period"
|
||||
value={experience.period}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateExperience(experience.id, 'period', e.target.value)}
|
||||
placeholder="e.g., Jan 2024 - Present"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<ModalButton
|
||||
variant="secondary"
|
||||
onClick={addExperience}
|
||||
className="w-full border-2 border-dashed border-gray-300 rounded-lg p-4 text-gray-500 hover:border-[#23A1EB] hover:text-[#23A1EB] transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<PlusOutlined />
|
||||
Add Experience
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 bg-gray-50">
|
||||
<ModalButton
|
||||
variant="secondary"
|
||||
className="bg-white shadow-md"
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Batal
|
||||
</ModalButton>
|
||||
<ModalButton
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isLoading ? 'Menyimpan...' : 'Simpan'}
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export { NotificationModal } from './notification-modal';
|
||||
export { CVModal } from './cv-modal';
|
||||
export { DescriptionModal } from './description-modal';
|
||||
export { EducationModal } from './education-modal';
|
||||
export { ExperienceModal } from './experience-modal';
|
||||
export { SocialMediaModal } from './social-media-modal';
|
||||
export { SkillsModal } from './skills-modal';
|
||||
export { EditProfileModal } from './edit-profile-modal';
|
||||
export { LanguagesModal } from './languages-modal';
|
||||
export { PersonalInfoModal } from './personal-info-modal';
|
||||
@@ -0,0 +1,153 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { Input } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { ModalButton } from '../buttons/modal-button';
|
||||
|
||||
interface Language {
|
||||
id?: string;
|
||||
name: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
interface LanguagesModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialValue: Language[];
|
||||
onSave: (languages: Language[]) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const LanguagesModal: FC<LanguagesModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialValue,
|
||||
onSave,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [languages, setLanguages] = useState<Language[]>(initialValue);
|
||||
|
||||
useEffect(() => {
|
||||
const languagesWithIds = initialValue.map(lang => ({
|
||||
...lang,
|
||||
id: lang.id || `lang-${Date.now()}-${Math.random()}`
|
||||
}));
|
||||
setLanguages(languagesWithIds);
|
||||
}, [initialValue]);
|
||||
|
||||
const handleAddLanguage = () => {
|
||||
setLanguages([...languages, {
|
||||
id: `lang-${Date.now()}-${Math.random()}`,
|
||||
name: '',
|
||||
level: ''
|
||||
}]);
|
||||
};
|
||||
|
||||
const handleLanguageChange = (index: number, field: keyof Language, value: string) => {
|
||||
const newLanguages = [...languages];
|
||||
newLanguages[index] = { ...newLanguages[index], [field]: value };
|
||||
setLanguages(newLanguages);
|
||||
};
|
||||
|
||||
const handleRemoveLanguage = (index: number) => {
|
||||
const newLanguages = languages.filter((_, i) => i !== index);
|
||||
setLanguages(newLanguages);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
|
||||
const languagesToSave = languages.map(({ id, ...lang }) => lang);
|
||||
onSave(languagesToSave);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
const languagesWithIds = initialValue.map(lang => ({
|
||||
...lang,
|
||||
id: lang.id || `lang-${Date.now()}-${Math.random()}`
|
||||
}));
|
||||
setLanguages(languagesWithIds);
|
||||
onClose();
|
||||
};
|
||||
|
||||
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}
|
||||
type="button"
|
||||
aria-label="Close modal"
|
||||
/>
|
||||
|
||||
{}
|
||||
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto max-h-[95vh] overflow-hidden">
|
||||
{}
|
||||
<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 Languages</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="p-6 max-h-[60vh] overflow-y-auto">
|
||||
<div className="space-y-4">
|
||||
{languages.map((language, index) => (
|
||||
<div key={language.id} className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-2">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-700 mb-1">Language Name</p>
|
||||
<Input
|
||||
value={language.name}
|
||||
onChange={(e) => handleLanguageChange(index, 'name', e.target.value)}
|
||||
placeholder="e.g., English"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-700 mb-1">Level</p>
|
||||
<Input
|
||||
value={language.level}
|
||||
onChange={(e) => handleLanguageChange(index, 'level', e.target.value)}
|
||||
placeholder="e.g., Fluent"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:flex-shrink-0">
|
||||
<ModalButton variant="danger" onClick={() => handleRemoveLanguage(index)} className="w-full sm:w-auto">
|
||||
Remove
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<ModalButton variant="secondary" onClick={handleAddLanguage} className="w-full">
|
||||
Add Language
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="flex gap-3 p-6 pt-4">
|
||||
<ModalButton
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
className="flex-1 bg-white shadow-md"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</ModalButton>
|
||||
<ModalButton
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isLoading ? 'Saving...' : 'Save'}
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { FC } from 'react';
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import { ModalButton } from '../buttons/modal-button';
|
||||
|
||||
export type NotificationType = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
type: 'success' | 'error';
|
||||
title: string;
|
||||
message?: string;
|
||||
header: string;
|
||||
}
|
||||
|
||||
interface NotificationModalProps extends NotificationType {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
type: 'success' | 'error';
|
||||
title: string;
|
||||
message?: string;
|
||||
header: string;
|
||||
}
|
||||
|
||||
export const NotificationModal: FC<NotificationModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
header,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isSuccess = type === 'success';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||
<button
|
||||
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
/>
|
||||
|
||||
<div className="relative bg-white rounded-xl shadow-xl max-w-md w-full overflow-hidden">
|
||||
<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">{header}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 text-center">
|
||||
|
||||
<div className="mb-6">
|
||||
{isSuccess ? (
|
||||
<div className="w-30 h-30 mx-auto mb-4">
|
||||
<img
|
||||
src="/image/success.png"
|
||||
alt="Success"
|
||||
width={80}
|
||||
height={80}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-red-500 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CloseOutlined className="text-white text-3xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className={`text-lg font-medium ${isSuccess ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{title}
|
||||
</h2>
|
||||
{!isSuccess && message && (
|
||||
<div className="mt-2 text-sm text-red-500 whitespace-pre-line">{message}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ModalButton
|
||||
variant="primary"
|
||||
onClick={onClose}
|
||||
className="w-full bg-[#23A1EB] hover:bg-[#23A1EB]/90"
|
||||
>
|
||||
Selesai
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { ModalButton } from '../buttons/modal-button';
|
||||
|
||||
interface PersonalInfo {
|
||||
email: string;
|
||||
phone: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
interface PersonalInfoModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialValue: PersonalInfo;
|
||||
onSave: (value: PersonalInfo) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const PersonalInfoModal: FC<PersonalInfoModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialValue,
|
||||
onSave,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [personalInfo, setPersonalInfo] = useState(initialValue);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setPersonalInfo(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await onSave(personalInfo);
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setPersonalInfo(initialValue);
|
||||
onClose();
|
||||
};
|
||||
|
||||
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 Personal Information</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="personal-email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="personal-email"
|
||||
type="email"
|
||||
value={personalInfo.email}
|
||||
onChange={(e) => setPersonalInfo({ ...personalInfo, email: e.target.value })}
|
||||
placeholder="Enter your email"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="personal-phone" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Phone
|
||||
</label>
|
||||
<input
|
||||
id="personal-phone"
|
||||
type="tel"
|
||||
value={personalInfo.phone}
|
||||
onChange={(e) => setPersonalInfo({ ...personalInfo, phone: e.target.value })}
|
||||
placeholder="Enter your phone number"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="personal-location" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Location
|
||||
</label>
|
||||
<input
|
||||
id="personal-location"
|
||||
type="text"
|
||||
value={personalInfo.location}
|
||||
onChange={(e) => setPersonalInfo({ ...personalInfo, location: e.target.value })}
|
||||
placeholder="Enter your location"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex gap-3 p-6 pt-4">
|
||||
<ModalButton variant="secondary"
|
||||
onClick={handleCancel}
|
||||
className="flex-1 bg-white shadow-md"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Batal
|
||||
</ModalButton>
|
||||
<ModalButton variant="primary"
|
||||
onClick={handleSave}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isLoading ? 'Menyimpan...' : 'Simpan'}
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { FC, useState } from 'react';
|
||||
import { ModalButton } from '../buttons/modal-button';
|
||||
|
||||
interface ProfileBasicInfo {
|
||||
name: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface ProfileBasicInfoModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialValue: ProfileBasicInfo;
|
||||
onSave: (value: ProfileBasicInfo) => void;
|
||||
}
|
||||
|
||||
export const ProfileBasicInfoModal: FC<ProfileBasicInfoModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialValue,
|
||||
onSave,
|
||||
}) => {
|
||||
const [profileInfo, setProfileInfo] = useState(initialValue);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(profileInfo);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setProfileInfo(initialValue);
|
||||
onClose();
|
||||
};
|
||||
|
||||
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 Profile</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="profile-name" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
id="profile-name"
|
||||
type="text"
|
||||
value={profileInfo.name}
|
||||
onChange={(e) => setProfileInfo({ ...profileInfo, name: e.target.value })}
|
||||
placeholder="Enter your full name"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="profile-title" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Professional Title
|
||||
</label>
|
||||
<input
|
||||
id="profile-title"
|
||||
type="text"
|
||||
value={profileInfo.title}
|
||||
onChange={(e) => setProfileInfo({ ...profileInfo, title: e.target.value })}
|
||||
placeholder="Enter your professional title"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 p-6 pt-4">
|
||||
<ModalButton
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
className="flex-1"
|
||||
>
|
||||
Batal
|
||||
</ModalButton>
|
||||
<ModalButton
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
className="flex-1"
|
||||
>
|
||||
Simpan
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { ModalButton } from '../buttons/modal-button';
|
||||
import { InputField } from '@imphnen-frontend-service/ui/molecules';
|
||||
|
||||
interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface SkillsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialValue: Skill[];
|
||||
onSave: (value: Skill[]) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const SkillsModal: FC<SkillsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialValue,
|
||||
onSave,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [skills, setSkills] = useState<Skill[]>(initialValue);
|
||||
const [newSkill, setNewSkill] = useState({ name: '' });
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setSkills(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await onSave(skills);
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setSkills(initialValue);
|
||||
setNewSkill({ name: '' });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const addSkill = () => {
|
||||
if (newSkill.name.trim()) {
|
||||
const skill: Skill = {
|
||||
id: Date.now().toString(),
|
||||
name: newSkill.name.trim(),
|
||||
};
|
||||
setSkills([...skills, skill]);
|
||||
setNewSkill({ name: '' });
|
||||
}
|
||||
};
|
||||
|
||||
const removeSkill = (id: string) => {
|
||||
setSkills(skills.filter(skill => skill.id !== id));
|
||||
};
|
||||
|
||||
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}
|
||||
type="button"
|
||||
aria-label="Close modal"
|
||||
/>
|
||||
|
||||
|
||||
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto max-h-[95vh] overflow-hidden">
|
||||
|
||||
<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 Skills</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="p-6 max-h-[60vh] overflow-y-auto">
|
||||
|
||||
<div className="mb-6 p-4 border border-gray-200 rounded-lg bg-gray-50">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Add New Skill</h3>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1">
|
||||
<InputField
|
||||
label="Skill Name"
|
||||
value={newSkill.name}
|
||||
onChange={(e) => setNewSkill({ ...newSkill, name: e.target.value })}
|
||||
placeholder="e.g., React, JavaScript, etc."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex sm:items-end">
|
||||
<ModalButton
|
||||
onClick={addSkill}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<PlusOutlined />
|
||||
<span className="sm:hidden ml-2">Add Skill</span>
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Current Skills</h3>
|
||||
{skills.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm py-4">No skills added yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{skills.map((skill) => (
|
||||
<div
|
||||
key={skill.id}
|
||||
className="flex items-center justify-between p-3 border border-gray-200 rounded-md bg-white"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900">{skill.name}</span>
|
||||
</div>
|
||||
<ModalButton
|
||||
onClick={() => removeSkill(skill.id)}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</ModalButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex gap-3 p-6 pt-4">
|
||||
<ModalButton variant="secondary"
|
||||
onClick={handleCancel}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Batal
|
||||
</ModalButton>
|
||||
<ModalButton variant="primary"
|
||||
onClick={handleSave}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isLoading ? 'Menyimpan...' : 'Simpan'}
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { ModalButton } from '../buttons/modal-button';
|
||||
|
||||
interface SocialLink {
|
||||
platform: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface SocialMediaModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialValue: SocialLink[];
|
||||
onSave: (value: SocialLink[]) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const SocialMediaModal: FC<SocialMediaModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialValue,
|
||||
onSave,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [socialLinks, setSocialLinks] = useState<SocialLink[]>(initialValue);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setSocialLinks(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await onSave(socialLinks);
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setSocialLinks(initialValue);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSocialLinkChange = (index: number, value: string) => {
|
||||
const updated = [...socialLinks];
|
||||
updated[index].value = value;
|
||||
setSocialLinks(updated);
|
||||
};
|
||||
|
||||
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 Social Media</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="px-6 py-4 space-y-4 max-h-[60vh] overflow-y-auto">
|
||||
{socialLinks.map((link, index) => (
|
||||
<div key={link.platform}>
|
||||
<label htmlFor={`social-${index}`} className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{link.platform}
|
||||
</label>
|
||||
<input
|
||||
id={`social-${index}`}
|
||||
type="text"
|
||||
placeholder={link.placeholder}
|
||||
value={link.value}
|
||||
onChange={(e) => handleSocialLinkChange(index, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex gap-3 p-6 pt-4">
|
||||
<ModalButton
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Batal
|
||||
</ModalButton>
|
||||
<ModalButton
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isLoading ? 'Menyimpan...' : 'Simpan'}
|
||||
</ModalButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export { ProfileHeader } from './profile-header';
|
||||
export { ProfileInfo } from './profile-info';
|
||||
export { ProfileTabs } from './profile-tabs';
|
||||
export { ProfileForm } from './profile-form';
|
||||
export { ProfileSidebar } from './profile-sidebar';
|
||||
export type { SocialLink, Experience, Education, Language, PersonalInfo, ContactInfo, CvResume, NotificationState, ProfileFormProps } from './profile-form-types';
|
||||
export { useProfileFormState, useProfileDataSync } from './profile-form-hooks';
|
||||
export { useProfileHandlers } from './profile-form-handlers';
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import type { MentorUpdateRequestDto, UserUpdateRequestDto } from '@imphnen-frontend-service/service';
|
||||
import { useProfile } from '../contexts/profile-context';
|
||||
import type { SocialLink, ProfileUpdateData, Experience, Education } from './profile-form-types';
|
||||
|
||||
export const useProfileHandlers = (
|
||||
showNotification: (type: 'success' | 'error', title: string, message?: string) => void
|
||||
) => {
|
||||
const { updateProfile, profileType } = useProfile();
|
||||
|
||||
const handleProfileUpdate = async (updates: ProfileUpdateData) => {
|
||||
try {
|
||||
const result = await updateProfile(updates);
|
||||
showNotification('success', 'Perubahan Berhasil Disimpan');
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Profile update error:', err);
|
||||
showNotification('error', 'Gagal menyimpan perubahan', 'Silakan coba lagi');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePersonalInfoSave = async (personalData: { phone: string; location: string }) => {
|
||||
await handleProfileUpdate({
|
||||
phone_for_verification: personalData.phone,
|
||||
location: personalData.location
|
||||
});
|
||||
};
|
||||
|
||||
const handleContactInfoSave = async (contactData: { phone: string; location: string }) => {
|
||||
await handleProfileUpdate({
|
||||
phone_for_verification: contactData.phone,
|
||||
location: contactData.location
|
||||
});
|
||||
};
|
||||
|
||||
const handleSocialMediaSave = async (newSocialLinks: SocialLink[]) => {
|
||||
const linkedIn = newSocialLinks.find(link => link.platform === 'LinkedIn')?.value;
|
||||
const github = newSocialLinks.find(link => link.platform === 'Github')?.value;
|
||||
const portfolio = newSocialLinks.find(link => link.platform === 'Portfolio')?.value;
|
||||
const twitter = newSocialLinks.find(link => link.platform === 'Twitter')?.value;
|
||||
|
||||
const updates: ProfileUpdateData = {
|
||||
linkedin_url: linkedIn || undefined,
|
||||
github_url: github || undefined,
|
||||
twitter_url: twitter || undefined,
|
||||
};
|
||||
|
||||
if (profileType === 'mentor') {
|
||||
(updates as MentorUpdateRequestDto).portfolio_url = portfolio || undefined;
|
||||
} else if (profileType === 'user') {
|
||||
(updates as UserUpdateRequestDto).website_url = portfolio || undefined;
|
||||
}
|
||||
|
||||
await handleProfileUpdate(updates);
|
||||
};
|
||||
|
||||
const handleDescriptionSave = async (newDescription: string) => {
|
||||
await handleProfileUpdate({
|
||||
bio: newDescription || null
|
||||
});
|
||||
};
|
||||
|
||||
const handleSkillsSave = async (newSkills: string[]) => {
|
||||
const updates: ProfileUpdateData = {};
|
||||
|
||||
if (profileType === 'user') {
|
||||
(updates as UserUpdateRequestDto).skills = newSkills;
|
||||
} else if (profileType === 'mentor') {
|
||||
(updates as MentorUpdateRequestDto).expertise = newSkills;
|
||||
}
|
||||
|
||||
await handleProfileUpdate(updates);
|
||||
};
|
||||
|
||||
const handleLanguagesSave = async (newLanguages: Array<{ name: string; level: string }>) => {
|
||||
await handleProfileUpdate({
|
||||
languages: newLanguages.map(lang => lang.name)
|
||||
} as MentorUpdateRequestDto | UserUpdateRequestDto);
|
||||
};
|
||||
|
||||
const handleExperiencesSave = async (newExperiences: Experience[]) => {
|
||||
try {
|
||||
await handleProfileUpdate({
|
||||
experience: newExperiences
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Experience update error:', error);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
const handleEducationSave = async (newEducations: Education[]) => {
|
||||
try {
|
||||
await handleProfileUpdate({
|
||||
education: newEducations
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Education update error:', error);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
const handleCvResumeSave = async (cvData: { fileName?: string; fileUrl?: string }) => {
|
||||
await handleProfileUpdate({
|
||||
cv_url: cvData.fileUrl || cvData.fileName || null
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
handlePersonalInfoSave,
|
||||
handleContactInfoSave,
|
||||
handleSocialMediaSave,
|
||||
handleDescriptionSave,
|
||||
handleSkillsSave,
|
||||
handleLanguagesSave,
|
||||
handleExperiencesSave,
|
||||
handleEducationSave,
|
||||
handleCvResumeSave
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useProfile } from '../contexts/profile-context';
|
||||
import type {
|
||||
SocialLink,
|
||||
Experience,
|
||||
Education,
|
||||
Language,
|
||||
PersonalInfo,
|
||||
ContactInfo,
|
||||
CvResume,
|
||||
NotificationState
|
||||
} from './profile-form-types';
|
||||
|
||||
export const useProfileFormState = () => {
|
||||
const { profileData, profileType } = useProfile();
|
||||
|
||||
const [notification, setNotification] = useState<NotificationState>({
|
||||
isOpen: false,
|
||||
type: 'success',
|
||||
title: '',
|
||||
message: ''
|
||||
});
|
||||
|
||||
const [socialLinks, setSocialLinks] = useState<SocialLink[]>([
|
||||
{
|
||||
platform: 'LinkedIn',
|
||||
placeholder: 'linkedin.com/in/yourprofile',
|
||||
value: ''
|
||||
},
|
||||
{
|
||||
platform: 'Github',
|
||||
placeholder: 'github.com/yourusername',
|
||||
value: ''
|
||||
},
|
||||
{
|
||||
platform: 'Portfolio',
|
||||
placeholder: 'yourportfolio.com',
|
||||
value: ''
|
||||
},
|
||||
{
|
||||
platform: 'Twitter',
|
||||
placeholder: 'twitter.com/yourusername',
|
||||
value: ''
|
||||
}
|
||||
]);
|
||||
|
||||
const [experiences, setExperiences] = useState<Experience[]>([]);
|
||||
const [education, setEducation] = useState<Education[]>([]);
|
||||
const [skills, setSkills] = useState<string[]>([]);
|
||||
const [languages, setLanguages] = useState<Language[]>([]);
|
||||
|
||||
const [personalInfo, setPersonalInfo] = useState<PersonalInfo>({
|
||||
fullname: '',
|
||||
title: '',
|
||||
bio: '',
|
||||
birthdate: '',
|
||||
gender: ''
|
||||
});
|
||||
|
||||
const [contactInfo, setContactInfo] = useState<ContactInfo>({
|
||||
email: '',
|
||||
phone: '',
|
||||
location: ''
|
||||
});
|
||||
|
||||
const [cvResume, setCvResume] = useState<CvResume>({
|
||||
cvUrl: '',
|
||||
resumeUrl: ''
|
||||
});
|
||||
|
||||
return {
|
||||
profileData,
|
||||
profileType,
|
||||
notification,
|
||||
setNotification,
|
||||
socialLinks,
|
||||
setSocialLinks,
|
||||
experiences,
|
||||
setExperiences,
|
||||
education,
|
||||
setEducation,
|
||||
skills,
|
||||
setSkills,
|
||||
languages,
|
||||
setLanguages,
|
||||
personalInfo,
|
||||
setPersonalInfo,
|
||||
contactInfo,
|
||||
setContactInfo,
|
||||
cvResume,
|
||||
setCvResume
|
||||
};
|
||||
};
|
||||
|
||||
export const useProfileDataSync = (state: ReturnType<typeof useProfileFormState>) => {
|
||||
const {
|
||||
profileData,
|
||||
profileType,
|
||||
setSocialLinks,
|
||||
setExperiences,
|
||||
setEducation,
|
||||
setSkills,
|
||||
setLanguages,
|
||||
setPersonalInfo,
|
||||
setContactInfo,
|
||||
setCvResume
|
||||
} = state;
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const linkedinUrl = 'linkedin_url' in profileData ? profileData.linkedin_url || '' : '';
|
||||
const githubUrl = 'github_url' in profileData ? profileData.github_url || '' : '';
|
||||
|
||||
|
||||
let portfolioUrl = '';
|
||||
if (profileType === 'mentor' && 'portfolio_url' in profileData) {
|
||||
portfolioUrl = profileData.portfolio_url || '';
|
||||
} else if (profileType === 'user' && 'website_url' in profileData) {
|
||||
portfolioUrl = profileData.website_url || '';
|
||||
}
|
||||
|
||||
const twitterUrl = 'twitter_url' in profileData ? profileData.twitter_url || '' : '';
|
||||
|
||||
setSocialLinks([
|
||||
{
|
||||
platform: 'LinkedIn',
|
||||
placeholder: 'linkedin.com/in/yourprofile',
|
||||
value: linkedinUrl
|
||||
},
|
||||
{
|
||||
platform: 'Github',
|
||||
placeholder: 'github.com/yourusername',
|
||||
value: githubUrl
|
||||
},
|
||||
{
|
||||
platform: 'Portfolio',
|
||||
placeholder: 'yourportfolio.com',
|
||||
value: portfolioUrl
|
||||
},
|
||||
{
|
||||
platform: 'Twitter',
|
||||
placeholder: 'twitter.com/yourusername',
|
||||
value: twitterUrl
|
||||
}
|
||||
]);
|
||||
}
|
||||
}, [profileData, profileType, setSocialLinks]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const experiences = 'experience' in profileData ? profileData.experience || [] : [];
|
||||
setExperiences(experiences);
|
||||
}
|
||||
}, [profileData, setExperiences]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const education = 'education' in profileData ? profileData.education || [] : [];
|
||||
setEducation(education);
|
||||
}
|
||||
}, [profileData, setEducation]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
let skills: string[] = [];
|
||||
if ('skills' in profileData) {
|
||||
skills = profileData.skills || [];
|
||||
} else if ('expertise' in profileData) {
|
||||
skills = profileData.expertise || [];
|
||||
}
|
||||
setSkills(skills);
|
||||
}
|
||||
}, [profileData, setSkills]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const languages: Language[] = 'languages' in profileData
|
||||
? (profileData.languages || []).map(lang => ({ name: lang, level: 'Intermediate' }))
|
||||
: [];
|
||||
setLanguages(languages);
|
||||
}
|
||||
}, [profileData, setLanguages]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const bio = 'bio' in profileData ? profileData.bio || '' : '';
|
||||
|
||||
let fullname = '';
|
||||
if ('fullname' in profileData) {
|
||||
fullname = profileData.fullname || '';
|
||||
} else if ('legal_name' in profileData) {
|
||||
fullname = profileData.legal_name || '';
|
||||
}
|
||||
|
||||
const title = 'current_role' in profileData ? profileData.current_role || '' : '';
|
||||
const birthdate = 'birthdate' in profileData ? profileData.birthdate || '' : '';
|
||||
const gender = 'gender' in profileData ? profileData.gender || '' : '';
|
||||
|
||||
setPersonalInfo({
|
||||
fullname,
|
||||
title,
|
||||
bio,
|
||||
birthdate,
|
||||
gender
|
||||
});
|
||||
}
|
||||
}, [profileData, setPersonalInfo]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const email = 'email' in profileData ? profileData.email || '' : '';
|
||||
|
||||
let phone = '';
|
||||
if ('phone_number' in profileData) {
|
||||
phone = profileData.phone_number || '';
|
||||
} else if ('phone_for_verification' in profileData) {
|
||||
phone = profileData.phone_for_verification || '';
|
||||
}
|
||||
|
||||
let location = '';
|
||||
if ('location' in profileData) {
|
||||
location = profileData.location || '';
|
||||
} else if ('domicile' in profileData) {
|
||||
location = profileData.domicile || '';
|
||||
}
|
||||
|
||||
setContactInfo({
|
||||
email,
|
||||
phone,
|
||||
location
|
||||
});
|
||||
}
|
||||
}, [profileData, setContactInfo]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const cvUrl = profileType === 'mentor' && 'cv_url' in profileData ? profileData.cv_url || '' : '';
|
||||
|
||||
setCvResume({
|
||||
cvUrl,
|
||||
resumeUrl: ''
|
||||
});
|
||||
}
|
||||
}, [profileData, profileType, setCvResume]);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { MentorUpdateRequestDto, UserUpdateRequestDto } from '@imphnen-frontend-service/service';
|
||||
|
||||
export interface SocialLink {
|
||||
platform: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Experience {
|
||||
id: string;
|
||||
company: string;
|
||||
position: string;
|
||||
duration: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
export interface Education {
|
||||
id: string;
|
||||
institution: string;
|
||||
degree: string;
|
||||
field: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
export interface Language {
|
||||
name: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
export interface PersonalInfo {
|
||||
fullname: string;
|
||||
title: string;
|
||||
bio: string;
|
||||
birthdate: string;
|
||||
gender: string;
|
||||
}
|
||||
|
||||
export interface ContactInfo {
|
||||
email: string;
|
||||
phone: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export interface CvResume {
|
||||
cvUrl: string;
|
||||
resumeUrl: string;
|
||||
}
|
||||
|
||||
export interface NotificationState {
|
||||
isOpen: boolean;
|
||||
type: 'success' | 'error';
|
||||
title: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ProfileFormProps {
|
||||
showNotification: (type: 'success' | 'error', title: string, message?: string) => void;
|
||||
}
|
||||
|
||||
export type ProfileUpdateData = Partial<MentorUpdateRequestDto | UserUpdateRequestDto>;
|
||||
@@ -0,0 +1,251 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { NotificationModal } from '../modals';
|
||||
import { ExperiencesSection } from '../sections/experiences-section';
|
||||
import { CvResumeSection } from '../sections/cv-resume-section';
|
||||
import { DescriptionSection } from '../sections/description-section';
|
||||
import { EducationSection } from '../sections/education-section';
|
||||
import type { MentorUpdateRequestDto, UserUpdateRequestDto } from '@imphnen-frontend-service/service';
|
||||
import { useProfile } from '../contexts/profile-context';
|
||||
|
||||
interface Experience {
|
||||
id: string;
|
||||
company: string;
|
||||
position: string;
|
||||
duration: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
interface Education {
|
||||
id: string;
|
||||
institution: string;
|
||||
degree: string;
|
||||
field: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
interface ProfileFormProps {
|
||||
showNotification: (type: 'success' | 'error', title: string, message?: string) => void;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
}
|
||||
|
||||
export const ProfileForm: FC<ProfileFormProps> = ({ showNotification, isViewOnly = false }) => {
|
||||
const { profileData, updateProfile, isUpdating } = useProfile();
|
||||
|
||||
const [notification, setNotification] = useState<{
|
||||
isOpen: boolean;
|
||||
type: 'success' | 'error';
|
||||
title: string;
|
||||
message?: string;
|
||||
}>({
|
||||
isOpen: false,
|
||||
type: 'success',
|
||||
title: '',
|
||||
message: ''
|
||||
});
|
||||
|
||||
const [experiences, setExperiences] = useState<Experience[]>([]);
|
||||
const [education, setEducation] = useState<Education[]>([]);
|
||||
|
||||
|
||||
const [personalInfo, setPersonalInfo] = useState({
|
||||
fullname: '',
|
||||
title: '',
|
||||
bio: '',
|
||||
birthdate: '',
|
||||
gender: ''
|
||||
});
|
||||
|
||||
const [cvResume, setCvResume] = useState({
|
||||
cvUrl: '',
|
||||
resumeUrl: ''
|
||||
});
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const experiences = 'experience' in profileData ? profileData.experience || [] : [];
|
||||
setExperiences(experiences);
|
||||
}
|
||||
}, [profileData]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const education = 'education' in profileData ? profileData.education || [] : [];
|
||||
setEducation(education);
|
||||
}
|
||||
}, [profileData]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const bio = 'bio' in profileData ? profileData.bio || '' : '';
|
||||
|
||||
let fullname = '';
|
||||
if ('fullname' in profileData) {
|
||||
fullname = profileData.fullname || '';
|
||||
} else if ('legal_name' in profileData) {
|
||||
fullname = profileData.legal_name || '';
|
||||
}
|
||||
|
||||
const title = 'current_role' in profileData ? profileData.current_role || '' : '';
|
||||
const birthdate = 'birthdate' in profileData ? profileData.birthdate || '' : '';
|
||||
const gender = 'gender' in profileData ? profileData.gender || '' : '';
|
||||
|
||||
setPersonalInfo({
|
||||
fullname,
|
||||
title,
|
||||
bio,
|
||||
birthdate,
|
||||
gender
|
||||
});
|
||||
}
|
||||
}, [profileData]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const cvUrl = 'cv_url' in profileData ? profileData.cv_url || '' : '';
|
||||
|
||||
setCvResume({
|
||||
cvUrl,
|
||||
resumeUrl: ''
|
||||
});
|
||||
}
|
||||
}, [profileData]);
|
||||
|
||||
|
||||
|
||||
function isErrorWithResponse(err: unknown): err is { response: { data: { message: string } } } {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
|
||||
typeof (err as { response?: { data?: { message?: unknown } } }).response?.data?.message === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorWithMessage(err: unknown): err is { message: string } {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'message' in err &&
|
||||
typeof (err as { message?: unknown }).message === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function tryParseJsonMessage(msg: string): string {
|
||||
const trimmed = msg.trim();
|
||||
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed.message === 'string') {
|
||||
return parsed.message;
|
||||
}
|
||||
} catch {
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
function extractApiMessage(err: unknown): string {
|
||||
if (isErrorWithResponse(err)) {
|
||||
return err.response.data.message;
|
||||
}
|
||||
if (isErrorWithMessage(err)) {
|
||||
const msg = err.message || '';
|
||||
return tryParseJsonMessage(msg);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const handleProfileUpdate = async (updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto>) => {
|
||||
if (isViewOnly) { // Prevent updates if in view-only mode
|
||||
showNotification('error', 'Akses Ditolak', 'Anda tidak memiliki izin untuk mengedit profil ini.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await updateProfile(updates);
|
||||
showNotification('success', 'Perubahan Berhasil Disimpan');
|
||||
return result;
|
||||
} catch (err: unknown) {
|
||||
console.error('Profile update error:', err);
|
||||
const apiMessage = extractApiMessage(err);
|
||||
showNotification('error', 'Gagal menyimpan perubahan', apiMessage || 'Silakan coba lagi');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{}
|
||||
<DescriptionSection
|
||||
initialDescription={personalInfo.bio}
|
||||
onSave={async (newDescription) => {
|
||||
await handleProfileUpdate({
|
||||
bio: newDescription || null
|
||||
});
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
isLoading={isUpdating}
|
||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||
/>
|
||||
{}
|
||||
<CvResumeSection
|
||||
initialFileName={cvResume.cvUrl}
|
||||
fullname={personalInfo.fullname}
|
||||
onSave={async (cvData) => {
|
||||
await handleProfileUpdate({
|
||||
cv_url: cvData.fileUrl || null
|
||||
});
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
isLoading={isUpdating}
|
||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||
/>
|
||||
{}
|
||||
<ExperiencesSection
|
||||
initialExperiences={experiences}
|
||||
onSave={async (newExperiences) => {
|
||||
try {
|
||||
await handleProfileUpdate({
|
||||
experience: newExperiences
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Experience update error:', error);
|
||||
}
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
isLoading={isUpdating}
|
||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||
/>
|
||||
|
||||
{}
|
||||
<EducationSection
|
||||
initialEducation={education}
|
||||
onSave={async (newEducations) => {
|
||||
try {
|
||||
await handleProfileUpdate({
|
||||
education: newEducations
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Education update error:', error);
|
||||
}
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
isLoading={isUpdating}
|
||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||
/>
|
||||
|
||||
<NotificationModal
|
||||
isOpen={notification.isOpen}
|
||||
onClose={() => setNotification(prev => ({ ...prev, isOpen: false }))}
|
||||
type={notification.type}
|
||||
title={notification.title}
|
||||
message={notification.message}
|
||||
header="Profile"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { FC } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useProfile } from '../contexts/profile-context';
|
||||
|
||||
interface ProfileHeaderProps {
|
||||
onEditProfileClick: () => void;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
}
|
||||
|
||||
export const ProfileHeader: FC<ProfileHeaderProps> = ({ onEditProfileClick, isViewOnly = false }) => {
|
||||
const { profileData, profileType } = useProfile();
|
||||
|
||||
|
||||
const avatarSrc = (profileType === 'user' && profileData && 'avatar' in profileData)
|
||||
? profileData.avatar || "/image/testimonial.webp"
|
||||
: "/image/testimonial.webp";
|
||||
|
||||
|
||||
const displayFullname = profileData?.fullname ||
|
||||
(profileType === 'mentor' && profileData && 'legal_name' in profileData
|
||||
? profileData.legal_name
|
||||
: 'User Name');
|
||||
let displayJob = 'Role';
|
||||
if (profileData) {
|
||||
if (profileType === 'mentor' && 'current_role' in profileData) {
|
||||
displayJob = profileData.current_role || 'Mentor';
|
||||
} else if (profileType === 'user' && 'role' in profileData && profileData.role) {
|
||||
displayJob = profileData.role.name || 'User';
|
||||
} else if ('current_role' in profileData) {
|
||||
|
||||
displayJob = profileData.current_role || 'Role';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const joinDate = profileData && 'created_at' in profileData
|
||||
? new Date(profileData.created_at).toLocaleDateString('id-ID', {
|
||||
year: 'numeric',
|
||||
month: 'long'
|
||||
})
|
||||
: 'April 2024';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-white rounded-lg p-6 md:p-8 shadow-sm"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-6">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-24 h-24 md:w-32 md:h-32 rounded-full overflow-hidden bg-primary-100 flex items-center justify-center">
|
||||
<img
|
||||
src={avatarSrc}
|
||||
alt="Profile"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-neutral-800 mb-2">
|
||||
{displayFullname}
|
||||
</h1>
|
||||
<p className="text-neutral-600 mb-1">{displayJob}</p>
|
||||
{}
|
||||
<p className="text-sm text-neutral-500 mt-2">
|
||||
Bergabung sejak {joinDate}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isViewOnly && ( // Conditionally render the button
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 self-start"
|
||||
onClick={onEditProfileClick}
|
||||
>
|
||||
<EditOutlined className="text-sm" />
|
||||
Edit Profile
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profileType === 'mentor' && (
|
||||
<div className="flex justify-around md:justify-start md:gap-12 mt-6 pt-6 border-t border-neutral-100">
|
||||
<div className="text-center md:text-left">
|
||||
<p className="text-lg md:text-xl font-semibold text-primary-500">
|
||||
{profileData && 'mentoring_sessions' in profileData ? profileData.mentoring_sessions || 'N/A' : 'N/A'}
|
||||
</p>
|
||||
<p className="text-xs md:text-sm text-neutral-600">Mentoring Sessions</p>
|
||||
</div>
|
||||
<div className="text-center md:text-left">
|
||||
<p className="text-lg md:text-xl font-semibold text-primary-500">
|
||||
{profileData && 'rating' in profileData ? profileData.rating || 'N/A' : 'N/A'}
|
||||
</p>
|
||||
<p className="text-xs md:text-sm text-neutral-600">Rating</p>
|
||||
</div>
|
||||
<div className="text-center md:text-left">
|
||||
<p className="text-lg md:text-xl font-semibold text-primary-500">
|
||||
0
|
||||
</p>
|
||||
<p className="text-xs md:text-sm text-neutral-600">Certificates</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useState, FC, useEffect } from 'react';
|
||||
import { PersonalInfoSection } from '../sections/personal-info-section';
|
||||
import { SkillsSection } from '../sections/skills-section';
|
||||
import { LanguagesSection } from '../sections/languages-section';
|
||||
import { SocialMediaSection } from '../sections/social-media-section';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { useProfile } from '../contexts/profile-context';
|
||||
import type { MentorUpdateRequestDto, UserUpdateRequestDto } from '@imphnen-frontend-service/service';
|
||||
|
||||
interface Language {
|
||||
name: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
interface SocialLink {
|
||||
platform: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ProfileInfoProps {
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
}
|
||||
|
||||
export const ProfileInfo: FC<ProfileInfoProps> = ({ showNotification }) => {
|
||||
const { profileData, updateProfile, profileType } = useProfile();
|
||||
|
||||
const [contactInfo, setContactInfo] = useState({
|
||||
email: '',
|
||||
phone: '',
|
||||
location: ''
|
||||
});
|
||||
|
||||
const [currentSkills, setCurrentSkills] = useState<string[]>([]);
|
||||
const [currentLanguages, setCurrentLanguages] = useState<Language[]>([]);
|
||||
const [currentSocialLinks, setCurrentSocialLinks] = useState<SocialLink[]>([
|
||||
{ platform: 'LinkedIn', placeholder: 'linkedin.com/in/yourprofile', value: '' },
|
||||
{ platform: 'Github', placeholder: 'github.com/yourusername', value: '' },
|
||||
{ platform: 'Portfolio', placeholder: 'yourportfolio.com', value: '' },
|
||||
{ platform: 'Twitter', placeholder: 'twitter.com/yourusername', value: '' }
|
||||
]);
|
||||
|
||||
|
||||
const extractContactInfo = (profileData: MentorUpdateRequestDto | UserUpdateRequestDto) => {
|
||||
const email = 'email' in profileData && typeof profileData.email === 'string' ? profileData.email || '' : '';
|
||||
let phone = '';
|
||||
if ('phone_number' in profileData) {
|
||||
phone = profileData.phone_number || '';
|
||||
} else if ('phone_for_verification' in profileData) {
|
||||
phone = profileData.phone_for_verification || '';
|
||||
}
|
||||
|
||||
let location = '';
|
||||
if ('location' in profileData) {
|
||||
location = profileData.location || '';
|
||||
} else if ('domicile' in profileData) {
|
||||
location = profileData.domicile || '';
|
||||
}
|
||||
|
||||
return { email, phone, location };
|
||||
};
|
||||
|
||||
const extractSkills = (profileData: MentorUpdateRequestDto | UserUpdateRequestDto) => {
|
||||
if ('skills' in profileData) {
|
||||
return profileData.skills || [];
|
||||
} else if ('expertise' in profileData) {
|
||||
return profileData.expertise || [];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const extractLanguages = (profileData: MentorUpdateRequestDto | UserUpdateRequestDto): Language[] => {
|
||||
return 'languages' in profileData
|
||||
? (profileData.languages || []).map((lang: string) => ({ name: lang, level: 'Intermediate' }))
|
||||
: [];
|
||||
};
|
||||
|
||||
const extractSocialLinks = (profileData: MentorUpdateRequestDto | UserUpdateRequestDto, profileType: string): SocialLink[] => {
|
||||
const linkedinUrl = 'linkedin_url' in profileData ? profileData.linkedin_url || '' : '';
|
||||
const githubUrl = 'github_url' in profileData ? profileData.github_url || '' : '';
|
||||
let portfolioUrl = '';
|
||||
if (profileType === 'mentor' && 'portfolio_url' in profileData) {
|
||||
portfolioUrl = profileData.portfolio_url || '';
|
||||
} else if (profileType === 'user' && 'website_url' in profileData) {
|
||||
portfolioUrl = profileData.website_url || '';
|
||||
}
|
||||
const twitterUrl = 'twitter_url' in profileData ? profileData.twitter_url || '' : '';
|
||||
|
||||
return [
|
||||
{ platform: 'LinkedIn', placeholder: 'linkedin.com/in/yourprofile', value: linkedinUrl },
|
||||
{ platform: 'Github', placeholder: 'github.com/yourusername', value: githubUrl },
|
||||
{ platform: 'Portfolio', placeholder: 'yourportfolio.com', value: portfolioUrl },
|
||||
{ platform: 'Twitter', placeholder: 'twitter.com/yourusername', value: twitterUrl }
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
setContactInfo(extractContactInfo(profileData));
|
||||
setCurrentSkills(extractSkills(profileData));
|
||||
setCurrentLanguages(extractLanguages(profileData));
|
||||
setCurrentSocialLinks(extractSocialLinks(profileData, profileType));
|
||||
}
|
||||
}, [profileData, profileType]);
|
||||
|
||||
|
||||
const handleProfileUpdate = async (updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto>) => {
|
||||
try {
|
||||
await updateProfile(updates);
|
||||
showNotification('success', 'Perubahan Berhasil Disimpan');
|
||||
} catch (err) {
|
||||
console.error('Profile update error:', err);
|
||||
showNotification('error', 'Gagal menyimpan perubahan', 'Silakan coba lagi');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PersonalInfoSection
|
||||
initialContactInfo={contactInfo}
|
||||
onSave={async (newContactInfo) => {
|
||||
setContactInfo(newContactInfo);
|
||||
await handleProfileUpdate({
|
||||
phone_for_verification: newContactInfo.phone,
|
||||
location: newContactInfo.location
|
||||
});
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
|
||||
<SocialMediaSection
|
||||
initialSocialLinks={currentSocialLinks}
|
||||
onSave={async (newSocialLinks) => {
|
||||
setCurrentSocialLinks(newSocialLinks);
|
||||
const linkedIn = newSocialLinks.find(link => link.platform === 'LinkedIn')?.value;
|
||||
const github = newSocialLinks.find(link => link.platform === 'Github')?.value;
|
||||
const portfolio = newSocialLinks.find(link => link.platform === 'Portfolio')?.value;
|
||||
const twitter = newSocialLinks.find(link => link.platform === 'Twitter')?.value;
|
||||
|
||||
const updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto> = {
|
||||
linkedin_url: linkedIn || undefined,
|
||||
github_url: github || undefined,
|
||||
twitter_url: twitter || undefined,
|
||||
};
|
||||
|
||||
if (profileType === 'mentor') {
|
||||
(updates as MentorUpdateRequestDto).portfolio_url = portfolio || undefined;
|
||||
} else if (profileType === 'user') {
|
||||
(updates as UserUpdateRequestDto).website_url = portfolio || undefined;
|
||||
}
|
||||
|
||||
await handleProfileUpdate(updates);
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
|
||||
<SkillsSection
|
||||
initialSkills={currentSkills}
|
||||
onSave={async (newSkills) => {
|
||||
setCurrentSkills(newSkills);
|
||||
const updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto> = {};
|
||||
|
||||
if (profileType === 'user') {
|
||||
(updates as UserUpdateRequestDto).skills = newSkills;
|
||||
} else if (profileType === 'mentor') {
|
||||
(updates as MentorUpdateRequestDto).expertise = newSkills;
|
||||
}
|
||||
|
||||
await handleProfileUpdate(updates);
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
|
||||
<LanguagesSection
|
||||
initialLanguages={currentLanguages}
|
||||
onSave={async (newLanguages) => {
|
||||
setCurrentLanguages(newLanguages);
|
||||
await handleProfileUpdate({
|
||||
languages: newLanguages.map(lang => lang.name)
|
||||
} as MentorUpdateRequestDto | UserUpdateRequestDto);
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useState, FC, useEffect } from 'react';
|
||||
import { PersonalInfoSection } from '../sections/personal-info-section';
|
||||
import { SkillsSection } from '../sections/skills-section';
|
||||
import { LanguagesSection } from '../sections/languages-section';
|
||||
import { SocialMediaSection } from '../sections/social-media-section';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { useProfile } from '../contexts/profile-context';
|
||||
import type { MentorUpdateRequestDto, UserUpdateRequestDto } from '@imphnen-frontend-service/service';
|
||||
|
||||
interface Language {
|
||||
name: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
interface SocialLink {
|
||||
platform: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ProfileInfoProps {
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
}
|
||||
|
||||
export const ProfileInfo: FC<ProfileInfoProps> = ({ showNotification }) => {
|
||||
const { profileData, updateProfile, profileType } = useProfile();
|
||||
|
||||
const [contactInfo, setContactInfo] = useState({
|
||||
email: '',
|
||||
phone: '',
|
||||
location: ''
|
||||
});
|
||||
|
||||
const [currentSkills, setCurrentSkills] = useState<string[]>([]);
|
||||
const [currentLanguages, setCurrentLanguages] = useState<Language[]>([]);
|
||||
const [currentSocialLinks, setCurrentSocialLinks] = useState<SocialLink[]>([
|
||||
{ platform: 'LinkedIn', placeholder: 'linkedin.com/in/yourprofile', value: '' },
|
||||
{ platform: 'Github', placeholder: 'github.com/yourusername', value: '' },
|
||||
{ platform: 'Portfolio', placeholder: 'yourportfolio.com', value: '' },
|
||||
{ platform: 'Twitter', placeholder: 'twitter.com/yourusername', value: '' }
|
||||
]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const email = 'email' in profileData ? profileData.email || '' : '';
|
||||
let phone = '';
|
||||
if ('phone_number' in profileData) {
|
||||
phone = profileData.phone_number || '';
|
||||
} else if ('phone_for_verification' in profileData) {
|
||||
phone = profileData.phone_for_verification || '';
|
||||
}
|
||||
|
||||
let location = '';
|
||||
if ('location' in profileData) {
|
||||
location = profileData.location || '';
|
||||
} else if ('domicile' in profileData) {
|
||||
location = profileData.domicile || '';
|
||||
}
|
||||
|
||||
setContactInfo({ email, phone, location });
|
||||
}
|
||||
}, [profileData]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
let skills: string[] = [];
|
||||
if ('skills' in profileData) {
|
||||
skills = profileData.skills || [];
|
||||
} else if ('expertise' in profileData) {
|
||||
skills = profileData.expertise || [];
|
||||
}
|
||||
setCurrentSkills(skills);
|
||||
}
|
||||
}, [profileData]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const languages: Language[] = 'languages' in profileData
|
||||
? (profileData.languages || []).map(lang => ({ name: lang, level: 'Intermediate' }))
|
||||
: [];
|
||||
setCurrentLanguages(languages);
|
||||
}
|
||||
}, [profileData]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData) {
|
||||
const linkedinUrl = 'linkedin_url' in profileData ? profileData.linkedin_url || '' : '';
|
||||
const githubUrl = 'github_url' in profileData ? profileData.github_url || '' : '';
|
||||
let portfolioUrl = '';
|
||||
if (profileType === 'mentor' && 'portfolio_url' in profileData) {
|
||||
portfolioUrl = profileData.portfolio_url || '';
|
||||
} else if (profileType === 'user' && 'website_url' in profileData) {
|
||||
portfolioUrl = profileData.website_url || '';
|
||||
}
|
||||
const twitterUrl = 'twitter_url' in profileData ? profileData.twitter_url || '' : '';
|
||||
|
||||
setCurrentSocialLinks([
|
||||
{ platform: 'LinkedIn', placeholder: 'linkedin.com/in/yourprofile', value: linkedinUrl },
|
||||
{ platform: 'Github', placeholder: 'github.com/yourusername', value: githubUrl },
|
||||
{ platform: 'Portfolio', placeholder: 'yourportfolio.com', value: portfolioUrl },
|
||||
{ platform: 'Twitter', placeholder: 'twitter.com/yourusername', value: twitterUrl }
|
||||
]);
|
||||
}
|
||||
}, [profileData, profileType]);
|
||||
|
||||
|
||||
const handleProfileUpdate = async (updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto>) => {
|
||||
try {
|
||||
await updateProfile(updates);
|
||||
showNotification('success', 'Perubahan Berhasil Disimpan');
|
||||
} catch (err) {
|
||||
console.error('Profile update error:', err);
|
||||
showNotification('error', 'Gagal menyimpan perubahan', 'Silakan coba lagi');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PersonalInfoSection
|
||||
initialContactInfo={contactInfo}
|
||||
onSave={async (newContactInfo) => {
|
||||
setContactInfo(newContactInfo);
|
||||
await handleProfileUpdate({
|
||||
phone_for_verification: newContactInfo.phone,
|
||||
location: newContactInfo.location
|
||||
});
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
|
||||
<SocialMediaSection
|
||||
initialSocialLinks={currentSocialLinks}
|
||||
onSave={async (newSocialLinks) => {
|
||||
setCurrentSocialLinks(newSocialLinks);
|
||||
const linkedIn = newSocialLinks.find(link => link.platform === 'LinkedIn')?.value;
|
||||
const github = newSocialLinks.find(link => link.platform === 'Github')?.value;
|
||||
const portfolio = newSocialLinks.find(link => link.platform === 'Portfolio')?.value;
|
||||
const twitter = newSocialLinks.find(link => link.platform === 'Twitter')?.value;
|
||||
|
||||
const updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto> = {
|
||||
linkedin_url: linkedIn || undefined,
|
||||
github_url: github || undefined,
|
||||
twitter_url: twitter || undefined,
|
||||
};
|
||||
|
||||
if (profileType === 'mentor') {
|
||||
(updates as MentorUpdateRequestDto).portfolio_url = portfolio || undefined;
|
||||
} else if (profileType === 'user') {
|
||||
(updates as UserUpdateRequestDto).website_url = portfolio || undefined;
|
||||
}
|
||||
|
||||
await handleProfileUpdate(updates);
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
|
||||
<SkillsSection
|
||||
initialSkills={currentSkills}
|
||||
onSave={async (newSkills) => {
|
||||
setCurrentSkills(newSkills);
|
||||
const updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto> = {};
|
||||
|
||||
if (profileType === 'user') {
|
||||
(updates as UserUpdateRequestDto).skills = newSkills;
|
||||
} else if (profileType === 'mentor') {
|
||||
(updates as MentorUpdateRequestDto).expertise = newSkills;
|
||||
}
|
||||
|
||||
await handleProfileUpdate(updates);
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
|
||||
<LanguagesSection
|
||||
initialLanguages={currentLanguages}
|
||||
onSave={async (newLanguages) => {
|
||||
setCurrentLanguages(newLanguages);
|
||||
await handleProfileUpdate({
|
||||
languages: newLanguages.map(lang => lang.name)
|
||||
} as MentorUpdateRequestDto | UserUpdateRequestDto);
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
import { FC, useState, useEffect, useCallback } from 'react';
|
||||
import { Select } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { SectionWrapper } from '../shared/section-wrapper';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { PersonalInfoSection } from '../sections/personal-info-section';
|
||||
import { SkillsSection } from '../sections/skills-section';
|
||||
import type { MentorUpdateRequestDto, UserUpdateRequestDto } from '@imphnen-frontend-service/service';
|
||||
import { useProfile } from '../contexts/profile-context';
|
||||
|
||||
interface ProfileSidebarProps {
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
}
|
||||
|
||||
export const ProfileSidebar: FC<ProfileSidebarProps> = ({ showNotification, isViewOnly = false }) => {
|
||||
const { profileData, updateProfile, profileType, isLoading, isUpdating } = useProfile();
|
||||
|
||||
|
||||
const getCareerStatus = useCallback(() => {
|
||||
if (!profileData) {
|
||||
return 'Career Status';
|
||||
}
|
||||
|
||||
if (profileType === 'user' && 'career_status' in profileData) {
|
||||
const status = profileData.career_status;
|
||||
if (status && typeof status === 'string' && status.trim() !== '') {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
if (profileType === 'mentor' && 'availability_commitment' in profileData) {
|
||||
const commitment = profileData.availability_commitment;
|
||||
if (commitment && typeof commitment === 'string' && commitment.trim() !== '') {
|
||||
return commitment;
|
||||
}
|
||||
}
|
||||
|
||||
return 'Career Status';
|
||||
}, [profileType, profileData]);
|
||||
|
||||
const getEmail = useCallback(() => {
|
||||
if (profileData && 'email' in profileData) {
|
||||
return profileData.email || 'email@example.com';
|
||||
}
|
||||
return 'email@example.com';
|
||||
}, [profileData]);
|
||||
|
||||
const getPhone = useCallback(() => {
|
||||
if (profileData && 'phone_for_verification' in profileData && profileData.phone_for_verification) {
|
||||
return profileData.phone_for_verification;
|
||||
}
|
||||
if (profileType === 'user' && profileData && 'phone_number' in profileData && profileData.phone_number) {
|
||||
return profileData.phone_number;
|
||||
}
|
||||
return '+62 (88) 8888 8888';
|
||||
}, [profileType, profileData]);
|
||||
|
||||
const getLocation = useCallback(() => {
|
||||
if (profileData && 'domicile' in profileData && profileData.domicile) {
|
||||
return profileData.domicile;
|
||||
}
|
||||
if (profileType === 'user' && profileData && 'location' in profileData && profileData.location) {
|
||||
return profileData.location;
|
||||
}
|
||||
return 'Location';
|
||||
}, [profileType, profileData]);
|
||||
|
||||
const getSkills = useCallback(() => {
|
||||
if (profileType === 'mentor' && profileData && 'expertise' in profileData && profileData.expertise) {
|
||||
return Array.isArray(profileData.expertise) ? profileData.expertise : [];
|
||||
}
|
||||
if (profileType === 'user' && profileData && 'skills' in profileData && profileData.skills) {
|
||||
return Array.isArray(profileData.skills) ? profileData.skills : [];
|
||||
}
|
||||
return [''];
|
||||
}, [profileType, profileData]);
|
||||
|
||||
const [careerStatus, setCareerStatus] = useState<string>('Career Status');
|
||||
const [isUpdatingCareerStatus, setIsUpdatingCareerStatus] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
const [personalInfo, setPersonalInfo] = useState({
|
||||
email: 'email@example.com',
|
||||
phone: '+62 (88) 8888 8888',
|
||||
location: 'Location'
|
||||
});
|
||||
|
||||
const [skills, setSkills] = useState<string[]>(['']);
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData && !isLoading && careerStatus === 'Career Status') {
|
||||
const initialCareerStatus = getCareerStatus();
|
||||
|
||||
setCareerStatus(initialCareerStatus);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [profileData, isLoading, careerStatus, getCareerStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData && !isLoading) {
|
||||
if (!isUpdatingCareerStatus && isInitialized) {
|
||||
const newCareerStatus = getCareerStatus();
|
||||
console.log('ProfileSidebar: Updating career status from API:', newCareerStatus);
|
||||
setCareerStatus(newCareerStatus);
|
||||
}
|
||||
|
||||
setPersonalInfo({
|
||||
email: getEmail(),
|
||||
phone: getPhone(),
|
||||
location: getLocation()
|
||||
});
|
||||
|
||||
setSkills(getSkills());
|
||||
}
|
||||
}, [profileData, profileType, isLoading, isInitialized, getCareerStatus, getEmail, getPhone, getLocation, getSkills, isUpdatingCareerStatus]);
|
||||
|
||||
|
||||
const tryParseJsonMessage = (msg: string): string => {
|
||||
if (msg.trim().startsWith('{') && msg.trim().endsWith('}')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed.message === 'string') {
|
||||
return parsed.message;
|
||||
}
|
||||
} catch {
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
};
|
||||
|
||||
|
||||
const extractApiMessage = (err: unknown): string => {
|
||||
if (typeof err !== 'object' || err === null) return '';
|
||||
|
||||
const maybeAxiosError = err as { response?: { data?: { message?: string } } };
|
||||
if (maybeAxiosError.response?.data?.message) {
|
||||
return maybeAxiosError.response.data.message;
|
||||
}
|
||||
|
||||
if ('message' in err && typeof (err as { message?: string }).message === 'string') {
|
||||
const msg = (err as { message?: string }).message || '';
|
||||
return tryParseJsonMessage(msg);
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleProfileUpdate = async (updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto>) => {
|
||||
if (isViewOnly) { // Prevent updates if in view-only mode
|
||||
showNotification('error', 'Akses Ditolak', 'Anda tidak memiliki izin untuk mengedit profil ini.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateProfile(updates);
|
||||
showNotification('success', 'Perubahan Berhasil Disimpan');
|
||||
} catch (err) {
|
||||
console.error('Profile update error:', err);
|
||||
const apiMessage = extractApiMessage(err);
|
||||
showNotification('error', 'Gagal menyimpan perubahan', apiMessage || 'Silakan coba lagi');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionWrapper title="Career Status">
|
||||
<Select
|
||||
value={careerStatus}
|
||||
onChange={async (e) => {
|
||||
if (isUpdatingCareerStatus) return; // Prevent multiple clicks
|
||||
const newStatus = e.target.value;
|
||||
console.log('ProfileSidebar: User selected career status:', newStatus);
|
||||
setCareerStatus(newStatus);
|
||||
setIsUpdatingCareerStatus(true);
|
||||
try {
|
||||
console.log('ProfileSidebar: Updating career status on backend...');
|
||||
|
||||
const updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto> = {};
|
||||
if (profileType === 'mentor') {
|
||||
(updates as MentorUpdateRequestDto).availability_commitment = newStatus;
|
||||
} else {
|
||||
(updates as UserUpdateRequestDto).career_status = newStatus;
|
||||
}
|
||||
|
||||
await handleProfileUpdate(updates);
|
||||
} catch (error) {
|
||||
console.error('ProfileSidebar: Career status update failed:', error);
|
||||
} finally {
|
||||
setIsUpdatingCareerStatus(false);
|
||||
}
|
||||
}}
|
||||
className="w-full min-w-[200px]"
|
||||
disabled={isUpdatingCareerStatus || isViewOnly} // Disable if in view-only mode
|
||||
>
|
||||
<option value="Career Status">Career Status</option>
|
||||
<option value="Student">Student</option>
|
||||
<option value="Fresh Graduate">Fresh Graduate</option>
|
||||
<option value="Junior Developer">Junior Developer</option>
|
||||
<option value="Senior Developer">Senior Developer</option>
|
||||
<option value="Team Lead">Team Lead</option>
|
||||
<option value="Freelancer">Freelancer</option>
|
||||
</Select>
|
||||
</SectionWrapper>
|
||||
|
||||
<PersonalInfoSection
|
||||
initialContactInfo={personalInfo}
|
||||
onSave={async (newPersonalInfo) => {
|
||||
setPersonalInfo(newPersonalInfo);
|
||||
const updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto> = {
|
||||
phone_for_verification: newPersonalInfo.phone || null,
|
||||
domicile: newPersonalInfo.location || null
|
||||
};
|
||||
await handleProfileUpdate(updates);
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
isLoading={isUpdating}
|
||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||
/>
|
||||
|
||||
<SkillsSection
|
||||
initialSkills={skills}
|
||||
onSave={async (newSkills) => {
|
||||
setSkills(newSkills);
|
||||
|
||||
const updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto> = {};
|
||||
if (profileType === 'mentor') {
|
||||
(updates as MentorUpdateRequestDto).expertise = newSkills;
|
||||
} else if (profileType === 'user') {
|
||||
(updates as UserUpdateRequestDto).skills = newSkills;
|
||||
}
|
||||
await handleProfileUpdate(updates);
|
||||
}}
|
||||
showNotification={showNotification}
|
||||
isLoading={isUpdating}
|
||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
import { FC, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { BookOutlined, TrophyOutlined, ClockCircleOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
|
||||
import { For } from '@imphnen-frontend-service/utils';
|
||||
|
||||
type TabType = 'overview' | 'certificates' | 'activity' | 'mentoring';
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Overview', icon: BookOutlined },
|
||||
{ id: 'certificates', label: 'Certificates', icon: SafetyCertificateOutlined },
|
||||
{ id: 'activity', label: 'Activity', icon: ClockCircleOutlined },
|
||||
{ id: 'mentoring', label: 'Mentoring', icon: TrophyOutlined },
|
||||
] as const;
|
||||
|
||||
const certificates = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'UI/UX Design Fundamentals',
|
||||
issuer: 'Google',
|
||||
date: 'March 2024',
|
||||
image: '/image/certificate-placeholder.jpg'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Advanced Figma Techniques',
|
||||
issuer: 'Coursera',
|
||||
date: 'February 2024',
|
||||
image: '/image/certificate-placeholder.jpg'
|
||||
}
|
||||
];
|
||||
|
||||
const activities = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'mentoring',
|
||||
title: 'Completed mentoring session with Riko',
|
||||
date: '2 hours ago',
|
||||
icon: TrophyOutlined
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'certificate',
|
||||
title: 'Earned UI/UX Design Fundamentals certificate',
|
||||
date: '1 day ago',
|
||||
icon: SafetyCertificateOutlined
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'mentoring',
|
||||
title: 'Started new mentoring session',
|
||||
date: '3 days ago',
|
||||
icon: TrophyOutlined
|
||||
}
|
||||
];
|
||||
|
||||
const mentoringStats = [
|
||||
{ label: 'Total Sessions', value: '15', change: '+3 this month' },
|
||||
{ label: 'Average Rating', value: '4.8/5', change: '+0.2 from last month' },
|
||||
{ label: 'Total Hours', value: '45h', change: '+12h this month' },
|
||||
{ label: 'Active Mentees', value: '8', change: '+2 this month' }
|
||||
];
|
||||
|
||||
export const ProfileTabs: FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'overview':
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-neutral-800 mb-4">About Me</h3>
|
||||
<p className="text-neutral-600 leading-relaxed">
|
||||
Passionate UI/UX Designer with 5+ years of experience creating user-centered designs
|
||||
for web and mobile applications. I love mentoring aspiring designers and sharing
|
||||
knowledge about design thinking, prototyping, and user research methodologies.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-neutral-800 mb-4">Recent Achievements</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="p-4 bg-primary-50 rounded-lg">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<TrophyOutlined className="text-primary-500" />
|
||||
<h4 className="font-medium text-neutral-800">Top Mentor</h4>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600">Ranked #1 in UI/UX mentoring this month</p>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-lg">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<SafetyCertificateOutlined className="text-green-500" />
|
||||
<h4 className="font-medium text-neutral-800">New Certificate</h4>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600">Google UX Design Professional Certificate</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
case 'certificates':
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-neutral-800 mb-4">My Certificates</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<For data={certificates}>
|
||||
{(cert) => (
|
||||
<div key={cert.id} className="border border-neutral-200 rounded-lg p-4">
|
||||
<div className="w-full h-32 bg-neutral-100 rounded-lg mb-4 flex items-center justify-center">
|
||||
<SafetyCertificateOutlined className="text-4xl text-neutral-400" />
|
||||
</div>
|
||||
<h4 className="font-medium text-neutral-800 mb-1">{cert.title}</h4>
|
||||
<p className="text-sm text-neutral-600">{cert.issuer}</p>
|
||||
<p className="text-xs text-neutral-500 mt-2">{cert.date}</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
case 'activity':
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-neutral-800 mb-4">Recent Activity</h3>
|
||||
<div className="space-y-4">
|
||||
<For data={activities}>
|
||||
{(activity) => (
|
||||
<div key={activity.id} className="flex items-start gap-4 p-4 border border-neutral-100 rounded-lg">
|
||||
<div className="w-10 h-10 bg-primary-100 rounded-full flex items-center justify-center">
|
||||
<activity.icon className="text-primary-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-neutral-800">{activity.title}</p>
|
||||
<p className="text-sm text-neutral-500">{activity.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
case 'mentoring':
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-neutral-800 mb-4">Mentoring Statistics</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<For data={mentoringStats}>
|
||||
{(stat) => (
|
||||
<div key={stat.label} className="p-4 border border-neutral-200 rounded-lg">
|
||||
<h4 className="text-2xl font-bold text-primary-500 mb-1">{stat.value}</h4>
|
||||
<p className="font-medium text-neutral-800 mb-1">{stat.label}</p>
|
||||
<p className="text-sm text-green-600">{stat.change}</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-neutral-800 mb-4">Mentoring Feedback</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-neutral-100 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex text-yellow-400">
|
||||
<span>★★★★★</span>
|
||||
</div>
|
||||
<span className="text-sm text-neutral-600">5.0</span>
|
||||
</div>
|
||||
<p className="text-neutral-700 mb-2">
|
||||
"Excellent mentor! Very patient and explains concepts clearly."
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500">- Riko, Junior Developer</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm">
|
||||
<div className="border-b border-neutral-200">
|
||||
<nav className="flex space-x-8 px-6 py-4 overflow-x-auto">
|
||||
<For data={tabs}>
|
||||
{(tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as TabType)}
|
||||
className={`flex items-center gap-2 px-3 py-2 text-sm font-medium whitespace-nowrap border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-neutral-600 hover:text-neutral-800 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className="text-sm" />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<AnimatePresence mode="wait">
|
||||
{renderTabContent()}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { MailOutlined, PhoneOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
||||
import { PersonalInfoModal } from '../modals';
|
||||
import { SectionWrapper } from '../shared/section-wrapper';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||
|
||||
interface ContactInfo {
|
||||
email: string;
|
||||
phone: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
interface ContactInfoSectionProps {
|
||||
initialContactInfo: ContactInfo;
|
||||
onSave: (newContactInfo: ContactInfo) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const ContactInfoSection: FC<ContactInfoSectionProps> = ({
|
||||
initialContactInfo,
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading,
|
||||
}) => {
|
||||
const [isPersonalInfoModalOpen, setIsPersonalInfoModalOpen] = useState(false);
|
||||
const [contactInfo, setContactInfo] = useState<ContactInfo>(initialContactInfo);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setContactInfo(initialContactInfo);
|
||||
}, [initialContactInfo]);
|
||||
|
||||
const handleSave = async (newInfo: { email: string; phone: string; location: string }) => {
|
||||
|
||||
const newContactInfo = { email: newInfo.email, phone: newInfo.phone, location: newInfo.location };
|
||||
|
||||
|
||||
|
||||
await onSave(newContactInfo);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionWrapper
|
||||
title="Personal Informations"
|
||||
editButton={
|
||||
<EditSectionButton onClick={() => setIsPersonalInfoModalOpen(true)} />
|
||||
}
|
||||
delay={0.1}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-[#23A1EB]/10 rounded-lg flex items-center justify-center mt-0.5">
|
||||
<MailOutlined className="text-[#23A1EB] text-sm" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{contactInfo.email}</p>
|
||||
<p className="text-sm text-gray-600">Email Address</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-[#23A1EB]/10 rounded-lg flex items-center justify-center mt-0.5">
|
||||
<PhoneOutlined className="text-[#23A1EB] text-sm" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{contactInfo.phone}</p>
|
||||
<p className="text-sm text-gray-600">Phone Number</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-[#23A1EB]/10 rounded-lg flex items-center justify-center mt-0.5">
|
||||
<EnvironmentOutlined className="text-[#23A1EB] text-sm" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{contactInfo.location}</p>
|
||||
<p className="text-sm text-gray-600">Location</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PersonalInfoModal
|
||||
isOpen={isPersonalInfoModalOpen}
|
||||
onClose={() => setIsPersonalInfoModalOpen(false)}
|
||||
initialValue={contactInfo}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { DownloadOutlined } from '@ant-design/icons';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { CVModal } from '../modals';
|
||||
import { SectionWrapper } from '../shared/section-wrapper';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||
|
||||
|
||||
interface CvResumeSectionProps {
|
||||
initialFileName: string;
|
||||
fullname: string;
|
||||
onSave: (cvData: { fileName: string; fileUrl?: string }) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
}
|
||||
|
||||
export const CvResumeSection: FC<CvResumeSectionProps> = ({
|
||||
initialFileName,
|
||||
fullname,
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading = false,
|
||||
isViewOnly = false, // Default to false
|
||||
}) => {
|
||||
const [isCVModalOpen, setIsCVModalOpen] = useState(false);
|
||||
const [fileName, setFileName] = useState(initialFileName);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setFileName(initialFileName);
|
||||
}, [initialFileName]);
|
||||
|
||||
const handleSave = async (cvData: { fileName: string; fileUrl?: string }) => {
|
||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||
await onSave(cvData);
|
||||
};
|
||||
|
||||
|
||||
let displayFileName = 'Belum ada CV';
|
||||
let fileUrl = '';
|
||||
if (fileName) {
|
||||
if (fileName.startsWith('http') && fullname) {
|
||||
displayFileName = `${fullname}.pdf`;
|
||||
fileUrl = fileName;
|
||||
} else {
|
||||
displayFileName = fileName;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
if (fileUrl) {
|
||||
const link = document.createElement('a');
|
||||
link.href = fileUrl;
|
||||
link.download = displayFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionWrapper
|
||||
title="CV/Resume"
|
||||
editButton={
|
||||
!isViewOnly ? ( // Conditionally render the edit button
|
||||
<EditSectionButton
|
||||
onClick={() => setIsCVModalOpen(true)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
delay={0.3}
|
||||
>
|
||||
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div className="w-10 h-10 bg-gray-300 rounded flex items-center justify-center">
|
||||
<span className="text-gray-600 text-xs font-medium">PDF</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">{displayFileName}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
disabled={!fileUrl}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<DownloadOutlined />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CVModal
|
||||
isOpen={isCVModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||
onClose={() => setIsCVModalOpen(false)}
|
||||
initialValue={{ fileName, fileUrl: fileName }}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { DescriptionModal } from '../modals';
|
||||
import { SectionWrapper } from '../shared/section-wrapper';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||
|
||||
interface DescriptionSectionProps {
|
||||
initialDescription: string;
|
||||
onSave: (newDescription: string) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
}
|
||||
|
||||
export const DescriptionSection: FC<DescriptionSectionProps> = ({
|
||||
initialDescription,
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading = false,
|
||||
isViewOnly = false, // Default to false
|
||||
}) => {
|
||||
const [isDescriptionModalOpen, setIsDescriptionModalOpen] = useState(false);
|
||||
const [description, setDescription] = useState(initialDescription);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setDescription(initialDescription);
|
||||
}, [initialDescription]);
|
||||
|
||||
const handleSave = async (newDescription: string) => {
|
||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||
await onSave(newDescription);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionWrapper
|
||||
title="Description"
|
||||
editButton={
|
||||
!isViewOnly ? ( // Conditionally render the edit button
|
||||
<EditSectionButton
|
||||
onClick={() => setIsDescriptionModalOpen(true)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
delay={0.2}
|
||||
>
|
||||
<div className="text-gray-700 leading-relaxed whitespace-pre-wrap min-h-[150px] p-4 border border-gray-200 rounded-md bg-gray-50">
|
||||
{description}
|
||||
</div>
|
||||
|
||||
<DescriptionModal
|
||||
isOpen={isDescriptionModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||
onClose={() => setIsDescriptionModalOpen(false)}
|
||||
initialValue={description}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { EducationModal } from '../modals';
|
||||
import { SectionWrapper } from '../shared/section-wrapper';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||
|
||||
interface Education {
|
||||
id: string;
|
||||
institution: string;
|
||||
degree: string;
|
||||
field: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
interface EducationSectionProps {
|
||||
initialEducation: Education[];
|
||||
onSave: (newEducation: Education[]) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
}
|
||||
|
||||
export const EducationSection: FC<EducationSectionProps> = ({
|
||||
initialEducation,
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading = false,
|
||||
isViewOnly = false, // Default to false
|
||||
}) => {
|
||||
const [isEducationModalOpen, setIsEducationModalOpen] = useState(false);
|
||||
const [education, setEducation] = useState<Education[]>(initialEducation);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setEducation(initialEducation);
|
||||
}, [initialEducation]);
|
||||
|
||||
const handleSave = async (newEducation: Education[]) => {
|
||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||
await onSave(newEducation);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionWrapper
|
||||
title="Education"
|
||||
editButton={
|
||||
!isViewOnly ? ( // Conditionally render the edit button
|
||||
<div className="flex gap-2">
|
||||
<EditSectionButton
|
||||
onClick={() => setIsEducationModalOpen(true)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
delay={0.5}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{education.map((edu) => (
|
||||
<div key={edu.id} className="flex items-start gap-4 p-4 border border-gray-200 rounded-lg">
|
||||
<div className="w-10 h-10 bg-gray-200 rounded-full flex-shrink-0"></div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900">{edu.institution}</h4>
|
||||
<p className="text-gray-700">{edu.degree}</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
|
||||
<span>{edu.field}</span>
|
||||
<span>•</span>
|
||||
<span>{edu.period}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<EducationModal
|
||||
isOpen={isEducationModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||
onClose={() => setIsEducationModalOpen(false)}
|
||||
initialValue={education}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { ExperienceModal } from '../modals';
|
||||
import { SectionWrapper } from '../shared/section-wrapper';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||
|
||||
interface Experience {
|
||||
id: string;
|
||||
company: string;
|
||||
position: string;
|
||||
duration: string;
|
||||
period: string;
|
||||
}
|
||||
|
||||
interface ExperiencesSectionProps {
|
||||
initialExperiences: Experience[];
|
||||
onSave: (newExperiences: Experience[]) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
}
|
||||
|
||||
export const ExperiencesSection: FC<ExperiencesSectionProps> = ({
|
||||
initialExperiences,
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading = false,
|
||||
isViewOnly = false, // Default to false
|
||||
}) => {
|
||||
const [isExperienceModalOpen, setIsExperienceModalOpen] = useState(false);
|
||||
const [experiences, setExperiences] = useState<Experience[]>(initialExperiences);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setExperiences(initialExperiences);
|
||||
}, [initialExperiences]);
|
||||
|
||||
const handleSave = async (newExperiences: Experience[]) => {
|
||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||
await onSave(newExperiences);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionWrapper
|
||||
title="Experiences"
|
||||
editButton={
|
||||
!isViewOnly ? ( // Conditionally render the edit button
|
||||
<div className="flex gap-2">
|
||||
<EditSectionButton
|
||||
onClick={() => setIsExperienceModalOpen(true)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
delay={0.4}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{experiences.map((exp) => (
|
||||
<div key={exp.id} className="flex items-start gap-4 p-4 border border-gray-200 rounded-lg">
|
||||
<div className="w-10 h-10 bg-gray-200 rounded-full flex-shrink-0"></div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900">{exp.company}</h4>
|
||||
<p className="text-gray-700">{exp.position}</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
|
||||
<span>{exp.duration}</span>
|
||||
<span>•</span>
|
||||
<span>{exp.period}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ExperienceModal
|
||||
isOpen={isExperienceModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||
onClose={() => setIsExperienceModalOpen(false)}
|
||||
initialValue={experiences}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
showNotification={showNotification}
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export { SocialMediaSection } from './social-media-section';
|
||||
export { DescriptionSection } from './description-section';
|
||||
export { CvResumeSection } from './cv-resume-section';
|
||||
export { ExperiencesSection } from './experiences-section';
|
||||
export { EducationSection } from './education-section';
|
||||
export { PersonalInfoSection } from './personal-info-section';
|
||||
export { SkillsSection } from './skills-section';
|
||||
export { LanguagesSection } from './languages-section';
|
||||
@@ -0,0 +1,72 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { LanguagesModal } from '../modals';
|
||||
import { SectionWrapper } from '../shared/section-wrapper';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||
|
||||
interface Language {
|
||||
name: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
interface LanguagesSectionProps {
|
||||
initialLanguages: Language[];
|
||||
onSave: (newLanguages: Language[]) => void;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const LanguagesSection: FC<LanguagesSectionProps> = ({
|
||||
initialLanguages,
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [isLanguagesModalOpen, setIsLanguagesModalOpen] = useState(false);
|
||||
const [languages, setLanguages] = useState<Language[]>(initialLanguages);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setLanguages(initialLanguages);
|
||||
}, [initialLanguages]);
|
||||
|
||||
const handleSave = (newLanguages: Language[]) => {
|
||||
|
||||
|
||||
onSave(newLanguages);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionWrapper
|
||||
title="Languages"
|
||||
editButton={
|
||||
<EditSectionButton
|
||||
onClick={() => setIsLanguagesModalOpen(true)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
}
|
||||
delay={0.4}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{languages.map((language) => (
|
||||
<div key={language.name} className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-neutral-800">{language.name}</span>
|
||||
<span className="text-xs text-neutral-600 bg-neutral-100 px-2 py-1 rounded">
|
||||
{language.level}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<LanguagesModal
|
||||
isOpen={isLanguagesModalOpen}
|
||||
onClose={() => setIsLanguagesModalOpen(false)}
|
||||
initialValue={languages}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { MailOutlined, PhoneOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
||||
import { PersonalInfoModal } from '../modals';
|
||||
import { SectionWrapper } from '../shared/section-wrapper';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||
|
||||
interface PersonalInfo {
|
||||
email: string;
|
||||
phone: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
interface PersonalInfoSectionProps {
|
||||
initialContactInfo: PersonalInfo;
|
||||
onSave: (newContactInfo: PersonalInfo) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
}
|
||||
|
||||
export const PersonalInfoSection: FC<PersonalInfoSectionProps> = ({
|
||||
initialContactInfo,
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading = false,
|
||||
isViewOnly = false, // Default to false
|
||||
}) => {
|
||||
const [isPersonalInfoModalOpen, setIsPersonalInfoModalOpen] = useState(false);
|
||||
const [personalInfo, setPersonalInfo] = useState<PersonalInfo>(initialContactInfo);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setPersonalInfo(initialContactInfo);
|
||||
}, [initialContactInfo]);
|
||||
|
||||
const handleSave = async (newInfo: { email: string; phone: string; location: string }) => {
|
||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||
const newPersonalInfo = { email: newInfo.email, phone: newInfo.phone, location: newInfo.location };
|
||||
|
||||
await onSave(newPersonalInfo);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionWrapper
|
||||
title="Personal Informations"
|
||||
editButton={
|
||||
!isViewOnly ? ( // Conditionally render the edit button
|
||||
<EditSectionButton
|
||||
onClick={() => setIsPersonalInfoModalOpen(true)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
delay={0.1}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-[#23A1EB]/10 rounded-lg flex items-center justify-center mt-0.5">
|
||||
<MailOutlined className="text-[#23A1EB] text-sm" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{personalInfo.email}</p>
|
||||
<p className="text-sm text-gray-600">Email Address</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-[#23A1EB]/10 rounded-lg flex items-center justify-center mt-0.5">
|
||||
<PhoneOutlined className="text-[#23A1EB] text-sm" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{personalInfo.phone}</p>
|
||||
<p className="text-sm text-gray-600">Phone Number</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-[#23A1EB]/10 rounded-lg flex items-center justify-center mt-0.5">
|
||||
<EnvironmentOutlined className="text-[#23A1EB] text-sm" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{personalInfo.location}</p>
|
||||
<p className="text-sm text-gray-600">Location</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PersonalInfoModal
|
||||
isOpen={isPersonalInfoModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||
onClose={() => setIsPersonalInfoModalOpen(false)}
|
||||
initialValue={personalInfo}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { SkillsModal } from '../modals';
|
||||
import { SectionWrapper } from '../shared/section-wrapper';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||
|
||||
interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface SkillsSectionProps {
|
||||
initialSkills: string[];
|
||||
onSave: (newSkills: string[]) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
isViewOnly?: boolean; // Add isViewOnly prop
|
||||
}
|
||||
|
||||
export const SkillsSection: FC<SkillsSectionProps> = ({
|
||||
initialSkills,
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading = false,
|
||||
isViewOnly = false, // Default to false
|
||||
}) => {
|
||||
const [isSkillsModalOpen, setIsSkillsModalOpen] = useState(false);
|
||||
const [skills, setSkills] = useState<string[]>(initialSkills);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setSkills(initialSkills);
|
||||
}, [initialSkills]);
|
||||
|
||||
const handleSave = async (newSkills: Skill[]) => {
|
||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
||||
const stringSkills = newSkills.map(skill => skill.name);
|
||||
|
||||
await onSave(stringSkills);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionWrapper
|
||||
title="Skills"
|
||||
editButton={
|
||||
!isViewOnly ? ( // Conditionally render the edit button
|
||||
<EditSectionButton
|
||||
onClick={() => setIsSkillsModalOpen(true)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
delay={0.3}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{skills.map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="inline-block px-3 py-1 bg-[#23A1EB]/10 text-[#23A1EB] text-sm rounded-md border border-[#23A1EB]/20"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SkillsModal
|
||||
isOpen={isSkillsModalOpen && !isViewOnly} // Only open if not in view-only mode
|
||||
onClose={() => setIsSkillsModalOpen(false)}
|
||||
initialValue={skills.map(skill => ({ id: skill, name: skill }))}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import { SocialMediaModal } from '../modals';
|
||||
import { SectionWrapper } from '../shared/section-wrapper';
|
||||
import { NotificationType } from '../modals/notification-modal';
|
||||
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||
|
||||
interface SocialLink {
|
||||
platform: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface SocialMediaSectionProps {
|
||||
initialSocialLinks: SocialLink[];
|
||||
onSave: (newSocialLinks: SocialLink[]) => Promise<void>;
|
||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const SocialMediaSection: FC<SocialMediaSectionProps> = ({
|
||||
initialSocialLinks,
|
||||
onSave,
|
||||
showNotification,
|
||||
isLoading,
|
||||
}) => {
|
||||
const [isSocialMediaModalOpen, setIsSocialMediaModalOpen] = useState(false);
|
||||
const [socialLinks, setSocialLinks] = useState<SocialLink[]>(initialSocialLinks);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setSocialLinks(initialSocialLinks);
|
||||
}, [initialSocialLinks]);
|
||||
|
||||
const handleSave = async (newSocialLinks: SocialLink[]) => {
|
||||
|
||||
await onSave(newSocialLinks);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionWrapper
|
||||
title="Social Media"
|
||||
editButton={
|
||||
<EditSectionButton onClick={() => setIsSocialMediaModalOpen(true)} />
|
||||
}
|
||||
delay={0.1}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{socialLinks.map((link) => (
|
||||
<div key={link.platform} className="border border-gray-200 rounded-md p-4 bg-gray-50">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">{link.platform}</div>
|
||||
<div className="text-gray-900 text-sm">
|
||||
{link.value || <span className="text-gray-400 italic">{link.placeholder}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SocialMediaModal
|
||||
isOpen={isSocialMediaModalOpen}
|
||||
onClose={() => setIsSocialMediaModalOpen(false)}
|
||||
initialValue={socialLinks}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</SectionWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user