Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d4b92b357 | ||
|
|
8e7c6e6c21 | ||
|
|
6960b17c41 | ||
|
|
a281f279f8 | ||
|
|
a157e11e13 | ||
|
|
b38afd2b44 | ||
|
|
3c301ec2c4 | ||
|
|
65a6bb686c |
@@ -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 flex justify-center">
|
||||||
<div className="bg-primary-50 min-h-screen w-full flex">
|
<div className="bg-primary-50 min-h-screen w-full flex">
|
||||||
<BackofficeSidebar />
|
<BackofficeSidebar />
|
||||||
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</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 |
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { FC, useRef, useState } from 'react';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { UploadOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
interface FileUploaderProps {
|
||||||
|
accept?: string;
|
||||||
|
maxSize?: number;
|
||||||
|
onFileSelect?: (file: File) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
dragAndDrop?: boolean;
|
||||||
|
buttonText?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileUploader: FC<FileUploaderProps> = ({
|
||||||
|
accept = "*/*",
|
||||||
|
maxSize = 10 * 1024 * 1024,
|
||||||
|
onFileSelect,
|
||||||
|
isLoading = false,
|
||||||
|
className = "",
|
||||||
|
children,
|
||||||
|
dragAndDrop = false,
|
||||||
|
buttonText = "Choose File",
|
||||||
|
description = "Click to select a file"
|
||||||
|
}) => {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const validateFile = (file: File): string | null => {
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return `File size must be less than ${Math.round(maxSize / (1024 * 1024))}MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accept !== "*/*" && !accept.split(',').some(type => {
|
||||||
|
const trimmedType = type.trim();
|
||||||
|
if (trimmedType.startsWith('.')) {
|
||||||
|
return file.name.toLowerCase().endsWith(trimmedType.toLowerCase());
|
||||||
|
}
|
||||||
|
return new RegExp(trimmedType.replace('*', '.*')).exec(file.type);
|
||||||
|
})) {
|
||||||
|
return `File type not supported. Accepted types: ${accept}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (file: File) => {
|
||||||
|
const error = validateFile(file);
|
||||||
|
if (error) {
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileSelect?.(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
handleFileSelect(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) {
|
||||||
|
handleFileSelect(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerFileSelect = () => {
|
||||||
|
if (!isLoading) {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (children) {
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
||||||
|
disabled={isLoading}
|
||||||
|
title="Select file to upload"
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dragAndDrop) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="hidden"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`w-full border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||||
|
isDragging
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-300 hover:border-blue-400 hover:bg-gray-50'
|
||||||
|
} ${isLoading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={triggerFileSelect}
|
||||||
|
disabled={isLoading}
|
||||||
|
aria-label="Upload file by clicking or drag and drop"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<LoadingOutlined className="text-2xl text-blue-500 mb-2" />
|
||||||
|
<p className="text-sm text-gray-600">Uploading...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UploadOutlined className="text-2xl text-gray-400 mb-2" />
|
||||||
|
<p className="text-sm text-gray-600 mb-1">{description}</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{accept === "*/*" ? "Any file type" : accept} • Max {Math.round(maxSize / (1024 * 1024))}MB
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="hidden"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={triggerFileSelect}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<LoadingOutlined className="mr-2" />
|
||||||
|
Uploading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UploadOutlined className="mr-2" />
|
||||||
|
{buttonText}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SectionWrapper } from './section-wrapper';
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { FC, ReactElement, ReactNode } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface SectionWrapperProps {
|
||||||
|
title: string;
|
||||||
|
editButton?: ReactElement;
|
||||||
|
children: ReactNode;
|
||||||
|
delay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SectionWrapper: FC<SectionWrapperProps> = ({ title, editButton, children, delay = 0 }): ReactElement => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="bg-white rounded-lg p-6 shadow-lg hover:shadow-xl transition-shadow duration-300"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||||
|
{editButton}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FC, ReactElement, useState } from 'react';
|
||||||
|
import { ProfileForm, ProfileSidebar, ProfileHeader } from './_components';
|
||||||
|
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { NotificationModal, NotificationType } from './_components/modals/notification-modal';
|
||||||
|
import { ProfileProvider, useProfile } from './_components/contexts/profile-context';
|
||||||
|
import { EditProfileModal } from './_components/modals/edit-profile-modal';
|
||||||
|
|
||||||
|
export const Components: FC = (): ReactElement => {
|
||||||
|
return (
|
||||||
|
<ProfileProvider profileType="user">
|
||||||
|
<ProfileContent />
|
||||||
|
</ProfileProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProfileContent: FC = (): ReactElement => {
|
||||||
|
const { isLoading, error } = 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 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
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">Please try again later.</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">Your Profile</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} />
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-8 order-1">
|
||||||
|
<ProfileForm showNotification={showNotification} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-4 order-2">
|
||||||
|
<ProfileSidebar showNotification={showNotification} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NotificationModal
|
||||||
|
isOpen={notification.isOpen}
|
||||||
|
onClose={hideNotification}
|
||||||
|
type={notification.type}
|
||||||
|
title={notification.title}
|
||||||
|
message={notification.message}
|
||||||
|
header="Profile"
|
||||||
|
/>
|
||||||
|
{}
|
||||||
|
<EditProfileModal
|
||||||
|
isOpen={isEditProfileModalOpen}
|
||||||
|
onClose={closeEditProfileModal}
|
||||||
|
showNotification={showNotification}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Components;
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
export interface GoogleLoginResponse {
|
||||||
|
access_token?: string;
|
||||||
|
token?: string | {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
};
|
||||||
|
accessToken?: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
fullname: string;
|
||||||
|
avatar: string;
|
||||||
|
birthdate?: string;
|
||||||
|
gender?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
phone_number?: string;
|
||||||
|
role?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
permissions: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGoogleLogin = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { setSession } = useAuthStore();
|
||||||
|
|
||||||
|
const handleGoogleLogin = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:4099';
|
||||||
|
const callbackUrl = `${window.location.origin}/auth/google-oauth-popup`;
|
||||||
|
|
||||||
|
let authUrl;
|
||||||
|
if (baseUrl.endsWith('/v1')) {
|
||||||
|
authUrl = `${baseUrl}/auth/google/login?redirect_uri=${encodeURIComponent(callbackUrl)}`;
|
||||||
|
} else {
|
||||||
|
authUrl = `${baseUrl}/v1/auth/google/login?redirect_uri=${encodeURIComponent(callbackUrl)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const popup = window.open(
|
||||||
|
authUrl,
|
||||||
|
'google-oauth',
|
||||||
|
'width=500,height=600,scrollbars=yes,resizable=yes'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!popup) {
|
||||||
|
toast.error('Popup diblokir. Silakan aktifkan popup untuk situs ini.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
if (event.origin !== window.location.origin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data.type === 'GOOGLE_OAUTH_SUCCESS') {
|
||||||
|
const { payload } = event.data as { payload: GoogleLoginResponse };
|
||||||
|
|
||||||
|
const tokenObj = typeof payload.token === 'object' ? payload.token : null;
|
||||||
|
const accessToken = tokenObj?.access_token || payload.access_token;
|
||||||
|
const refreshToken = tokenObj?.refresh_token || payload.refresh_token;
|
||||||
|
const user = payload.user;
|
||||||
|
|
||||||
|
if (accessToken && refreshToken && user && typeof accessToken === 'string' && typeof refreshToken === 'string') {
|
||||||
|
// Convert Google user data to match TUserItem structure
|
||||||
|
const convertedUser = {
|
||||||
|
id: user.id,
|
||||||
|
avatar: user.avatar || '',
|
||||||
|
birthdate: user.birthdate || '',
|
||||||
|
email: user.email,
|
||||||
|
fullname: user.fullname,
|
||||||
|
gender: user.gender || '',
|
||||||
|
is_active: user.is_active ?? true,
|
||||||
|
phone_number: user.phone_number || '',
|
||||||
|
role: user.role || {
|
||||||
|
id: '',
|
||||||
|
name: 'User',
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
permissions: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use setSession like credential login does
|
||||||
|
setSession({
|
||||||
|
token: {
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
},
|
||||||
|
user: convertedUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success('Login berhasil!');
|
||||||
|
navigate(0); // Same as credential login
|
||||||
|
} else {
|
||||||
|
toast.error('Data login tidak lengkap');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeEventListener('message', handleMessage);
|
||||||
|
} else if (event.data.type === 'GOOGLE_OAUTH_ERROR') {
|
||||||
|
const { error } = event.data;
|
||||||
|
toast.error(`Login gagal: ${error}`);
|
||||||
|
window.removeEventListener('message', handleMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.removeEventListener('message', handleMessage);
|
||||||
|
toast.error('Login timeout. Silakan coba lagi.');
|
||||||
|
}, 300000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Google login error:', error);
|
||||||
|
toast.error('Terjadi kesalahan saat login dengan Google');
|
||||||
|
}
|
||||||
|
}, [navigate, setSession]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleGoogleLogin,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { FC, ReactElement, useEffect } from 'react';
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '@imphnen-frontend-service/utils';
|
||||||
|
import { useGoogleCallback } from '@imphnen-frontend-service/service';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export const GoogleCallbackPage: FC = (): ReactElement => {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { setSession, clearSession } = useAuthStore();
|
||||||
|
const { mutate: googleCallback } = useGoogleCallback();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleCallback = async () => {
|
||||||
|
const code = searchParams.get('code');
|
||||||
|
const state = searchParams.get('state');
|
||||||
|
const error = searchParams.get('error');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error('Google login dibatalkan atau terjadi kesalahan');
|
||||||
|
navigate('/auth/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
toast.error('Parameter login Google tidak valid');
|
||||||
|
navigate('/auth/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
googleCallback(
|
||||||
|
{ code, state },
|
||||||
|
{
|
||||||
|
onSuccess: (response) => {
|
||||||
|
if (response.token && response.user) {
|
||||||
|
setSession({
|
||||||
|
token: response.token,
|
||||||
|
user: response.user,
|
||||||
|
});
|
||||||
|
toast.success('Login Google berhasil!');
|
||||||
|
navigate('/dashboard');
|
||||||
|
} else {
|
||||||
|
throw new Error('Response data tidak valid');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Google OAuth callback error:', error);
|
||||||
|
toast.error('Login Google gagal');
|
||||||
|
clearSession();
|
||||||
|
navigate('/auth/login');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Google OAuth callback error:', error);
|
||||||
|
toast.error('Login Google gagal');
|
||||||
|
clearSession();
|
||||||
|
navigate('/auth/login');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCallback();
|
||||||
|
}, [searchParams, navigate, setSession, clearSession, googleCallback]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-500 mx-auto mb-4"></div>
|
||||||
|
<h2 className="text-2xl font-semibold text-primary-500 mb-2">
|
||||||
|
Menyelesaikan Login Google...
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Mohon tunggu sebentar, kami sedang memproses login Anda.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GoogleCallbackPage;
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { FC, ReactElement, useEffect } from 'react';
|
||||||
|
|
||||||
|
let globalIsProcessed = false;
|
||||||
|
|
||||||
|
export const GoogleOAuthPopupPage: FC = (): ReactElement => {
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (globalIsProcessed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callBackend = async (code: string, state: string) => {
|
||||||
|
if (globalIsProcessed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalIsProcessed = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:4099';
|
||||||
|
let callbackUrl;
|
||||||
|
|
||||||
|
if (baseUrl.endsWith('/v1')) {
|
||||||
|
callbackUrl = `${baseUrl}/auth/google/callback`;
|
||||||
|
} else {
|
||||||
|
callbackUrl = `${baseUrl}/v1/auth/google/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(callbackUrl);
|
||||||
|
url.searchParams.append('code', code);
|
||||||
|
url.searchParams.append('state', state);
|
||||||
|
url.searchParams.append('redirect_uri', `${window.location.origin}/auth/google-oauth-popup`);
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
mode: 'cors',
|
||||||
|
credentials: 'omit',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
window.opener?.postMessage(
|
||||||
|
{
|
||||||
|
type: 'GOOGLE_OAUTH_SUCCESS',
|
||||||
|
payload: data,
|
||||||
|
},
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
window.close();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
globalIsProcessed = false;
|
||||||
|
|
||||||
|
window.opener?.postMessage(
|
||||||
|
{
|
||||||
|
type: 'GOOGLE_OAUTH_ERROR',
|
||||||
|
error: `Failed to process OAuth callback: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
},
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOAuthResponse = () => {
|
||||||
|
const isPopup = window.opener && window.opener !== window;
|
||||||
|
|
||||||
|
const detectJsonResponse = () => {
|
||||||
|
try {
|
||||||
|
const bodyText = document.body.innerText || document.body.textContent || '';
|
||||||
|
const trimmedText = bodyText.trim();
|
||||||
|
|
||||||
|
if (trimmedText.startsWith('{') && trimmedText.endsWith('}')) {
|
||||||
|
const parsedJson = JSON.parse(trimmedText);
|
||||||
|
|
||||||
|
if (parsedJson && typeof parsedJson === 'object') {
|
||||||
|
const hasAccessToken = parsedJson.access_token || parsedJson.token || parsedJson.accessToken;
|
||||||
|
|
||||||
|
if (hasAccessToken) {
|
||||||
|
return parsedJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkOAuthParams = () => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const code = urlParams.get('code');
|
||||||
|
const state = urlParams.get('state');
|
||||||
|
const error = urlParams.get('error');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (isPopup) {
|
||||||
|
window.opener?.postMessage(
|
||||||
|
{
|
||||||
|
type: 'GOOGLE_OAUTH_ERROR',
|
||||||
|
error: error,
|
||||||
|
},
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code && state) {
|
||||||
|
if (isPopup) {
|
||||||
|
callBackend(code, state);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const immediateJson = detectJsonResponse();
|
||||||
|
if (immediateJson && isPopup) {
|
||||||
|
window.opener?.postMessage(
|
||||||
|
{
|
||||||
|
type: 'GOOGLE_OAUTH_SUCCESS',
|
||||||
|
payload: immediateJson,
|
||||||
|
},
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
window.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkOAuthParams()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 50;
|
||||||
|
|
||||||
|
const checkForJson = () => {
|
||||||
|
attempts++;
|
||||||
|
const jsonResponse = detectJsonResponse();
|
||||||
|
|
||||||
|
if (jsonResponse && isPopup) {
|
||||||
|
window.opener?.postMessage(
|
||||||
|
{
|
||||||
|
type: 'GOOGLE_OAUTH_SUCCESS',
|
||||||
|
payload: jsonResponse,
|
||||||
|
},
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
window.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempts < maxAttempts) {
|
||||||
|
setTimeout(checkForJson, 500);
|
||||||
|
} else if (isPopup) {
|
||||||
|
window.opener?.postMessage(
|
||||||
|
{
|
||||||
|
type: 'GOOGLE_OAUTH_ERROR',
|
||||||
|
error: 'Timeout waiting for response',
|
||||||
|
},
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(checkForJson, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Small delay to ensure DOM is ready
|
||||||
|
setTimeout(handleOAuthResponse, 100);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-primary-500 mx-auto mb-4"></div>
|
||||||
|
<h2 className="text-lg font-semibold text-primary-500 mb-2">
|
||||||
|
Memproses Login Google...
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
Jangan tutup jendela ini.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GoogleOAuthPopupPage;
|
||||||
@@ -2,10 +2,12 @@ import { FC, ReactElement } from 'react';
|
|||||||
import { ControlledInputField, LoginBanner } from '@imphnen-frontend-service/ui/organisms';
|
import { ControlledInputField, LoginBanner } from '@imphnen-frontend-service/ui/organisms';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { useLogin } from '../../_hooks/use-login';
|
import { useLogin } from '../../_hooks/use-login';
|
||||||
|
import { useGoogleLogin } from '../../_hooks/use-google-login';
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
export const Components: FC = (): ReactElement => {
|
export const Components: FC = (): ReactElement => {
|
||||||
const { form, onSubmit, isLoading } = useLogin();
|
const { form, onSubmit, isLoading } = useLogin();
|
||||||
|
const { handleGoogleLogin } = useGoogleLogin();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]">
|
<div className="flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]">
|
||||||
@@ -25,8 +27,8 @@ export const Components: FC = (): ReactElement => {
|
|||||||
label="Email"
|
label="Email"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full mb-2"
|
className="w-full mb-2"
|
||||||
placeholder="Masukkan email-mu, Senpai~! ✨ (Pastikan tidak typo, ya~ 😆)"
|
placeholder="Masukkan email-mu, Senpai~! ✨ (Pastikan tidak typo, ya~ 😆)"
|
||||||
name={'email'}
|
name={'email'}
|
||||||
/>
|
/>
|
||||||
<ControlledInputField
|
<ControlledInputField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -44,7 +46,7 @@ export const Components: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
<Button className="w-full" type='submit' disabled={(!form.formState.isValid || isLoading)}>Enter Isekai</Button>
|
<Button className="w-full" type='submit' disabled={(!form.formState.isValid || isLoading)}>Enter Isekai</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="flex my-3 gap-3 justify-center">
|
<div className="flex my-3 gap-3 justify-center">
|
||||||
<h5>Belum Punya akun ?</h5>
|
<h5>Belum Punya akun ?</h5>
|
||||||
<a href="/auth/register" className="text-primary-500 font-medium">
|
<a href="/auth/register" className="text-primary-500 font-medium">
|
||||||
@@ -64,6 +66,7 @@ export const Components: FC = (): ReactElement => {
|
|||||||
<Button
|
<Button
|
||||||
className="w-full my-3 text-gray-500 gap-2"
|
className="w-full my-3 text-gray-500 gap-2"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
onClick={handleGoogleLogin}
|
||||||
>
|
>
|
||||||
<p>Log In With Google</p>
|
<p>Log In With Google</p>
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -10,17 +10,17 @@ const mappingPublicRoutes = [
|
|||||||
'/auth/login',
|
'/auth/login',
|
||||||
'/auth/forgot',
|
'/auth/forgot',
|
||||||
'/auth/forgot/otp',
|
'/auth/forgot/otp',
|
||||||
'/auth/register',
|
'/auth/register',
|
||||||
'/auth/register/otp',
|
'/auth/register/otp',
|
||||||
'/auth/register/success',
|
'/auth/register/success',
|
||||||
'/auth/new-password',
|
'/auth/new-password',
|
||||||
'/auth/register-mentor',
|
'/auth/register-mentor',
|
||||||
'/auth/register-mentor/pending',
|
'/auth/register-mentor/pending',
|
||||||
'/auth/register-mentor/success',
|
'/auth/register-mentor/success',
|
||||||
|
'/auth/google-callback',
|
||||||
|
'/auth/google-oauth-popup',
|
||||||
'/resources',
|
'/resources',
|
||||||
];
|
];const mappingRoutePermissions = [
|
||||||
|
|
||||||
const mappingRoutePermissions = [
|
|
||||||
{
|
{
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
permissions: [],
|
permissions: [],
|
||||||
@@ -96,7 +96,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
const token = session_token?.token?.access_token;
|
const token = session_token?.token?.access_token;
|
||||||
const userPermissions =
|
const userPermissions =
|
||||||
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
||||||
|
|
||||||
// Allow to access the landing page without authentication
|
// Allow to access the landing page without authentication
|
||||||
// So, if the route prefix is in the mappingPublicPrefixRoutes, we return null
|
// So, if the route prefix is in the mappingPublicPrefixRoutes, we return null
|
||||||
// to indicate that we don't need to authenticate the user
|
// to indicate that we don't need to authenticate the user
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ export async function fetchPostSignin({
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { CommunitySection } from '../_components/community-section';
|
||||||
|
import { CTASection } from '../_components/cta-section';
|
||||||
|
import { HeroSection } from '../_components/hero-section';
|
||||||
|
import { TestimonialSection } from '../_components/testimonial-section';
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeroSection />
|
||||||
|
<CommunitySection />
|
||||||
|
<TestimonialSection />
|
||||||
|
<CTASection />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { CommunitySection } from './_components/community-section';
|
|
||||||
import { CTASection } from './_components/cta-section';
|
|
||||||
import { HeroSection } from './_components/hero-section';
|
|
||||||
import { TestimonialSection } from './_components/testimonial-section';
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<HeroSection />
|
|
||||||
<CommunitySection />
|
|
||||||
<TestimonialSection />
|
|
||||||
<CTASection />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { LogoSimple } from '@/app/_components/logo';
|
import { LogoSimple } from '@/app/_components/logo';
|
||||||
import NAVIGATIONS from '@/data/navigations.json';
|
import NAVIGATIONS from '@/data/navigations.json';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
import { Button } from '@components';
|
import { Button } from '@components';
|
||||||
import { cn } from '@utils';
|
import { cn } from '@utils';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
@@ -13,6 +14,7 @@ import { LuMenu, LuX } from 'react-icons/lu';
|
|||||||
export function Header() {
|
export function Header() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -30,6 +32,13 @@ export function Header() {
|
|||||||
setMobileMenuOpen(false);
|
setMobileMenuOpen(false);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
document.cookie = '__imphnen_access_token__=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
document.cookie = '__imphnen_refresh_token__=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
router.push('/');
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 w-full z-50 bg-background/70">
|
<header className="sticky top-0 w-full z-50 bg-background/70">
|
||||||
<div className="container flex h-20 items-center justify-between">
|
<div className="container flex h-20 items-center justify-between">
|
||||||
@@ -62,19 +71,31 @@ export function Header() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="hidden md:flex items-center gap-x-3">
|
<div className="hidden md:flex items-center gap-x-3">
|
||||||
<Button
|
{isAuthenticated ? (
|
||||||
onClick={() => router.push('/signin')}
|
<Button
|
||||||
className="px-5 py-2 text-sm font-medium"
|
onClick={handleLogout}
|
||||||
>
|
variant="bordered"
|
||||||
Masuk
|
className="px-5 py-2 text-sm font-medium"
|
||||||
</Button>
|
>
|
||||||
<Button
|
Keluar
|
||||||
variant="bordered"
|
</Button>
|
||||||
onClick={() => router.push('/signup')}
|
) : (
|
||||||
className="px-5 py-2 text-sm font-medium shadow-lg shadow-primary/20 hover:shadow-primary/30"
|
<>
|
||||||
>
|
<Button
|
||||||
Daftar
|
onClick={() => router.push('/signin')}
|
||||||
</Button>
|
className="px-5 py-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Masuk
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
onClick={() => router.push('/signup')}
|
||||||
|
className="px-5 py-2 text-sm font-medium shadow-lg shadow-primary/20 hover:shadow-primary/30"
|
||||||
|
>
|
||||||
|
Daftar
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -144,25 +165,40 @@ export function Header() {
|
|||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ delay: 0.2 }}
|
transition={{ delay: 0.2 }}
|
||||||
>
|
>
|
||||||
<Button
|
{isAuthenticated ? (
|
||||||
onClick={() => {
|
<Button
|
||||||
setMobileMenuOpen(false);
|
onClick={() => {
|
||||||
router.push('/signin');
|
setMobileMenuOpen(false);
|
||||||
}}
|
handleLogout();
|
||||||
className="w-full py-4 text-base"
|
}}
|
||||||
>
|
variant="bordered"
|
||||||
Masuk
|
className="w-full py-4 text-base"
|
||||||
</Button>
|
>
|
||||||
<Button
|
Keluar
|
||||||
variant="bordered"
|
</Button>
|
||||||
onClick={() => {
|
) : (
|
||||||
setMobileMenuOpen(false);
|
<>
|
||||||
router.push('/signup');
|
<Button
|
||||||
}}
|
onClick={() => {
|
||||||
className="w-full py-4 text-base shadow-lg shadow-primary/20"
|
setMobileMenuOpen(false);
|
||||||
>
|
router.push('/signin');
|
||||||
Daftar
|
}}
|
||||||
</Button>
|
className="w-full py-4 text-base"
|
||||||
|
>
|
||||||
|
Masuk
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
onClick={() => {
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
router.push('/signup');
|
||||||
|
}}
|
||||||
|
className="w-full py-4 text-base shadow-lg shadow-primary/20"
|
||||||
|
>
|
||||||
|
Daftar
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||||
|
|
||||||
|
interface HackathonContentProps {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HackathonContent({ content }: HackathonContentProps) {
|
||||||
|
return (
|
||||||
|
<div className="prose w-full max-w-none">
|
||||||
|
<MDXRemote source={content} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { HackathonContent } from '@/content/hackathons/types';
|
||||||
|
|
||||||
|
interface HackathonHeaderProps {
|
||||||
|
metadata: HackathonContent['metadata'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HackathonHeader({ metadata }: HackathonHeaderProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
className="max-w-2xl space-y-4 mb-8"
|
||||||
|
>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold">
|
||||||
|
{metadata.name}
|
||||||
|
</h1>
|
||||||
|
{metadata.theme && (
|
||||||
|
<p>
|
||||||
|
{metadata.theme}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{metadata.description && (
|
||||||
|
<p>
|
||||||
|
{metadata.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
interface HackathonHeroProps {
|
||||||
|
coverImage: string;
|
||||||
|
hackathonName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HackathonHero({ coverImage, hackathonName }: HackathonHeroProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="relative h-64 overflow-hidden"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
unoptimized
|
||||||
|
fill
|
||||||
|
src={coverImage}
|
||||||
|
alt={hackathonName}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { HackathonContent } from '@/content/hackathons/types';
|
||||||
|
import { BiBuilding } from 'react-icons/bi';
|
||||||
|
|
||||||
|
interface HackathonPartnersProps {
|
||||||
|
partners: HackathonContent['metadata']['partners'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HackathonPartners({ partners }: HackathonPartnersProps) {
|
||||||
|
if (!partners || partners.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="mx-auto w-24 h-24 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||||
|
<BiBuilding className="w-12 h-12 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">No Sponsors Yet</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Sponsor information will appear here once partnerships are announced.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">Event Sponsors</h3>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{partners.length} sponsor{partners.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Kami berterima kasih kepada sponsor-sponsor berikut yang telah mendukung hackathon ini, tanpa dukungan mereka, acara ini tidak akan mungkin terlaksana.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{partners.map((partner, idx) => (
|
||||||
|
<a
|
||||||
|
key={idx}
|
||||||
|
href={partner.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-4 p-4 bg-card rounded-lg border hover:shadow-md transition-all hover:border-primary/50"
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={partner.logo}
|
||||||
|
alt={partner.name}
|
||||||
|
className="w-12 h-12 object-contain flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="font-medium text-sm block truncate">{partner.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Sponsor</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { HackathonContent } from '@/content/hackathons/types';
|
||||||
|
|
||||||
|
interface HackathonQuickInfoProps {
|
||||||
|
metadata: HackathonContent['metadata'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HackathonQuickInfo({ metadata }: HackathonQuickInfoProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card rounded-lg p-6 border">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Quick Info</h3>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
{metadata.prize && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Prize Pool:</span>
|
||||||
|
<span className="font-medium">{metadata.prize}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{metadata.minTeamSize && metadata.maxTeamSize && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Team Size:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{metadata.minTeamSize === metadata.maxTeamSize
|
||||||
|
? `${metadata.minTeamSize} person${metadata.minTeamSize > 1 ? 's' : ''}`
|
||||||
|
: `${metadata.minTeamSize}-${metadata.maxTeamSize} people`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{metadata.submissionsCount && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Submissions:</span>
|
||||||
|
<span className="font-medium">{metadata.submissionsCount}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{metadata.partnersCount && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Partners:</span>
|
||||||
|
<span className="font-medium">{metadata.partnersCount}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { HackathonContent } from '@/content/hackathons/types';
|
||||||
|
|
||||||
|
interface HackathonRequirementsProps {
|
||||||
|
requirements: HackathonContent['metadata']['requirements'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HackathonRequirements({ requirements }: HackathonRequirementsProps) {
|
||||||
|
if (!requirements || requirements.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card rounded-lg p-6 border">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Requirements</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{requirements.map((req) => (
|
||||||
|
<div key={req.id} className="flex items-start gap-3">
|
||||||
|
<div className={`w-2 h-2 rounded-full mt-2 ${
|
||||||
|
req.mandatory ? 'bg-red-500' : 'bg-blue-500'
|
||||||
|
}`} />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm">{req.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{req.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { HackathonContent } from '@/content/hackathons/types';
|
||||||
|
import { HackathonHeader } from './HackathonHeader';
|
||||||
|
import { HackathonQuickInfo } from './HackathonQuickInfo';
|
||||||
|
import { HackathonRequirements } from './HackathonRequirements';
|
||||||
|
|
||||||
|
interface HackathonSidebarProps {
|
||||||
|
metadata: HackathonContent['metadata'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HackathonSidebar({ metadata }: HackathonSidebarProps) {
|
||||||
|
return (
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<HackathonHeader metadata={metadata} />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.3 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<HackathonQuickInfo metadata={metadata} />
|
||||||
|
<HackathonRequirements requirements={metadata.requirements} />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { HackathonContent } from '@/content/hackathons/types';
|
||||||
|
import { formatDateRange } from '@/content/hackathons/utils';
|
||||||
|
|
||||||
|
interface HackathonStatusBarProps {
|
||||||
|
metadata: HackathonContent['metadata'];
|
||||||
|
progressPercent: number | null;
|
||||||
|
daysLeftText: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HackathonStatusBar({ metadata, progressPercent, daysLeftText }: HackathonStatusBarProps) {
|
||||||
|
// Only show if we have progress and days left, and it's not too far in the past
|
||||||
|
if (!(progressPercent !== null && daysLeftText && parseInt(daysLeftText) > -5)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-muted/50 py-4">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||||
|
metadata.status === 'active' ? 'bg-green-100 text-green-800' :
|
||||||
|
metadata.status === 'upcoming' ? 'bg-blue-100 text-blue-800' :
|
||||||
|
metadata.status === 'ended' ? 'bg-gray-100 text-gray-800' :
|
||||||
|
'bg-yellow-100 text-yellow-800'
|
||||||
|
}`}>
|
||||||
|
{metadata.status?.charAt(0).toUpperCase() + metadata.status?.slice(1)}
|
||||||
|
</span>
|
||||||
|
{metadata.submissionWindow && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{formatDateRange(metadata.submissionWindow)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{daysLeftText && (
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{daysLeftText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{progressPercent !== null && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all duration-300"
|
||||||
|
style={{ width: `${progressPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { BiLinkExternal, BiLogoGithub, BiImage, BiGroup } from 'react-icons/bi';
|
||||||
|
|
||||||
|
interface Submission {
|
||||||
|
team_name: string;
|
||||||
|
project_title: string;
|
||||||
|
description: string;
|
||||||
|
repo_link: string;
|
||||||
|
screenshot?: string;
|
||||||
|
file_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HackathonSubmissionsProps {
|
||||||
|
submissions?: Submission[];
|
||||||
|
submissionsCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HackathonSubmissions({ submissions, submissionsCount }: HackathonSubmissionsProps) {
|
||||||
|
if (!submissions || submissions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="mx-auto w-24 h-24 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||||
|
<BiGroup className="w-12 h-12 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">No Submissions Yet</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Submissions will appear here once participants start submitting their projects.
|
||||||
|
</p>
|
||||||
|
{submissionsCount && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
Expected submissions: {submissionsCount}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">Project Submissions</h3>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{submissions.length} project{submissions.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{submissions.map((submission, index) => (
|
||||||
|
<div key={index} className="bg-card rounded-lg border hover:shadow-md transition-shadow">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="w-full h-40 bg-muted rounded-lg rounded-b-none overflow-hidden flex items-center justify-center">
|
||||||
|
{submission.screenshot ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={submission.screenshot}
|
||||||
|
alt={submission.project_title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||||
|
No Screenshot Available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 flex flex-col h-full">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<h4 className="font-semibold text-base">{submission.project_title}</h4>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<BiGroup className="w-4 h-4" />
|
||||||
|
<span>{submission.team_name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed grow line-clamp-6">
|
||||||
|
{submission.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Links */}
|
||||||
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
|
{submission.repo_link && (
|
||||||
|
<a
|
||||||
|
href={submission.repo_link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-1 bg-primary/10 text-primary rounded-full text-xs hover:bg-primary/20 transition-colors"
|
||||||
|
>
|
||||||
|
<BiLogoGithub className="w-3 h-3" />
|
||||||
|
Repository
|
||||||
|
<BiLinkExternal className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { HackathonContent } from '@/content/hackathons/types';
|
||||||
|
import { HackathonPartners } from './HackathonPartners';
|
||||||
|
import { HackathonSubmissions } from './HackathonSubmissions';
|
||||||
|
import { BiGroup, BiFile, BiInfoCircle } from 'react-icons/bi';
|
||||||
|
|
||||||
|
interface Submission {
|
||||||
|
team_name: string;
|
||||||
|
project_title: string;
|
||||||
|
description: string;
|
||||||
|
repo_link: string;
|
||||||
|
screenshot?: string;
|
||||||
|
file_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HackathonTabsProps {
|
||||||
|
metadata: HackathonContent['metadata'];
|
||||||
|
submissions?: Submission[];
|
||||||
|
contentElement: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HackathonTabs({ metadata, submissions, contentElement }: HackathonTabsProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState('content');
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
id: 'content',
|
||||||
|
label: 'Details',
|
||||||
|
icon: BiInfoCircle,
|
||||||
|
count: 0 // No count for content tab
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sponsors',
|
||||||
|
label: 'Sponsors',
|
||||||
|
icon: BiGroup,
|
||||||
|
count: metadata.partners?.length || 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'submissions',
|
||||||
|
label: 'Submissions',
|
||||||
|
icon: BiFile,
|
||||||
|
count: submissions?.length || metadata.submissionsCount || 0
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div className="border-b border-border">
|
||||||
|
<nav className="flex space-x-8" aria-label="Tabs">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`relative py-4 px-1 cursor-pointer font-medium text-sm flex items-center gap-2 transition-colors`}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
{tab.count > 0 && (
|
||||||
|
<span className="ml-2 bg-muted text-muted-foreground px-2 py-1 rounded-full text-xs">
|
||||||
|
{tab.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{activeTab === tab.id && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"
|
||||||
|
layoutId="activeTab"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<motion.div
|
||||||
|
key={activeTab}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
{activeTab === 'content' && contentElement}
|
||||||
|
{activeTab === 'sponsors' && (
|
||||||
|
<HackathonPartners partners={metadata.partners} />
|
||||||
|
)}
|
||||||
|
{activeTab === 'submissions' && (
|
||||||
|
<HackathonSubmissions
|
||||||
|
submissions={submissions}
|
||||||
|
submissionsCount={metadata.submissionsCount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||||
|
|
||||||
|
interface ServerHackathonContentProps {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ServerHackathonContent({ content }: ServerHackathonContentProps) {
|
||||||
|
return (
|
||||||
|
<div className="prose w-full max-w-none">
|
||||||
|
<MDXRemote source={content} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import { getHackathonBySlug, getAllHackathonSlugs } from '@/content/hackathons/content';
|
||||||
|
import { getDaysLeftText, getProgressPercent } from '@/content/hackathons/utils';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Button } from '@components';
|
||||||
|
import { BsArrowLeft } from 'react-icons/bs';
|
||||||
|
import { HackathonHero } from './_components/HackathonHero';
|
||||||
|
import { HackathonStatusBar } from './_components/HackathonStatusBar';
|
||||||
|
import { HackathonSidebar } from './_components/HackathonSidebar';
|
||||||
|
import { HackathonTabs } from './_components/HackathonTabs';
|
||||||
|
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: {
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate static params for all hackathons
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
const slugs = await getAllHackathonSlugs();
|
||||||
|
return slugs.map((slug) => ({
|
||||||
|
slug,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate metadata for each hackathon
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const hackathon = await getHackathonBySlug(resolvedParams.slug);
|
||||||
|
|
||||||
|
if (!hackathon) {
|
||||||
|
return {
|
||||||
|
title: 'Hackathon Not Found',
|
||||||
|
description: 'The requested hackathon could not be found.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { metadata } = hackathon;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: metadata.seoTitle || `${metadata.name} - IMPHNEN`,
|
||||||
|
description: metadata.seoDescription || metadata.description,
|
||||||
|
keywords: metadata.tags?.join(', '),
|
||||||
|
openGraph: {
|
||||||
|
title: metadata.name,
|
||||||
|
description: metadata.description,
|
||||||
|
images: metadata.socialImage ? [metadata.socialImage] : [metadata.cover],
|
||||||
|
type: 'website',
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: metadata.name,
|
||||||
|
description: metadata.description,
|
||||||
|
images: metadata.socialImage ? [metadata.socialImage] : [metadata.cover],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function HackathonPage({ params }: Props) {
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const hackathon = await getHackathonBySlug(resolvedParams.slug);
|
||||||
|
|
||||||
|
if (!hackathon) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { metadata, content } = hackathon;
|
||||||
|
|
||||||
|
// Return 404 if no content is found
|
||||||
|
if (!content || content.trim() === '') {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressPercent = getProgressPercent(metadata);
|
||||||
|
const daysLeftText = getDaysLeftText(metadata);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen container mx-auto px-4 py-8 bg-background">
|
||||||
|
<Link href="/hackathon">
|
||||||
|
<Button variant={'bordered'} className='mb-6 font-normal gap-2'>
|
||||||
|
<BsArrowLeft /> <span>Back to Hackathons</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<HackathonHero
|
||||||
|
coverImage={metadata.cover}
|
||||||
|
hackathonName={metadata.name}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HackathonStatusBar
|
||||||
|
metadata={metadata}
|
||||||
|
progressPercent={progressPercent}
|
||||||
|
daysLeftText={daysLeftText}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="container mx-auto px-4 py-12 flex gap-8">
|
||||||
|
{/* Sidebar only */}
|
||||||
|
<div className="max-w-xs w-full shrink-0 mx-auto lg:mx-0 lg:col-span-2">
|
||||||
|
<HackathonSidebar metadata={metadata} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs section */}
|
||||||
|
<div className="mb-12 col-span-8 lg:col-span-8">
|
||||||
|
<HackathonTabs
|
||||||
|
metadata={metadata}
|
||||||
|
submissions={metadata.submissions}
|
||||||
|
contentElement={
|
||||||
|
<div className="prose w-full max-w-none">
|
||||||
|
<MDXRemote source={content} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { HackathonSummary } from '@/content/hackathons/types';
|
||||||
|
import { hackathonSummaries } from '@/content/hackathons/index';
|
||||||
|
import {
|
||||||
|
getDaysLeftText,
|
||||||
|
getProgressPercent,
|
||||||
|
filterHackathons,
|
||||||
|
sortHackathons
|
||||||
|
} from '@/content/hackathons/utils';
|
||||||
|
|
||||||
|
const HackathonTags: React.FC<{ tags?: string[] }> = ({ tags }) => {
|
||||||
|
if (!tags || tags.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3 text-xs">
|
||||||
|
{tags.slice(0, 3).map((t) => (
|
||||||
|
<div
|
||||||
|
key={t}
|
||||||
|
className="px-3 py-[2px] rounded-md border border-primary-500/50 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<div className="w-2 h-2 bg-primary-500 rounded-full" />
|
||||||
|
{t}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RegistrationProgress: React.FC<{ hackathon: HackathonSummary }> = ({ hackathon }) => {
|
||||||
|
const status = hackathon.status?.toLowerCase().trim();
|
||||||
|
// Hide the progress bar if the status is explicitly "ended"
|
||||||
|
if (status === 'ended') return null;
|
||||||
|
|
||||||
|
const percent = getProgressPercent(hackathon);
|
||||||
|
const label = getDaysLeftText(hackathon);
|
||||||
|
if (percent === null && !label) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-6 mt-2 mb-3 flex gap-2 items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="w-full h-[0.6rem] rounded-full border border-primary-500/50 overflow-hidden"
|
||||||
|
aria-label="Registration progress"
|
||||||
|
>
|
||||||
|
<div className="h-full bg-primary rounded-full" style={{ width: `${percent ?? 0}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-primary-700 shrink-0">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const HackathonCard: React.FC<{ hackathon: HackathonSummary; idx: number }> = ({ hackathon, idx }) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="rounded-lg overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 bg-card group"
|
||||||
|
initial={{ opacity: 0, y: 40 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: idx * 0.08, type: 'spring', stiffness: 60 }}
|
||||||
|
whileHover={{ scale: 1.03, boxShadow: '0 8px 32px rgba(0,0,0,0.10)' }}
|
||||||
|
>
|
||||||
|
<Link href={`/hackathon/${hackathon.slug}`} className="block focus:outline-none relative">
|
||||||
|
<div className="h-48 bg-muted overflow-hidden">
|
||||||
|
<div className='w-full h-48 overflow-hidden object-cover'>
|
||||||
|
{/* using img tag since it simpler to control */}
|
||||||
|
<img
|
||||||
|
src={hackathon.cover}
|
||||||
|
alt={hackathon.name}
|
||||||
|
className="object-cover object-center w-full h-full group-hover:scale-105 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='relative -mt-4 bg-card rounded-lg border-2 border-white hover:border-muted transition-all duration-300'>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="text-lg font-medium mb-2 text-foreground line-clamp-2">
|
||||||
|
{hackathon.name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm mb-2 line-clamp-3">
|
||||||
|
{hackathon.description}
|
||||||
|
</p>
|
||||||
|
<HackathonTags tags={hackathon.tags} />
|
||||||
|
</div>
|
||||||
|
<RegistrationProgress hackathon={hackathon} />
|
||||||
|
{hackathon.prize && (
|
||||||
|
<div className="px-6 py-3 border-t font-medium text-lg">
|
||||||
|
<h4 className='text-muted-foreground'>Hadiah</h4>
|
||||||
|
<p>{hackathon.prize ?? '—'}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HackathonsPage() {
|
||||||
|
const [filteredItems, setFilteredItems] = React.useState<HackathonSummary[]>(hackathonSummaries);
|
||||||
|
const [searchTerm, setSearchTerm] = React.useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = React.useState<string>('all');
|
||||||
|
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let filtered = [...hackathonSummaries];
|
||||||
|
if (searchTerm) {
|
||||||
|
filtered = filterHackathons(filtered, { search: searchTerm });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
filtered = filterHackathons(filtered, { status: [statusFilter] });
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered = sortHackathons(filtered, 'registrationStart', 'desc');
|
||||||
|
setFilteredItems(filtered);
|
||||||
|
}, [searchTerm, statusFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="min-h-screen bg-background container py-10">
|
||||||
|
{/* Search and Filter Controls */}
|
||||||
|
{/*
|
||||||
|
<div className="mb-8 space-y-4">
|
||||||
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search hackathons..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="flex-1 px-4 py-2 border border-gray-400 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="px-4 py-2 border border-muted rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="upcoming">Upcoming</option>
|
||||||
|
<option value="ended">Ended</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8"
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
variants={{
|
||||||
|
visible: { transition: { staggerChildren: 0.12 } },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredItems.map((hackathon, idx) => (
|
||||||
|
<HackathonCard key={hackathon.slug || hackathon.name} hackathon={hackathon} idx={idx} />
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* No results message */}
|
||||||
|
{filteredItems.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<h3 className="text-lg font-medium text-muted-foreground mb-2">
|
||||||
|
No hackathons found
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Try adjusting your search or filter criteria
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
redirect('https://forms.gle/fNzQWSdqXoyFsf8V6');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { poppinsFont } from '@/lib/fonts';
|
|||||||
import '@/styles/globals.css';
|
import '@/styles/globals.css';
|
||||||
import { cn } from '@utils';
|
import { cn } from '@utils';
|
||||||
import { type Metadata } from 'next';
|
import { type Metadata } from 'next';
|
||||||
|
import NextTopLoader from 'nextjs-toploader';
|
||||||
import { Providers } from './_components/providers';
|
import { Providers } from './_components/providers';
|
||||||
import { Toaster } from './_components/toaster';
|
import { Toaster } from './_components/toaster';
|
||||||
|
|
||||||
@@ -18,6 +19,17 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="id" suppressHydrationWarning>
|
<html lang="id" suppressHydrationWarning>
|
||||||
<body className={cn(poppinsFont.className, 'antialiased')}>
|
<body className={cn(poppinsFont.className, 'antialiased')}>
|
||||||
|
<NextTopLoader
|
||||||
|
color="#6366f1"
|
||||||
|
initialPosition={0.08}
|
||||||
|
crawlSpeed={200}
|
||||||
|
height={3}
|
||||||
|
crawl={true}
|
||||||
|
showSpinner={true}
|
||||||
|
easing="ease"
|
||||||
|
speed={200}
|
||||||
|
shadow="0 0 10px #6366f1,0 0 5px #6366f1"
|
||||||
|
/>
|
||||||
<Providers
|
<Providers
|
||||||
attribute="class"
|
attribute="class"
|
||||||
defaultTheme="system"
|
defaultTheme="system"
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
title: "AI Agent Hackathon 2025"
|
||||||
|
description: "Hackathon AI Agent untuk Kemerdekaan Indonesia"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tentang Acara
|
||||||
|
|
||||||
|
Halo semuanya 👋, IMPHNEN dengan bangga mempersembahkan **Hackathon perdana** bertajuk **AI Agent Hackathon 2025**.
|
||||||
|
Event ini dirancang untuk menjadi ruang eksplorasi dan kolaborasi bagi para developer, mahasiswa, maupun kreator teknologi yang ingin membangun **AI Agent inovatif** dengan semangat **Kemerdekaan Indonesia** 🇮🇩.
|
||||||
|
|
||||||
|
Selama satu minggu penuh, para peserta akan bekerja dalam tim untuk menciptakan solusi berbasis AI yang memanfaatkan platform teknologi terbaru.
|
||||||
|
|
||||||
|
## 🌟 Tema Hackathon
|
||||||
|
|
||||||
|
**"AI Agent for Kemerdekaan Indonesia"**
|
||||||
|
Peserta diajak merancang agen AI yang mampu memberikan dampak positif dalam memperingati dan mengaktualisasi nilai kemerdekaan Indonesia di era digital.
|
||||||
|
|
||||||
|
## 📜 Ketentuan Peserta
|
||||||
|
|
||||||
|
1. Hackathon ini bersifat **tim-based**, dengan minimal **1 orang** dan maksimal **3 orang** per tim.
|
||||||
|
2. Peserta **wajib menggunakan ketiga platform berikut** sebagai komponen utama proyek:
|
||||||
|
- [lunos.tech](https://lunos.tech)
|
||||||
|
- [mailry.co](https://mailry.co)
|
||||||
|
- [unli.dev](https://unli.dev)
|
||||||
|
3. Semua ide dan kode yang disubmit **harus orisinal** serta dikembangkan selama periode hackathon.
|
||||||
|
4. Penggunaan API atau library pihak ketiga diperbolehkan, selama tidak melanggar hak cipta atau lisensi.
|
||||||
|
5. Plagiarisme dalam bentuk apa pun akan menyebabkan diskualifikasi.
|
||||||
|
6. Penyelenggara berhak melakukan perubahan jadwal maupun aturan dan akan mengumumkannya kepada peserta.
|
||||||
|
7. Informasi detail dapat dilihat pada **formulir pendaftaran**.
|
||||||
|
|
||||||
|
## 🏆 Hadiah
|
||||||
|
|
||||||
|
Hadiah akan diberikan kepada **3 tim terbaik**, dengan detail lebih lanjut diumumkan pada saat acara.
|
||||||
|
*(Catatan: pajak hadiah ditanggung oleh pemenang).*
|
||||||
|
|
||||||
|
## 📅 Timeline
|
||||||
|
|
||||||
|
* **Pendaftaran dibuka:** segera setelah pengumuman
|
||||||
|
* **Masa pengerjaan:** 18–24 Agustus 2025
|
||||||
|
* **Deadline submission:** Kamis, 21 Agustus 2025
|
||||||
|
* **Pengumuman pemenang:** setelah tahap penjurian selesai
|
||||||
|
|
||||||
|
## Cara Ikut (Arsip)
|
||||||
|
|
||||||
|
1. Daftar melalui tautan yang tersedia (QR code atau kolom komentar).
|
||||||
|
2. Bentuk timmu.
|
||||||
|
3. Mulai ngoding dan kembangkan ide terbaikmu.
|
||||||
|
4. Submit proyek sesuai jadwal.
|
||||||
|
|
||||||
|
## Catatan
|
||||||
|
|
||||||
|
* Event ini terbuka bagi siapa saja yang berkomitmen untuk membangun solusi kreatif.
|
||||||
|
* Jangan sia-siakan kesempatan ini untuk berkolaborasi, belajar, dan menantang dirimu.
|
||||||
|
* **Status:** Hackathon telah berakhir. Terima kasih untuk semua partisipan!
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
{
|
||||||
|
"slug": "ai-agent-hackathon-2025",
|
||||||
|
"name": "AI Agent Hackathon",
|
||||||
|
"cover": "https://cdn.andka.my.id/imphnen/534798944_122241336080178516_4647521272813012181_n.jpg",
|
||||||
|
"description": "Hackathon dalam rangka AI Agent Kemerdekaan Indonesia. Selama satu minggu, para peserta akan diminta untuk membuat proyek mereka sendiri secara online dengan bantuan lunos.tech, mailry.co, dan unli.dev.",
|
||||||
|
"theme": "AI Agent untuk Kemerdekaan Indonesia",
|
||||||
|
"status": "ended",
|
||||||
|
"prize": "Rp. 5.000.000",
|
||||||
|
"prizes": [
|
||||||
|
{
|
||||||
|
"position": "Juara 1",
|
||||||
|
"amount": "Rp. 5.000.000",
|
||||||
|
"description": "Hadiah utama untuk tim terbaik"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"AI Agent"
|
||||||
|
],
|
||||||
|
"difficulty": "intermediate",
|
||||||
|
"submissionWindow": {
|
||||||
|
"start": "2025-08-15T00:00:00.000Z",
|
||||||
|
"end": "2025-08-22T23:59:59.999Z"
|
||||||
|
},
|
||||||
|
"minTeamSize": 1,
|
||||||
|
"maxTeamSize": 3,
|
||||||
|
"partnersCount": 2,
|
||||||
|
"submissionsCount": 22,
|
||||||
|
"partners": [
|
||||||
|
{
|
||||||
|
"name": "Lunos.tech",
|
||||||
|
"logo": "https://lunos.tech/favicon.ico",
|
||||||
|
"link": "https://lunos.tech"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mailry.co",
|
||||||
|
"logo": "https://mailry.co/favicon.ico",
|
||||||
|
"link": "https://mailry.co"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Unli.dev",
|
||||||
|
"logo": "https://unli.dev/favicon.ico",
|
||||||
|
"link": "https://unli.dev"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requirements": [
|
||||||
|
{
|
||||||
|
"id": "lunos-tech",
|
||||||
|
"name": "lunos.tech",
|
||||||
|
"description": "Wajib menggunakan platform lunos.tech dalam proyek",
|
||||||
|
"mandatory": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mailry-co",
|
||||||
|
"name": "mailry.co",
|
||||||
|
"description": "Wajib menggunakan platform mailry.co dalam proyek",
|
||||||
|
"mandatory": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "unli-dev",
|
||||||
|
"name": "unli.dev",
|
||||||
|
"description": "Wajib menggunakan platform unli.dev dalam proyek",
|
||||||
|
"mandatory": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"seoTitle": "AI Agent Hackathon 2025 - IMPHNEN",
|
||||||
|
"seoDescription": "Hackathon AI Agent untuk Kemerdekaan Indonesia. Bergabunglah dengan pengembang dari seluruh Indonesia untuk membangun solusi AI yang inovatif.",
|
||||||
|
"socialImage": "https://cdn.andka.my.id/imphnen/534798944_122241336080178516_4647521272813012181_n.jpg",
|
||||||
|
"submissions": [
|
||||||
|
{
|
||||||
|
"team_name": "Lineproject",
|
||||||
|
"project_title": "LaporMerdeka",
|
||||||
|
"description": "Platform pelaporan infrastruktur publik Indonesia yang memungkinkan warga melaporkan masalah dengan cepat dan mudah untuk Indonesia yang lebih baik.",
|
||||||
|
"repo_link": "https://github.com/MANFIT7/lapormerdeka",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=1tbOJKacQGsfldr5TsWtzNKL65iCpADz2",
|
||||||
|
"file_name": "Screenshot 2025-08-22 062036 - Fafnir.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team_name": "Aliansi switch",
|
||||||
|
"project_title": "News-ai",
|
||||||
|
"description": "ai agent untuk memilah beritah hoax dengan asli",
|
||||||
|
"repo_link": "https://github.com/7FIl/News-AI",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=1IYoOB1zqL70tpxaeopdS6VtuL8hoqpPn",
|
||||||
|
"file_name": "Screenshot 2025-08-22 223626 - 7Fil.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team_name": "Sodev Sedap",
|
||||||
|
"project_title": "Sejarah Alternatif ID",
|
||||||
|
"description": "Website AI Agent yang dapat memberikan user pov bagaimana jika user ada di situasi tersebut menggunakan reka adegan dengan pendekatan teks dengan gaya novel",
|
||||||
|
"repo_link": "https://github.com/rizalkr/sejarah-alternatif-id/tree/main",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=1TMUgayvtI45gU79I-0SokQnPJP6LHQaC",
|
||||||
|
"file_name": "Screenshot 2025-08-23 115137 - Rizal Kurnia.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team_name": "Muhammad Harafsan Alhad",
|
||||||
|
"project_title": "Elysia AI Kemerdekaan Indonesia",
|
||||||
|
"description": "“Sebuah chatbot AI interaktif yang menampilkan Elysia (dari Honkai Impact) yang menjawab pertanyaan tentang Kemerdekaan Indonesia dengan gaya khas Elysia, lengkap dengan fitur kuis interaktif.",
|
||||||
|
"repo_link": "https://github.com/rafsanalhad/elysia-ai-kemerdekaan",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=1_QE59lgHNbJfuVikJ5KT0_85tbJc6pMo",
|
||||||
|
"file_name": "Screenshot 2025-08-23 131756 - Ralhad Alhad.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team_name": "RaflanGT",
|
||||||
|
"project_title": "Ecobot",
|
||||||
|
"description": "EcoBot adalah AI Agent yang hadir untuk menjawab tantangan pengelolaan sampah dan keterbatasan digitalisasi di masyarakat. Melalui WhatsApp yang akrab bagi warga, EcoBot memandu pemilahan sampah dengan analisis gambar berbasis AI sekaligus menumbuhkan kesadaran lingkungan. Kemerdekaan bukan hanya bebas dari penjajahan, tetapi juga kesadaran kolektif untuk mengelola hal-hal sederhana yang berdampak besar. Dengan langkah kecil seperti ini, desa dan masyarakat dapat mandiri secara digital, menjaga lingkungan, dan bersama-sama membawa Indonesia terus maju.",
|
||||||
|
"repo_link": "https://github.com/mycoderisyad/raflangt-ecobot",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=1lj5DMJfxSrCwyNSLIlq-GQohJHCUDU1-",
|
||||||
|
"file_name": "Screenshot 2025-08-23 223413 - MRisyad Raflan.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team_name": "Tchh Tidak Akan",
|
||||||
|
"project_title": "Merdeka Quiziz",
|
||||||
|
"description": "Merdeka Quiziz merupakan web kuis yang menggunakan tema Kemerdekaan Indonesia dengan fitur gamifikasi yang membuat kuis menjadi menyenangkan, dimana setiap kuis dibuat oleh Mera (AI) dan dipersonalisasi untuk pengguna. Selain itu di Merdeka Quiziz pengguna juga dapat membahas sejarah Indonesia bersama Mera (AI).",
|
||||||
|
"repo_link": "https://gitlab.com/personal-projects9094234/merdeka-quiziz",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=1U_UMaYABLjbO38GA9DopXebDWznFKQcI",
|
||||||
|
"file_name": "Screenshot 2025-08-24 at 09.15.06 - Khen Cahyo.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team_name": "Pengen Ikut tapi Bingung Mau Buat Apa",
|
||||||
|
"project_title": "IMMPHNEN (Ingin Menjadi Mesin Pencari Handal Namun Enggan Ngecrawl)",
|
||||||
|
"description": "Mesin pencari yang didesain untuk memerdekakan para pencari informasi dari tracker-tracker yang berlebihan (lelah bukan habis mencari A, nongol iklan A dimana-mana?). Memiliki fitur ringkasan pencarian, serta filter negatif penelusuran (judi & pornografi). Dibuat dengan LangSearch dan Lunos(ChatGPT 5.0).",
|
||||||
|
"repo_link": "https://gitlab.com/myracledev/py-search-engine",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=1xNFtvGSLfWwdA4Mx46S7bx2nmwpt5CXA",
|
||||||
|
"file_name": "{CBBB6849-BC8E-4435-9C6A-8C88C83287DF} - Mohamad Yusuf Rizaldi.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team_name": "Ayam Geprek",
|
||||||
|
"project_title": "SURA AI (Suara Rakyat)",
|
||||||
|
"description": "SIngkatnya ini itu AI yang jadi mewakili hati rakyat Indonesia (bukan dpr). Dia bukan sekadar asisten digital, kenapa? ya karena dia kritis, cerdas, dan punya selera sinis yang bikin narasi kekuasaan gampang dibongkar. Gayanya penuh satir, dan sering pakai perumpamaan yang sangat panas. Sura AI hadir untuk menantang pemikiran, membakar semangat, dan memberikan perspektif yang ngga takut ngomong jujur tentang realita sosial dan politik.",
|
||||||
|
"repo_link": "https://github.com/Roti18/sura-ai",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=1uUWGu08NimQ1qV9E-xu0h39HyoAYrclS",
|
||||||
|
"file_name": "Screenshot 2025-08-24 204538 - Roti 1.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team_name": "Fae",
|
||||||
|
"project_title": "Daily Commit",
|
||||||
|
"description": "Daily Commit adalah semacam alarm commit yang bakal ngingetin kamu kalau seharian nggak ada commit di GitHub. Tapi kalau rajin, dia juga bisa jadi cheerleader digital yang muji-muji kamu.",
|
||||||
|
"repo_link": "https://github.com/far-id/send-mail-mailry.git",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=1GAvN4RxW_gAvOfew7LbbHANEx2F3lC5y",
|
||||||
|
"file_name": "GITHUB~1.PNG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team_name": "garudaStack",
|
||||||
|
"project_title": "Tani AI",
|
||||||
|
"description": "Tani AI adalah AI agent andalan anda untuk membantu dalam perkembangan, produktifitas serta analisis untuk komoditas pertanian anda.",
|
||||||
|
"repo_link": "FE : https://github.com/Jazaniest/garuda-ai-frontend.git BE : https://github.com/Rifaldy1292/be-hackaton.git",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=173dS3v9sAJXMOxs7uY_SG-TUW9wIo_WM",
|
||||||
|
"file_name": "Tani AI - M Abdillah Aljazani.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team_name": "Roki Miftah Kamaludin",
|
||||||
|
"project_title": "Mengenang Pahlawan",
|
||||||
|
"description": "Mengenang Pahlawan adalah platform digital untuk mengenang dan mempelajari kisah pahlawan nasional Indonesia. Aplikasi ini menyajikan biografi, foto, serta informasi resmi terkait penetapan gelar pahlawan.\n\nSelain sebagai ensiklopedia digital, platform ini juga dilengkapi fitur interaktif seperti kuis edukatif, pencarian, dan poin penghargaan.",
|
||||||
|
"repo_link": "https://github.com/rokimiftah/mengenang-pahlawan",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=140c-FyndYCAOCtKChbRaENc9fgWvQwU2",
|
||||||
|
"file_name": "mengenang-pahlawan - Roki Miftah Kamaludin.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team_name": "LokerHunter",
|
||||||
|
"project_title": "LokerKerja",
|
||||||
|
"description": "Sebuah platform job matching yang memanfaatkan analisis CV atau portofolio untuk mengidentifikasi keahlian utama pengguna dan melakukan inferensi otomatis terhadap posisi pekerjaan yang paling sesuai.\n\nHasil analisis ini digunakan untuk memberikan rekomendasi daftar lowongan yang relevan dengan profil keterampilan pengguna. Selain itu, pengguna dapat berlangganan newsletter agar selalu mendapatkan informasi lowongan terbaru yang sesuai dengan hasil analisis CV mereka, yang kemudian akan dikirimkan langsung melalui email.\n\nMapping ke Sponsor\nUNLI = Digunakan untuk vision & reasoning engine dalam analisis CV/portofolio (misalnya parsing teks dari PDF/gambar, lalu inferensi posisi kerja yang cocok).\nLunos = Digunakan untuk parsing terstruktur (PDF ke JSON), normalisasi data, dan orkestrasi pipeline analisis.\nMailry = Digunakan untuk layanan email newsletter, agar pengguna bisa berlangganan update lowongan yang sesuai dengan profil keterampilannya.",
|
||||||
|
"repo_link": "https://github.com/iegl3/LokerKerja",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=1qludNU5DnNPFtzowSnB_mYkPB00Ll4Rz",
|
||||||
|
"file_name": "demo - Eagle.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team_name": "Kami Gila Roblox",
|
||||||
|
"project_title": "Pitara: Pintu Sejarah Nusantara",
|
||||||
|
"description": "Pitara adalah platform yang bertujuan untuk meningkatkan literasi sejarah dan melawan hoaks di Indonesia. Platform ini menyediakan fitur chat AI untuk belajar sejarah, AI fact-checker untuk memverifikasi berita, forum diskusi, dan fitur pembuatan artikel otomatis. Pitara juga menjaga retensi pengguna melalui newsletter mingguan.",
|
||||||
|
"repo_link": "https://github.com/JackBerck/pitara",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=1GIXZkRrRn21hpNMcip-QdGayLr8m2qCG",
|
||||||
|
"file_name": "screencapture-127-0-0-1-8000-2025-08-24-22_56_54 - Zaki Dzulfikar.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team_name": "Hidup Jokowi",
|
||||||
|
"project_title": "Historia",
|
||||||
|
"description": "Historia, sebuah platform revolusioner yang menjembatani masa lalu dengan masa kini. kami memanfaatkan kekuatan kecerdasan buatan (AI) untuk menganalisis dan memberikan narasi pada foto-foto dan dokumen bersejarah Indonesia. cukup unggah sebuah gambar, dan biarkan teknologi kami mengungkap cerita, tokoh, serta konteks di balik momen beku tersebut. mari jelajahi kembali perjuangan bangsa dengan cara yang belum pernah ada sebelumnya.",
|
||||||
|
"repo_link": "https://github.com/mybday123/historia",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=1RaMiswa7Fy5m3V1xsoODvKabEwTspu2y",
|
||||||
|
"file_name": "Historia_-_Preview - Julian Mifta Yama Fauzan.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team_name": "CORTEZA FAMILY",
|
||||||
|
"project_title": "Garuda Shield - Criminal Website Detector",
|
||||||
|
"description": "Garuda Shield - Criminal Website Detector: Adalah Web analysis berbasis Crawling yang memanfaatkan AI Untuk mendeteksi anomali pada suatu web menggunakan: LunosTech, Mailry, Unli.Dev serta Crawler Tools",
|
||||||
|
"repo_link": "https://github.com/c0rt3z4/hackathon-imphnen",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=1AKJ7pN7zUEAoJEcZFAxGVtSE582r2Hw5",
|
||||||
|
"file_name": "Capture - Calm.PNG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team_name": "Oziral",
|
||||||
|
"project_title": "Kerja Merdeka - AI Agent Pendamping Pelamar Kerja",
|
||||||
|
"description": "Kerja Merdeka – AI Agent Pendamping Pelamar Kerja adalah platform berbasis kecerdasan buatan yang membantu pencari kerja menyusun CV dan Cover Letter yang relevan, berlatih interview secara interaktif, hingga mengirimkan lamaran dalam satu alur terpadu.",
|
||||||
|
"repo_link": "frontend : https://github.com/lakhatekno/imphnen-frontend, backend: https://github.com/Contsol-dev/kerja-merdeka-be",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=1l7IOCTj1tRJTtFgq0Wq8CJ8NaDYZ-DS1",
|
||||||
|
"file_name": "Screenshot 2025-08-24 230728 - Muhammad Iqbal Ghozy.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team_name": "ak mw heketon",
|
||||||
|
"project_title": "MerdekAI",
|
||||||
|
"description": "Kita sedang mengembangkan sebuah chatbot AI versi low budget yang tetap powerful dan fungsional. Meskipun budget pembuatan murah bahkan gratis dibanding ChatGPT, fitur-fiturnya gak kalah lengkap. Chatbot ini mendukung:\n\nChat Completion (percakapan interaktif seperti ChatGPT)\n\nText-to-Voice (mengubah teks menjadi suara)\n\nImage Generation (membuat gambar dari prompt)\n\nImage Recognition (mengidentifikasi dan mendeskripsikan gambar)\n\nJadi, meskipun gak ada dana keluar, project ini dirancang supaya tetap memberikan pengalaman mirip ChatGPT dengan fitur-fitur AI kekinian ygy.",
|
||||||
|
"repo_link": "https://github.com/kevinalvarel/merdekai",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=1U24L8_4c2nM088olI7V8LaqUGcOfg1YH",
|
||||||
|
"file_name": "merdekai.my.id_ - Muhammad Kevin Alvarel.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team_name": "Er Project",
|
||||||
|
"project_title": "Agentic Merdeka",
|
||||||
|
"description": "Multi-modal AI Chat interface, dengan kombinasi beberapa capability. Diantaranya:\n\nConversation, Image Analisis, Generate Embeddings Vector, Generate voice, Dan yang terakhir Generate Gambar, bisa build character ai sendiri, select persona dll\n\nFramework:\nNextjs 15+ (app router)\n\nDatabseses:\nFirebase untuk penyimpanan chat history dan login\n\nDilengkapi proteksi CSRF, Next Middleware dan Authentikasi menggunakan mailry\n\nSEMUA ITU DAPAT DI AKSES melalui satu web interface. Ini sudah malas, JANGAN ANGGAP PROYEK INI RAJIN🗿",
|
||||||
|
"repo_link": "https://github.com/ErRickow/ai-agent-hackathon",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=1EkVWezUIXc_F_9IeM3LeX47TFDl2j2Va",
|
||||||
|
"file_name": "download - Er Rickow.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team_name": "NamamuCore",
|
||||||
|
"project_title": "Namamu - Startup Name Generator",
|
||||||
|
"description": "Namamu.web.id merupakan situs generator nama sederhana yang memudahkan brainstorming ide platform, dengan tambahan fitur pengiriman hasil ke email.",
|
||||||
|
"repo_link": "https://github.com/nooradn/namamu-name-gen",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=1EW6wBuujpHkhT1tW-_j6TklSgqvZt7wa",
|
||||||
|
"file_name": "preview - Noor Adn.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team_name": "Tim GakTau.Dev",
|
||||||
|
"project_title": "Quiz Kemerdekaan",
|
||||||
|
"description": "Sebuah aplikasi kuis interaktif berbasis AI untuk membantu pelajar dan penggemar sejarah Indonesia memahami peristiwa kemerdekaan dengan cara yang menyenangkan",
|
||||||
|
"repo_link": "https://github.com/RAYDENFLY/Quiz-Merdeka/tree/main",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=1c0A6ijx_pNCmh87g_EvlyUAAV7zAT8Li",
|
||||||
|
"file_name": "Gambar WhatsApp 2025-08-24 pukul 21.36.26_53cb7a14 - RAYDENFLY.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team_name": "Icikiwir semilir",
|
||||||
|
"project_title": "Chef AI",
|
||||||
|
"description": "chat bot untuk mendapatkan resep dari AI",
|
||||||
|
"repo_link": "https://github.com/ranggacey/chef",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=1JCz7pEfM_nF--ZsAZvQSlUonJCjTZkh6",
|
||||||
|
"file_name": "Screenshot 2025-08-24 235059 - Diablo volfir.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"team_name": "Greatvitech Team",
|
||||||
|
"project_title": "Patriotisme Quiz",
|
||||||
|
"description": "Sebuah aplikasi quiz bertema patriotisme, pengguna bisa menjawab soal - soal yang berkaitan dengan patriotisme, serta soal digenerate langsung oleh ai",
|
||||||
|
"repo_link": "frontend: https://github.com/farhanangwa12/patriot-frontend backend: https://github.com/farhanangwa12/patriot-backend",
|
||||||
|
"screenshot": "https://drive.google.com/open?id=1IAKk_ShPo51_CmqaClgv1ulXKrlf2fHA",
|
||||||
|
"file_name": "Screenshot 2025-08-24 221129 - farhan hokado.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { HackathonMetadata, HackathonContent, HackathonSummary } from './types';
|
||||||
|
import { readMDXFile } from '../shared/mdx';
|
||||||
|
import { validateHackathon } from './validation';
|
||||||
|
import { toHackathonSummary, calculateHackathonStatus, sortHackathons, filterHackathons } from './utils';
|
||||||
|
|
||||||
|
const HACKATHONS_DIR = path.join(process.cwd(), 'src/content/hackathons');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load metadata from a hackathon's metadata.json file
|
||||||
|
*/
|
||||||
|
async function loadHackathonMetadata(hackathonDir: string): Promise<HackathonMetadata | null> {
|
||||||
|
const metadataPath = path.join(hackathonDir, 'metadata.json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(metadataPath)) {
|
||||||
|
console.warn(`No metadata.json found in ${hackathonDir}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and parse JSON file
|
||||||
|
const metadataContent = fs.readFileSync(metadataPath, 'utf-8');
|
||||||
|
const metadata = JSON.parse(metadataContent) as HackathonMetadata;
|
||||||
|
|
||||||
|
// Validate the metadata
|
||||||
|
const validation = validateHackathon(metadata);
|
||||||
|
if (!validation.isValid) {
|
||||||
|
console.error(`Invalid metadata in ${hackathonDir}:`, validation.errors);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate status if not explicitly set
|
||||||
|
const finalMetadata: HackathonMetadata = {
|
||||||
|
...metadata,
|
||||||
|
status: metadata.status || calculateHackathonStatus(metadata),
|
||||||
|
contentPath: path.relative(HACKATHONS_DIR, hackathonDir),
|
||||||
|
lastModified: fs.statSync(metadataPath).mtime.toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
return finalMetadata;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error loading metadata from ${metadataPath}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load content from a hackathon's content.mdx file
|
||||||
|
*/
|
||||||
|
async function loadHackathonContent(hackathonDir: string): Promise<string | null> {
|
||||||
|
const contentPath = path.join(hackathonDir, 'content.mdx');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(contentPath)) {
|
||||||
|
console.warn(`No content.mdx found in ${hackathonDir}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mdxContent = await readMDXFile(contentPath);
|
||||||
|
return mdxContent?.content || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error loading content from ${contentPath}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all hackathon directories
|
||||||
|
*/
|
||||||
|
function getHackathonDirectories(): string[] {
|
||||||
|
if (!fs.existsSync(HACKATHONS_DIR)) {
|
||||||
|
console.warn(`Hackathons directory not found: ${HACKATHONS_DIR}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs.readdirSync(HACKATHONS_DIR, { withFileTypes: true })
|
||||||
|
.filter(dirent => dirent.isDirectory())
|
||||||
|
.map(dirent => path.join(HACKATHONS_DIR, dirent.name))
|
||||||
|
.filter(dir => {
|
||||||
|
// Only include directories that have either metadata.json or content.mdx
|
||||||
|
const hasMetadata = fs.existsSync(path.join(dir, 'metadata.json'));
|
||||||
|
const hasContent = fs.existsSync(path.join(dir, 'content.mdx'));
|
||||||
|
return hasMetadata || hasContent;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all hackathons with full content (build-time)
|
||||||
|
*/
|
||||||
|
export async function getAllHackathons(options: {
|
||||||
|
includeDrafts?: boolean;
|
||||||
|
sortBy?: 'name' | 'registrationStart' | 'status';
|
||||||
|
sortDirection?: 'asc' | 'desc';
|
||||||
|
} = {}): Promise<HackathonContent[]> {
|
||||||
|
const hackathonDirs = getHackathonDirectories();
|
||||||
|
const hackathons: HackathonContent[] = [];
|
||||||
|
|
||||||
|
for (const dir of hackathonDirs) {
|
||||||
|
const metadata = await loadHackathonMetadata(dir);
|
||||||
|
if (!metadata) continue;
|
||||||
|
|
||||||
|
// Skip drafts unless explicitly included
|
||||||
|
if (!options.includeDrafts && metadata.status === 'draft') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await loadHackathonContent(dir);
|
||||||
|
|
||||||
|
hackathons.push({
|
||||||
|
metadata,
|
||||||
|
content: content || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort if requested
|
||||||
|
if (options.sortBy) {
|
||||||
|
const summaries = hackathons.map(h => toHackathonSummary(h.metadata));
|
||||||
|
const sortedSummaries = sortHackathons(summaries, options.sortBy, options.sortDirection);
|
||||||
|
|
||||||
|
// Reorder hackathons based on sorted summaries
|
||||||
|
return sortedSummaries.map(summary => {
|
||||||
|
const hackathon = hackathons.find(h => h.metadata.slug === summary.slug);
|
||||||
|
return hackathon;
|
||||||
|
}).filter((hackathon): hackathon is HackathonContent => hackathon !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hackathons;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get hackathon summaries for listing pages (build-time)
|
||||||
|
*/
|
||||||
|
export async function getHackathonSummaries(options: {
|
||||||
|
includeDrafts?: boolean;
|
||||||
|
sortBy?: 'name' | 'registrationStart' | 'status';
|
||||||
|
sortDirection?: 'asc' | 'desc';
|
||||||
|
filters?: {
|
||||||
|
status?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
search?: string;
|
||||||
|
};
|
||||||
|
} = {}): Promise<HackathonSummary[]> {
|
||||||
|
const hackathons = await getAllHackathons({
|
||||||
|
includeDrafts: options.includeDrafts,
|
||||||
|
sortBy: options.sortBy,
|
||||||
|
sortDirection: options.sortDirection
|
||||||
|
});
|
||||||
|
|
||||||
|
let summaries = hackathons.map(h => toHackathonSummary(h.metadata));
|
||||||
|
|
||||||
|
// Apply filters if provided
|
||||||
|
if (options.filters) {
|
||||||
|
summaries = filterHackathons(summaries, options.filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
return summaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single hackathon by slug (build-time)
|
||||||
|
*/
|
||||||
|
export async function getHackathonBySlug(slug: string): Promise<HackathonContent | null> {
|
||||||
|
console.log('Fetching hackathon by slug:', slug);
|
||||||
|
const hackathonDir = path.join(HACKATHONS_DIR, slug);
|
||||||
|
console.log('Resolved hackathon directory:', hackathonDir);
|
||||||
|
|
||||||
|
if (!fs.existsSync(hackathonDir)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = await loadHackathonMetadata(hackathonDir);
|
||||||
|
if (!metadata) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await loadHackathonContent(hackathonDir);
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadata,
|
||||||
|
content: content || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all hackathon slugs (for static generation)
|
||||||
|
*/
|
||||||
|
export async function getAllHackathonSlugs(): Promise<string[]> {
|
||||||
|
const hackathons = await getAllHackathons({ includeDrafts: false });
|
||||||
|
return hackathons.map(h => h.metadata.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate static index file for runtime use
|
||||||
|
*/
|
||||||
|
export async function generateHackathonIndex(): Promise<void> {
|
||||||
|
const summaries = await getHackathonSummaries({
|
||||||
|
includeDrafts: false,
|
||||||
|
sortBy: 'registrationStart',
|
||||||
|
sortDirection: 'desc'
|
||||||
|
});
|
||||||
|
|
||||||
|
const indexContent = `// Auto-generated file - do not edit manually
|
||||||
|
// Generated on: ${new Date().toISOString()}
|
||||||
|
|
||||||
|
import { HackathonSummary } from './types';
|
||||||
|
|
||||||
|
export const hackathonSummaries: HackathonSummary[] = ${JSON.stringify(summaries, null, 2)};
|
||||||
|
|
||||||
|
export const hackathonSlugs = ${JSON.stringify(summaries.map(s => s.slug), null, 2)};
|
||||||
|
|
||||||
|
export function getHackathonSummaryBySlug(slug: string): HackathonSummary | undefined {
|
||||||
|
return hackathonSummaries.find(h => h.slug === slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveHackathons(): HackathonSummary[] {
|
||||||
|
return hackathonSummaries.filter(h => h.status === 'active');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUpcomingHackathons(): HackathonSummary[] {
|
||||||
|
return hackathonSummaries.filter(h => h.status === 'upcoming');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFeaturedHackathons(): HackathonSummary[] {
|
||||||
|
return hackathonSummaries.filter(h => (h as any).featured === true);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const indexPath = path.join(HACKATHONS_DIR, 'index.ts');
|
||||||
|
fs.writeFileSync(indexPath, indexContent, 'utf-8');
|
||||||
|
|
||||||
|
console.log(`Generated hackathon index with ${summaries.length} hackathons`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Development helper - watch for changes and regenerate index
|
||||||
|
*/
|
||||||
|
export function watchHackathonChanges(): void {
|
||||||
|
if (process.env.NODE_ENV !== 'development') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.watch(HACKATHONS_DIR, { recursive: true }, (eventType, filename) => {
|
||||||
|
if (filename && (filename.includes('metadata.json') || filename.includes('content.mdx'))) {
|
||||||
|
console.log(`Hackathon content changed: ${filename}`);
|
||||||
|
generateHackathonIndex().catch(console.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build-time optimization: precompile all hackathon content
|
||||||
|
*/
|
||||||
|
export async function precompileHackathons(): Promise<void> {
|
||||||
|
console.log('Precompiling hackathon content...');
|
||||||
|
|
||||||
|
const hackathons = await getAllHackathons({ includeDrafts: false });
|
||||||
|
|
||||||
|
// Generate the main index
|
||||||
|
await generateHackathonIndex();
|
||||||
|
|
||||||
|
// Could add more optimizations here like:
|
||||||
|
// - Image optimization
|
||||||
|
// - Content minification
|
||||||
|
// - Search index generation
|
||||||
|
|
||||||
|
console.log(`Precompiled ${hackathons.length} hackathons`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Auto-generated file - do not edit manually
|
||||||
|
// This file will be regenerated by the build process
|
||||||
|
// Generated on: 2025-09-17T00:00:00.000Z
|
||||||
|
|
||||||
|
import { HackathonSummary } from './types';
|
||||||
|
|
||||||
|
export const hackathonSummaries: HackathonSummary[] = [
|
||||||
|
{
|
||||||
|
slug: 'ai-agent-hackathon-2025',
|
||||||
|
name: 'AI Agent Hackathon',
|
||||||
|
cover: 'https://cdn.andka.my.id/imphnen/534798944_122241336080178516_4647521272813012181_n.jpg',
|
||||||
|
description: 'Hackathon dalam rangka AI Agent Kemerdekaan Indonesia. Selama satu minggu, para peserta akan diminta untuk membuat proyek mereka sendiri secara online dengan bantuan lunos.tech, mailry.co, dan unli.dev.',
|
||||||
|
prize: 'Rp. 5.000.000',
|
||||||
|
tags: ['AI Agent'],
|
||||||
|
theme: 'AI Agent untuk Kemerdekaan Indonesia',
|
||||||
|
status: 'ended',
|
||||||
|
partnersCount: 2,
|
||||||
|
submissionsCount: 22,
|
||||||
|
submissionWindow: {
|
||||||
|
start: '2025-08-15T00:00:00.000Z',
|
||||||
|
end: '2025-09-20T23:59:59.999Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const hackathonSlugs = ['ai-agent-hackathon-2025'];
|
||||||
|
|
||||||
|
export function getHackathonSummaryBySlug(slug: string): HackathonSummary | undefined {
|
||||||
|
return hackathonSummaries.find(h => h.slug === slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveHackathons(): HackathonSummary[] {
|
||||||
|
return hackathonSummaries.filter(h => h.status === 'active');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUpcomingHackathons(): HackathonSummary[] {
|
||||||
|
return hackathonSummaries.filter(h => h.status === 'upcoming');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFeaturedHackathons(): HackathonSummary[] {
|
||||||
|
return hackathonSummaries.filter(h => (h as Record<string, unknown>).featured === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEndedHackathons(): HackathonSummary[] {
|
||||||
|
return hackathonSummaries.filter(h => h.status === 'ended');
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
export interface HackathonSubmission {
|
||||||
|
team_name: string;
|
||||||
|
project_title: string;
|
||||||
|
description: string;
|
||||||
|
repo_link: string;
|
||||||
|
screenshot: string;
|
||||||
|
file_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonPartner {
|
||||||
|
name: string;
|
||||||
|
logo: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonPrize {
|
||||||
|
position: string;
|
||||||
|
amount: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonTimeWindow {
|
||||||
|
start: string; // ISO date string
|
||||||
|
end: string; // ISO date string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonJudge {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
company?: string;
|
||||||
|
avatar?: string;
|
||||||
|
bio?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonSponsor {
|
||||||
|
name: string;
|
||||||
|
logo: string;
|
||||||
|
link: string;
|
||||||
|
tier: 'title' | 'platinum' | 'gold' | 'silver' | 'bronze';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonRequirement {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
mandatory: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonMetadata {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
cover: string;
|
||||||
|
description?: string;
|
||||||
|
theme?: string;
|
||||||
|
status: 'draft' | 'upcoming' | 'active' | 'ended';
|
||||||
|
|
||||||
|
// Prizes and competition details
|
||||||
|
prize?: string; // Main prize display text
|
||||||
|
prizes?: HackathonPrize[];
|
||||||
|
|
||||||
|
// Tags and categorization
|
||||||
|
tags?: string[];
|
||||||
|
difficulty?: 'beginner' | 'intermediate' | 'advanced';
|
||||||
|
|
||||||
|
// Time windows
|
||||||
|
registrationStart?: string;
|
||||||
|
registrationEnd?: string;
|
||||||
|
submissionWindow?: HackathonTimeWindow;
|
||||||
|
judgingWindow?: HackathonTimeWindow;
|
||||||
|
|
||||||
|
// Participation
|
||||||
|
partnersCount?: number;
|
||||||
|
submissionsCount?: number;
|
||||||
|
maxTeamSize?: number;
|
||||||
|
minTeamSize?: number;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
partners?: HackathonPartner[];
|
||||||
|
submissions?: HackathonSubmission[];
|
||||||
|
judges?: HackathonJudge[];
|
||||||
|
sponsors?: HackathonSponsor[];
|
||||||
|
requirements?: HackathonRequirement[];
|
||||||
|
|
||||||
|
// Content metadata
|
||||||
|
contentPath?: string;
|
||||||
|
lastModified?: string;
|
||||||
|
featured?: boolean;
|
||||||
|
|
||||||
|
// SEO and social
|
||||||
|
seoTitle?: string;
|
||||||
|
seoDescription?: string;
|
||||||
|
socialImage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonContent {
|
||||||
|
metadata: HackathonMetadata;
|
||||||
|
content: string; // MDX content as string
|
||||||
|
compiledContent?: React.ComponentType; // Compiled MDX component
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonSummary {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
cover: string;
|
||||||
|
description?: string;
|
||||||
|
prize?: string;
|
||||||
|
tags?: string[];
|
||||||
|
theme?: string;
|
||||||
|
status?: string;
|
||||||
|
partnersCount?: number;
|
||||||
|
submissionsCount?: number;
|
||||||
|
registrationStart?: string;
|
||||||
|
registrationEnd?: string;
|
||||||
|
submissionWindow?: HackathonTimeWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonApiResponse {
|
||||||
|
data: HackathonSummary[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonFilterOptions {
|
||||||
|
status?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
difficulty?: string[];
|
||||||
|
featured?: boolean;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonSortOptions {
|
||||||
|
field: 'name' | 'registrationStart' | 'registrationEnd' | 'status' | 'featured';
|
||||||
|
direction: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HackathonStatus = 'draft' | 'upcoming' | 'active' | 'ended';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user