Compare commits

...
Author SHA1 Message Date
xirf 4d4b92b357 feat(landing): login register 2025-09-21 15:42:58 +07:00
xirf 8e7c6e6c21 feat: update hackathon data and enhance components
- Removed existing submissions data from hackathons.json.
- Added new components for Hackathon details including HackathonContent, HackathonHeader, HackathonHero, HackathonPartners, HackathonQuickInfo, HackathonRequirements, HackathonSidebar, HackathonStatusBar, HackathonSubmissions, HackathonTabs, and ServerHackathonContent.
- Integrated framer-motion for animations in Hackathon components.
- Updated package.json and package-lock.json to include nextjs-toploader dependency.
2025-09-17 02:36:02 +07:00
xirf 6960b17c41 feat(WIP): hackatons management and pages 2025-09-17 01:26:05 +07:00
Asep Haryana SaputraandGitHub a281f279f8 full json (#56) 2025-08-27 19:28:40 +07:00
Asep Haryana SaputraandGitHub a157e11e13 hackatons (#55)
* feat(hackathon): add hackathon page static

* feat(hackathon): enhance animation effects on hackathon cards

* feat(hackathon): update hackathon screenshots and add new projects
2025-08-24 22:57:03 +07:00
Muhamad RijalandGitHub b38afd2b44 Feat/backoffice dimentorin (#53)
* feat(backoffice): slicing backoffice dimentorin

* feat(backoffice): slicing backoffice dimentorin - user management

* feat(backoffice): slicing backoffice dimentorin - modal detail user management

* feat(backoffice): slicing backoffice dimentorin - session management

* feat(backoffice): slicing backoffice dimentorin - modal detail session

* feat(backoffice): slicing backoffice dimentorin - modal detail session

* feat(backoffice): slicing backoffice dimentorin - setting page

* feat(backoffice): slicing backoffice dimentorin - content & roadmap

* feat(backoffice): slicing backoffice dimentorin - feedback & review
2025-08-20 21:58:28 +07:00
Asep Haryana SaputraandGitHub 3c301ec2c4 feat(auth): add Google authentication endpoints and hooks (#51)
- Implemented `getGoogleAuthUrl` and `postGoogleCallback` in auth API.
- Added corresponding hooks `useGoogleAuth` and `useGoogleCallback` for handling Google authentication.

feat(api): enhance API structure with mentors and upload services

- Created `mentors` and `upload` API modules with respective services for managing mentor details and file uploads.
- Added session management utilities to handle authentication tokens in cookies.

feat(users): expand user service with detailed user management

- Enhanced user service to include detailed user information and update capabilities.
- Added hooks for fetching and updating user data.

feat(hooks): introduce hooks for mentors and upload functionalities

- Added hooks for fetching mentor details and updating mentor information.
- Implemented hooks for file upload, avatar upload, and CV upload with validation.

refactor(types): organize and extend type definitions

- Introduced new types for mentors and enhanced existing user types.
- Re-exported user-related types for better accessibility.

chore: update index files to include new services and hooks

- Updated index files to export new services and hooks for mentors and uploads.
2025-08-18 12:50:32 +07:00
Naufal AzmiandGitHub 65a6bb686c Redirect (#52)
* feat: responsive

* fix: success register

* feat: auth login responsive && auth login implementation

* fix: login page button disabled & register page

* fix: conflict

* fix: button disabled when loading

* feat: register and otp integration

* Update middleware.ts

* feat: OTP Resend

* fix: minor

* stash

* feat: mentor register pages

* feat: mentor status page pending and success

* fix: conflict
2025-08-18 12:49:33 +07:00
Muhamad RijalandGitHub f3a19fa460 Feat/dimentorin landing page (#49)
* feat(dimentorin): slicing landing page menu mentoring

* feat(dimentorin): slicing landing page detail mentor

* feat(dimentorin): slicing modal booking mentor

* feat(dimentorin): slicing landing - resources page

* feat(dimentorin): slicing landing - articles page

* feat(dimentorin): slicing landing - detail articles page
2025-07-28 23:26:22 +07:00
177 changed files with 15953 additions and 715 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(npm run typecheck:*)"
],
"deny": [],
"ask": []
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -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>
)
}
@@ -0,0 +1,36 @@
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
type ChartProps = {
name: string
activeUser: number
activeSession: number
}
const chartData: ChartProps[] = [
{ name: "2014", activeUser: 0, activeSession: 0 },
{ name: "2015", activeUser: 15, activeSession: 25 },
{ name: "2016", activeUser: 30, activeSession: 40 },
{ name: "2017", activeUser: 45, activeSession: 55 },
{ name: "2018", activeUser: 60, activeSession: 70 },
{ name: "2019", activeUser: 75, activeSession: 85 },
{ name: "2020", activeUser: 90, activeSession: 95 },
{ name: "2021", activeUser: 85, activeSession: 80 },
{ name: "2022", activeUser: 95, activeSession: 90 },
{ name: "2023", activeUser: 100, activeSession: 100 },
]
export const UserGrowthChart = () => {
return (
<ResponsiveContainer width="100%" height={320}>
<LineChart data={chartData} width={500} height={320} margin={{ left: -32 }}>
<CartesianGrid />
<XAxis dataKey="name" />
<YAxis tickCount={10} />
<Tooltip />
<Legend />
<Line dataKey="activeUser" stroke="#23A1EB" />
<Line dataKey="activeSession" stroke="#0877C1" />
</LineChart>
</ResponsiveContainer>
)
}
@@ -0,0 +1,115 @@
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { BackofficeWrapper } from "@imphnen-frontend-service/ui/organisms";
import { For } from "@imphnen-frontend-service/utils";
import { ReactElement } from "react";
import { UserGrowthChart } from "./_components/chart/user-growth";
import { SessionStatusChart } from "./_components/chart/session-status";
const Overview = () => {
return (
<div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">0</h3>
<p className="text-neutral-400 text-p3">Total Users</p>
</div>
)
}
export default function Components(): ReactElement {
return (
<BackofficeWrapper title="Dimentorin.dev">
<h1 className="text-p1 font-semibold text-neutral-700 mb-5">Overview</h1>
<div className="space-y-14">
<div>
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
Overview
</Button>
<div className="grid grid-cols-5 gap-5">
<For data={Array.from({ length: 5 })}>
{(_, index) => <Overview key={index} />}
</For>
</div>
</div>
<div>
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
Trends & Analytics
</Button>
<div className="grid grid-cols-7 gap-x-5">
<div className="bg-white px-6 py-4 rounded-lg col-span-5">
<div className="flex items-center justify-between mb-7">
<h2 className="font-semibold text-p3 text-neutral-700">User Growth</h2>
<div></div>
</div>
<UserGrowthChart />
</div>
<div className="bg-white px-6 py-4 rounded-lg col-span-2">
<h2 className="font-semibold text-p3 text-neutral-700 mb-7">Session Status</h2>
<SessionStatusChart />
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-x-5">
<div className="bg-white px-7 py-4 rounded-lg">
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top 5 Mentors</h2>
<div>
<table className="w-full">
<thead>
<tr className="text-label1 bg-primary-50 text-left rounded-full">
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
<th className="font-medium py-4 px-5 w-3/5">Nama Lengkap</th>
<th className="font-medium py-4 px-5 rounded-r-lg">Avg Rating</th>
</tr>
</thead>
<tbody>
<For data={Array.from({ length: 5 })}>
{(_, index) => (
<tr key={index} className="shadow rounded-lg">
<td className="py-4 px-5">{index + 1}</td>
<td className="py-4 px-5">Mursid Al-Catraz</td>
<td className="py-4 px-5">4.9</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
<div className="bg-white px-6 py-4 rounded-lg">
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top Booked Mentoring Topics</h2>
<div>
<table className="w-full">
<thead>
<tr className="text-label1 bg-primary-50 text-left font-medium">
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
<th className="font-medium py-4 px-5 w-3/5">Nama Lengkap</th>
<th className="font-medium py-4 px-5 rounded-r-lg">Total Sesi</th>
</tr>
</thead>
<tbody>
<For data={Array.from({ length: 5 })}>
{(_, index) => (
<tr key={index} className="shadow rounded-lg">
<td className="py-4 px-5">{index + 1}</td>
<td className="py-4 px-5">Mursid Al-Catraz</td>
<td className="py-4 px-5">1000</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
</div>
</div>
</BackofficeWrapper>
)
}
@@ -0,0 +1,181 @@
import { SearchOutlined } from "@ant-design/icons";
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
import { cn, For } from "@imphnen-frontend-service/utils";
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
import { ReactElement, useState } from "react"
const TABS = {
MENTORING: 'Mentoring',
PLATFORM: 'Platform'
} as const
type Tabs = typeof TABS[keyof typeof TABS]
type FeedbackStatus = 'done' | 'todo';
interface FeedbackType {
id: number
name: string
email: string
rating: number
status: FeedbackStatus
}
const mockData: FeedbackType[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
name: i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana',
email: 'fullname23@gmail.com',
rating: 4.5,
status: i % 2 === 0 ? 'done' : 'todo',
}))
export default function Components(): ReactElement {
const [activeTab, setActiveTab] = useState<Tabs>(TABS.MENTORING)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const columns: ColumnDef<FeedbackType>[] = [
{
id: 'select',
meta: { cellClassName: cn("w-20") },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'name',
header: 'Name',
accessorKey: 'name',
},
{
id: 'email',
header: 'Email',
accessorKey: 'email',
},
{
id: 'rating',
header: 'Rating',
accessorKey: 'rating',
},
{
id: 'status',
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<FeedbackStatus, string> = {
done: 'bg-success-200 text-success-500',
todo: 'bg-primary-200 text-primary-500',
};
const statusText: Record<FeedbackStatus, string> = {
done: 'Done',
todo: 'To Do',
};
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
},
{
header: 'Action',
meta: { cellClassName: cn("w-72") },
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation()
}}
className="flex items-center gap-2 w-max"
>
<SearchOutlined className="text-[16px]" /> Lihat Feedback
</Button>
),
},
]
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<BackofficeWrapper title="Dimentorin.dev">
<div className="mb-8 flex justify-between items-center">
<h1 className="text-p1 font-semibold text-neutral-700">Feedback</h1>
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
<For data={Object.values(TABS)}>
{(tab) => (
<Button
key={tab}
variant="text"
className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
onClick={() => setActiveTab(tab)}
>
{tab}
</Button>
)}
</For>
</div>
</div>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-5 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama mentor/mentee"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<Select>
<option selected disabled>Rating</option>
<option value="4.5">4.5</option>
<option value="5">5</option>
</Select>
<Select>
<option selected disabled>Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</Select>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</section>
</BackofficeWrapper>
);
}
@@ -7,7 +7,7 @@ export const AppLayout: FC = (): ReactElement => {
<div className="bg-primary-50 min-h-screen flex justify-center">
<div className="bg-primary-50 min-h-screen w-full flex">
<BackofficeSidebar />
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
<div className="flex-1 overflow-auto">
<Outlet />
</div>
</div>
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -0,0 +1,5 @@
import { ModalProps } from "../type";
export interface ModalDetailUserProps extends ModalProps {
userId?: number | null
}
@@ -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>
)
}
@@ -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: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -3,7 +3,7 @@ import { motion } from 'framer-motion'
export function HeroSection() {
return (
<section className="relative w-full py-20 md:py-28 lg:py-32">
<section className="relative w-full z-0 py-20 md:py-28 lg:py-32">
{/* Background gradients and shapes */}
<div className="absolute inset-0 -z-10 overflow-x-clip hidden md:block">
<motion.div
@@ -2,8 +2,8 @@ import { CloseOutlined, MenuOutlined } from "@ant-design/icons";
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { cn, For, Show } from "@imphnen-frontend-service/utils";
import { motion, useMotionValueEvent, useScroll, Variants } from "framer-motion";
import { FC, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { FC, useMemo, useState } from "react";
import { Link, NavLink, useLocation } from "react-router-dom";
const MENUS: { label: string; href: string }[] = [
{ label: "Home", href: "/" },
@@ -30,8 +30,15 @@ export const Header: FC = () => {
else setShow(true)
})
const isActive = useMemo(() => {
return (path: string) => {
if (path === '/' && location.pathname !== '/') return false
return location.pathname.includes(path)
}
}, [location.pathname]);
return (
<div className="w-full px-8 pt-8 top-0 z-50 md:px-[60px] md:pt-[60px] lg:px-20 sticky overflow-hidden">
<div className={cn("w-full px-8 pt-8 top-0 z-50 md:px-[60px] md:pt-[60px] lg:px-20 sticky", !show && "overflow-hidden")}>
<motion.header
className="bg-white shadow-lg rounded-lg h-12 flex justify-between w-full max-w-7xl xl:mx-auto md:h-[60px] lg:h-[71px]"
aria-roledescription="nav"
@@ -63,18 +70,22 @@ export const Header: FC = () => {
variant="text"
className={cn(
"px-10 py-1.5 text-neutral-300 hover:text-neutral-400 lg:px-2.5 lg:py-2",
location.pathname === menu.href && "text-primary-500 hover:text-primary-500"
isActive(menu.href) && "text-primary-500 hover:text-primary-500"
)}
>
<Link to={menu.href}>{menu.label}</Link>
<NavLink to={menu.href}>{menu.label}</NavLink>
</Button>
)}
</For>
<Button type="button" className="px-12 py-1 md:hidden">Login</Button>
<Link to="/auth/login">
<Button type="button" className="px-12 py-1 lg:hidden">Login</Button>
</Link>
</nav>
<div>
<Button type="button" className="px-5 py-2 hidden lg:block">Login</Button>
<Link to="/auth/login">
<Button type="button" className="px-5 py-2 hidden lg:block">Login</Button>
</Link>
<Button
type="button"
variant="text"
@@ -0,0 +1,88 @@
import { Icon } from "@iconify/react";
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { cn } from "@imphnen-frontend-service/utils";
import { motion } from "framer-motion";
export default function DetailArticle() {
return (
<main>
<motion.section
className="w-full p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, ease: "easeInOut" }}
>
<div className="w-full max-w-[900px] mx-auto mb-8 md:mb-[60px]">
<div className="mb-10 flex items-center justify-between">
<Button
type="button"
variant="text"
className="flex gap-x-2 text-[10px] px-2.5 py-2 md:text-[15px]"
>
<Icon icon="material-symbols:arrow-back-rounded" />
<span>Back</span>
</Button>
<Button
type="button"
variant="text"
className="flex gap-x-2 text-[10px] px-2.5 py-2 md:text-[15px]"
>
<span>Read Next</span>
<Icon icon="material-symbols:arrow-forward-rounded" />
</Button>
</div>
<div className="text-center leading-tight">
<h1
className="text-[23px] text-neutral-800 font-semibold mb-6 md:text-[37px] md:mb-10 xl:text-[46px]"
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</h1>
<p
className="text-[15px] font-semibold text-neutral-800 mb-2 md:text-[19px] md:mb-1 xl:text-[23px] xl:mb-2"
>
Writer&rsquo;s Name
</p>
<p className="text-xs text-neutral-600 mb-6 md:text-[15px] xl:text-[19px]">
14 March 2025
</p>
<div
className="text-[10px] text-primary-600 font-medium bg-primary-200 px-3 py-1 rounded-4xl w-max mx-auto md:text-xs xl:text-[15px]"
>
5 min read
</div>
</div>
</div>
<div className="w-full max-w-[900px] mx-auto">
<div className="mb-8 w-full h-auto aspect-16/10 overflow-hidden md:aspect-31/16 md:mb-[60px] xl:aspect-45/26">
<img src="/image/59de8a682dfbe42455613ea07477ff3e30532c1f.webp" alt="Cover Article" className="size-full object-cover" />
</div>
<div
className={cn(
"text-balance text-[15px] font-medium text-neutral-600 space-y-8",
"md:space-y-12 md:text-[19px] xl:text-[23px] xl:max-w-[780px] xl:mx-auto",
)}
>
<h2 className="text-[19px] text-neutral-800 font-semibold leading-tight md:text-[23px] xl:text-[29px]">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus.
</h2>
<p className="leading-tight">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
</p>
<img src="/image/code-article.webp" alt="Code" className="w-full h-auto mb-12" />
<p className="leading-tight">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
</p>
<p className="leading-tight">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
</p>
</div>
</div>
</motion.section>
</main>
)
}
@@ -0,0 +1,74 @@
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { cn, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react"
import { Link } from "react-router-dom"
export type ArticleCardProps = {
variant?: "default" | "recent" | "featured"
}
export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default' }) => {
return (
<div
className={cn(
variant === "recent" && "md:grid md:grid-cols-2 md:gap-7 xl:flex xl:items-center",
)}
>
<div
className={cn(
"w-full h-auto aspect-16/10 overflow-hidden mb-8 md:h-60 md:w-full xl:aspect-auto",
variant === "featured" && "md:h-[300px]",
variant === "recent" && "md:h-auto md:w-full md:aspect-15/7 md:mb-0 xl:aspect-square xl:w-[162px] xl:h-auto",
)}
>
<img
src="/image/59de8a682dfbe42455613ea07477ff3e30532c1f.webp"
alt="Cover's Article"
className={cn("w-full object-cover", variant === "recent" && "h-full")}
/>
</div>
<div className={cn("leading-tight", variant === "recent" && "xl:flex-1")}>
<div className="w-max bg-primary-200 text-primary-600 px-3 py-1 text-xs font-medium rounded-4xl mb-4 xl:mb-[15px]">
5 min read
</div>
<Link
to="/articles/detail"
className={cn(
"text-[15px] text-neutral-800 font-semibold mb-4 line-clamp-2 md:text-[19px] xl:mb-[23px]",
variant === "featured" && "text-[19px] md:text-[23px]",
variant === "recent" && "mb-7 xl:text-[27px]"
)}
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</Link>
<Show condition={variant !== "recent"}>
<p
className={cn(
"text-xs text-neutral-600 mb-6 md:text-[15px] md:mb-8 xl:text-[19px]",
variant === "featured" && "text-[15px] md:text-[19px] md:mb-6",
)}
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris.
</p>
</Show>
<div
className={cn(
"flex justify-between items-center text-xs text-neutral-600 md:text-[15px] xl:text-[19px]",
variant === "default" && "mb-4",
variant === "featured" && "text-[15px] md:text-[19px] xl:text-base",
)}
>
<p>Writers Name</p>
<p>14 March 2025</p>
</div>
<Show condition={variant === "default"}>
<Button type="button" size="sm" className="px-2.5 py-2">
Baca Artikel
</Button>
</Show>
</div>
</div>
)
}
@@ -0,0 +1,122 @@
import { ReactElement, useRef, useState } from "react";
import { ArticleCard } from "./_components/card/article";
import { cn, For } from "@imphnen-frontend-service/utils";
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { motion, useInView, Variants } from "framer-motion";
const CATEGORIES = ['UI/UX Design', 'Software/Web Dev', 'Data & AI', 'Cloud & DevOps', 'Cybersecurity', 'IT & Network', 'Project Management', 'QA & Testing'] as const
type Category = typeof CATEGORIES[number]
export default function Components(): ReactElement {
const [activeTab, setActiveTab] = useState<Category>('UI/UX Design')
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}
const childVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
return (
<main ref={ref}>
<motion.section
className="w-full p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20"
variants={containerVariants}
initial="hidden"
animate={isInView ? "visible" : "hidden"}
>
<div className="max-w-7xl mx-auto mb-8 md:mb-20">
<motion.h1
className="text-[23px] font-semibold text-neutral-800 mb-9 md:text-[29px] xl:text-[46px] xl:font-bold"
variants={childVariants}
>
Featuring Articles
</motion.h1>
<div className="grid gap-12 xl:grid-cols-2">
<motion.div variants={childVariants}>
<ArticleCard variant="featured" />
</motion.div>
<div>
<motion.h1
className="text-[19px] text-neutral-800 font-semibold mb-10 md:text-[29px] xl:text-[37px] xl:mb-12"
variants={childVariants}
>
Recent Articles
</motion.h1>
<div className="grid gap-10">
<For data={Array.from({ length: 3 })}>
{(_, index) => (
<motion.div key={index} variants={childVariants}>
<ArticleCard variant="recent" />
</motion.div>
)}
</For>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto">
<motion.h1
className="text-[19px] text-neutral-800 font-semibold mb-4 md:text-[23px] xl:text-[29px]"
variants={childVariants}
>
Category
</motion.h1>
<div className="scrollbar-hide mb-8 w-full overflow-auto mx-auto">
<motion.div className="min-w-max w-auto flex gap-x-3" variants={childVariants}>
<For data={CATEGORIES}>
{(category) => (
<Button
key={category}
type="button"
variant="text"
onClick={() => setActiveTab(category)}
className={cn(
"relative text-[10px] text-neutral-400 font-medium px-3 py-2 rounded-4xl md:text-xs md:font-semibold xl:text-[15px]",
"before:w-0 before:absolute before:h-0.5 before:mx-auto before:inset-x-0 before:bg-primary-500 hover:before:w-full before:bottom-0 before:left-0 before:transition-all before:duration-300",
activeTab === category && "text-primary-500 before:w-full"
)}
>
{category}
</Button>
)}
</For>
</motion.div>
</div>
<div className="grid gap-8 xl:grid-cols-3">
<For data={Array.from({ length: 3 })}>
{(_, index) => (
<motion.div key={index} variants={childVariants}>
<ArticleCard />
</motion.div>
)}
</For>
</div>
</div>
</motion.section>
</main>
)
}
+1 -1
View File
@@ -6,7 +6,7 @@ export default function Layout() {
return (
<div className="flex min-h-screen flex-col bg-primary-50">
<Header />
<main className="flex-1 z-0">
<main className="flex-1">
<Outlet />
</main>
<Footer />
@@ -0,0 +1,163 @@
import { CloseOutlined } from "@ant-design/icons"
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { AnimatePresence, motion } from "framer-motion"
import { FC, useCallback, useEffect, useState } from "react"
import { TopicStep } from "./steps/topic"
import { cn, Show } from "@imphnen-frontend-service/utils"
import { ScheduleStep } from "./steps/schedule"
import { ProfileStep } from "./steps/profile"
import { QrisPaymentStep } from "./steps/qris-payement"
import { VAPaymentStep } from "./steps/va-payment"
import { SuccessStep } from "./steps/success"
import { PaymentStep } from "./steps/payment"
const STEPS = ['topic', 'schedule', 'profile', 'payment', 'qr-payment', 'va-payment', 'success'] as const
type Step = typeof STEPS[number]
type Props = {
open: boolean
setOpen: (open: boolean) => void
}
export const AppointmentModal: FC<Props> = ({ open, setOpen }) => {
const [step, setStep] = useState<Step>('topic')
const [selectedTopics, setSelectedTopics] = useState<number[]>([])
const handleStep = (action: 'next' | 'prev') => {
if (action === 'next' && step === 'success') {
setOpen(false)
} else if (action === 'next') {
setStep(STEPS[STEPS.indexOf(step) + 1])
} else if (action === 'prev' && step !== 'topic') {
setStep(STEPS[STEPS.indexOf(step) - 1])
}
}
const handleEscapeKey = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape" && open) setOpen(false)
},
[open, setOpen]
)
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden"
window.addEventListener("keydown", handleEscapeKey)
} else {
document.body.style.overflow = ""
}
return () => {
document.body.style.overflow = ""
window.removeEventListener("keydown", handleEscapeKey)
}
}, [handleEscapeKey, open])
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, top: "50%" }}
animate={{ opacity: 1, top: 0 }}
exit={{ opacity: 0, top: "50%" }}
className="fixed inset-0 z-100 flex items-center justify-center"
>
<div className="fixed inset-0 bg-primary-900/30" onClick={() => setOpen(false)} />
<div
className={cn(
"relative bg-primary-50 px-5 py-6 w-full max-w-[280px] mx-4 rounded-lg md:max-w-[627px] md:py-10 md:px-[60px] md:rounded-xl xl:max-w-[668px]",
step === 'success' && 'md:max-w-[400px] md:p-10 xl:max-w-[400px]',
)}
>
<Show condition={step !== 'success'}>
<Button
type="button"
size="sm"
variant="text"
className="absolute top-3 right-3 bg-primary-200 p-1 shadow md:bg-white md:p-2 md:top-8 md:right-10"
>
<CloseOutlined className="md:text-lg" />
</Button>
</Show>
<div>
{step !== 'success' && (
<img
src="/logos/simple.svg"
alt="IMPHNEN Logo"
className="mb-6 h-8 w-auto mx-auto md:h-12 xl:h-[60px]"
/>
)}
<div className="scrollbar-hide max-h-[50dvh] overflow-y-auto">
{step !== 'success' && (
<div className="relative mb-7 bg-primary-500 p-4 overflow-hidden rounded-md md:px-7 md:py-5 md:flex md:justify-between md:gap-x-6">
<p className="text-xs font-semibold text-primary-50 md:text-[19px] md:w-[290px] xl:text-[23px]">
<Show
condition={step !== 'qr-payment' && step !== 'va-payment'}
fallback="Yosha~! Saatnya Membayar :)"
>
Yosha~! Saatnya Level Up dengan Sesi Mentoring bersama Senpai!
</Show>
</p>
<img
src="/image/mascot-character.webp"
alt="IMPHNEN Mascot"
className="hidden w-[166px] h-auto absolute right-0 -bottom-3 md:block xl:w-[207px] xl:-bottom-5"
/>
</div>
)}
<AnimatePresence>
{step === 'topic' && <TopicStep selectedTopics={selectedTopics} setSelectedTopics={setSelectedTopics} />}
{step === 'schedule' && <ScheduleStep />}
{step === 'profile' && <ProfileStep />}
{step === 'payment' && <PaymentStep selectedTopics={selectedTopics} />}
{step === 'qr-payment' && <QrisPaymentStep />}
{step === 'va-payment' && <VAPaymentStep />}
{step === 'success' && <SuccessStep />}
</AnimatePresence>
</div>
<Show condition={step !== 'success'}>
<hr className="my-6 border-primary-300" />
</Show>
<div className="flex justify-between">
<Button
type="button"
size="sm"
variant="text"
className={cn((step === 'topic' || step === 'success') && 'hidden')}
onClick={() => handleStep('prev')}
>
Kembali
</Button>
<Button
type="button"
size="sm"
variant="primary"
className={cn((step === 'topic' || step === 'success') && 'w-full')}
disabled={selectedTopics.length === 0 && step === 'topic'}
onClick={() => handleStep('next')}
>
<Show
condition={step !== 'success'}
fallback="Halman Booking"
>
<Show condition={step !== 'payment'} fallback="Bayar Sekarang">
Selanjutnya
</Show>
</Show>
</Button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
@@ -0,0 +1,120 @@
import { For, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react"
import { motion } from "framer-motion"
import { TOPICS } from "../../../sections/topics"
type Props = {
selectedTopics: number[]
}
export const PaymentStep: FC<Props> = ({ selectedTopics }) => {
return (
<motion.div
className="bg-white px-6 py-5 rounded-md grid gap-6 md:grid-cols-2 md:gap-8 xl:grid-cols-9"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<div className="space-y-6 xl:col-span-4">
<div>
<p className="w-max mx-auto text-[10px] text-neutral-400 font-medium mb-2 md:text-xs md:ms-0">
Senpai Kamu
</p>
<div>
<div className="aspect-square mb-2 size-10 mx-auto rounded-full overflow-hidden md:size-20 md:ms-0">
<img src="/image/testimonial.webp" alt="Mentor" className="w-full object-cover" />
</div>
<div className="w-44 mx-auto text-center md:ms-0 md:text-left md:mb-2 md:text-[15px] md:w-auto">
<h3 className="text-xs font-semibold mb-1 xl:text-[15px]">
Muhammad Firdaus Oi Oi Oi, S.H., M.H.
</h3>
<p className="text-[10px] text-neutral-600 md:text-xs">
UI Designer at Oray orayan Studios
</p>
</div>
</div>
</div>
<div>
<p className="text-[10px] text-neutral-400 font-medium mb-2 md:text-xs">
Topics :
</p>
<div className="flex flex-wrap gap-2">
<For data={TOPICS}>
{(topic) => (
<Show key={topic.id} condition={selectedTopics.includes(topic.id)}>
<div
className="px-2.5 py-2 text-neutral-800 bg-white border border-primary-100 rounded-md shadow text-[10px] font-medium"
>
<span>{topic.icon} </span>
<span>{topic.name}</span>
</div>
</Show>
)}
</For>
</div>
</div>
</div>
<div className="space-y-6 xl:col-span-5">
<div>
<p className="text-[10px] text-neutral-400 font-medium mb-2 md:text-xs">
Metode Pembayaran
</p>
<div className="mb-3">
<p className="text-[8px] text-neutral-400 mb-2 md:text-[10px]">
Virtual Account
</p>
<div className="grid grid-cols-3 gap-2 md:grid-cols-4">
<For data={['BCA', 'BRI', 'BNI', 'MANDIRI', 'BCA-Virtual', 'BNI-Virtual', 'MANDIRI-Virtual']}>
{(bank, index) => (
<div
key={index}
className="p-1 bg-white rounded-xs border border-primary-50 flex items-center gap-x-1"
>
<input type="radio" name="payment" id={bank} className="size-2" />
<label htmlFor={bank} className="block">
<img src="/image/payment/bca.webp" alt={bank} className="object-scale-down" />
</label>
</div>
)}
</For>
</div>
</div>
<div>
<p className="text-[8px] text-neutral-400 mb-2 md:text-[10px]">
QRIS
</p>
<div className="grid grid-cols-3 gap-2 md:grid-cols-4">
<div
className="p-1 bg-white rounded-xs border border-primary-50 flex items-center gap-x-1"
>
<input type="radio" name="payment" id="QRIS" className="size-2" />
<label htmlFor="QRIS" className="block">
<img src="/image/payment/qris.webp" alt="QRIS" className="object-scale-down" />
</label>
</div>
</div>
</div>
</div>
<div>
<p className="text-[10px] text-neutral-400 font-medium mb-2 md:text-xs">
Detail Pembayaran
</p>
<div className="grid grid-cols-2 space-y-2.5 text-[10px] text-neutral-400 font-medium md:text-xs md:space-y-3">
<p className="font-normal">Subtotal :</p>
<p className="text-right">Rp. 50.000</p>
<p className="font-normal">Service Fee :</p>
<p className="text-right">Rp. 2.000</p>
<hr className="border-neutral-200 col-span-full mt-1" />
<p className="text-neutral-700">Total :</p>
<p className="text-right text-neutral-700">Rp. 52.000</p>
</div>
</div>
</div>
</motion.div>
)
}
@@ -0,0 +1,38 @@
import { Input } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils"
import { motion } from "framer-motion"
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
export const ProfileStep = () => {
return (
<motion.div
className="bg-white px-6 py-5 rounded-md md:px-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<h3 className="text-xs font-semibold mb-1.5 md:text-[15px] md:mb-2 xl:text-[19px]">
Langkah 3 dari 3
</h3>
<p className="text-[10px] text-neutral-600 mb-5 md:text-xs xl:text-[15px]">
Lengkapi profil kamu agar senpai dapat mengenal kamu lebih baik.
</p>
<div className="space-y-2.5 md:space-y-5">
<div>
<label className={labelClass}>Nomor WhatsApp</label>
<Input className="min-w-full w-full" placeholder="080000000" />
</div>
<div>
<label className={labelClass}>CV (Opsional)</label>
<Input type="file" className="min-w-full w-full" />
</div>
<div>
<label className={labelClass}>Portoflio (Opsional)</label>
<Input className="min-w-full w-full" placeholder="Link Url" />
</div>
</div>
</motion.div>
)
}
@@ -0,0 +1,55 @@
import { For } from "@imphnen-frontend-service/utils"
import { motion } from "framer-motion"
const PAYMENT_STEP = [
'Buka aplikasi e-wallet atau m-banking kamu',
'Cari fitur bayar menggunakan QRIS',
'Scan kode QR di samping',
'Konfimasi pembayaran, dan proses selesai.'
]
export const QrisPaymentStep = () => {
return (
<motion.div
className="bg-white px-6 py-5 rounded-md md:flex md:justify-between md:gap-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<div className="mb-7 md:mb-0">
<h3 className="text-xs font-semibold mb-4 text-neutral-600 text-center md:text-start">
Silahkan ikuti instruksi pembayaran di bawah ini
</h3>
<p className="text-[10px] text-neutral-400 mb-1.5">
Cara melakukan pembayaran
</p>
<ul className="list-decimal pl-2.5 mb-4 md:mb-7">
<For data={PAYMENT_STEP}>
{(step, index) => (
<li key={index} className="text-[10px] text-neutral-400 leading-tight font-medium">
{step}
</li>
)}
</For>
</ul>
<div className="text-center md:text-start">
<p className="text-[10px] text-neutral-400 mb-1.5 font-medium md:text-xs">
Biaya yang harus dibayarkan
</p>
<p className="text-xs font-semibold text-neutral-700 md:text-[15px]">Rp. 52.000</p>
</div>
</div>
<div>
<p className="text-[10px] text-neutral-400 mb-2 font-medium text-center md:text-xs">
QR Code Pembayaran
</p>
<div className="bg-primary-50 p-2.5 rounded-lg size-[120px] mx-auto md:size-[142px] md:me-0">
<img src="/image/sample-qrcode.webp" alt="QR Code" className="w-full" />
</div>
</div>
</motion.div>
)
}
@@ -0,0 +1,56 @@
import { Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils"
import { motion } from "framer-motion"
const placeholder = `Hi [Nama Mentor], Saya [Nama Kamu] & saya berharap dapat memiliki sesi mentoring dengan Anda.
Saat ini, saya tertarik untuk mengejar __. Tujuan saya untuk sesi ini adalah __.
Saya ingin tahu secara khusus tentang ___.
1.Pertanyaan Anda
2. ...
3. ...`
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
export const ScheduleStep = () => {
return (
<motion.div
className="bg-white px-6 py-5 rounded-md md:px-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<h3 className="text-xs font-semibold mb-1.5 md:text-[15px] md:mb-2 xl:text-[19px]">
Langkah 2 dari 3
</h3>
<p className="text-[10px] text-neutral-600 mb-5 md:text-xs xl:text-[15px]">
Pilih jadwal sesuai preferensi kamu & siapkan pertanyaanmu
</p>
<div className="grid gap-2.5 md:grid-cols-2 md:gap-5">
<div>
<label className={labelClass}>Tanggal</label>
<Input type="date" className="min-w-full w-full" />
</div>
<div>
<label className={labelClass}>Waktu</label>
<Input type="time" className="min-w-full w-full" />
</div>
<div className="relative md:col-span-full">
<label className={labelClass}>Lokasi</label>
<Select className="min-w-full w-full">
<option value="online">Online</option>
<option value="offline">Offline</option>
</Select>
</div>
<div className="md:col-span-full">
<label className={labelClass}>Pertanyaan Untuk Senpai</label>
<Textarea
className="min-w-full w-full h-40"
placeholder={placeholder} />
</div>
</div>
</motion.div>
)
}
@@ -0,0 +1,33 @@
import { CheckOutlined } from "@ant-design/icons"
import { cn } from "@imphnen-frontend-service/utils"
import { motion } from "framer-motion"
export const SuccessStep = () => {
return (
<motion.div
className="pb-6 rounded-md md:pb-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<h2 className="text-[15px] font-semibold mb-3 text-primary-500 text-center md:text-[19px] md:mb-7 xl:text-[23px] xl:mb-8">
Booking Senpai Berhasil
</h2>
<div
className={cn(
"mb-3 flex justify-center items-center mx-auto size-[60px] bg-green-200 rounded-full",
"md:size-[100px] md:mb-7 xl:size-[120px] xl:mb-8"
)}
>
<div className="bg-green-500 rounded-full size-10 flex justify-center items-center text-white md:size-16 xl:size-20">
<CheckOutlined className="text-xl md:text-3xl xl:text-5xl" />
</div>
</div>
<p className="text-[10px] text-neutral-400 text-center md:text-xs md:max-w-[220px] md:mx-auto xl:text-[15px]">
Untuk detail dan instruksi berikutnya dapat dilihat di dashboard pada halaman booking
</p>
</motion.div>
)
}
@@ -0,0 +1,82 @@
import { CheckOutlined } from "@ant-design/icons"
import { cn, For, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react"
import { motion } from "framer-motion"
import { TOPICS } from "../../../sections/topics"
type Props = {
selectedTopics: number[]
setSelectedTopics: (topics: number[]) => void
}
export const TopicStep: FC<Props> = ({ selectedTopics, setSelectedTopics }) => {
const handleSelectTopic = (topicId: number) => {
if (selectedTopics.includes(topicId)) {
setSelectedTopics(selectedTopics.filter((id) => id !== topicId))
} else {
setSelectedTopics([...selectedTopics, topicId])
}
}
return (
<>
<motion.div
className="mb-6 flex items-center gap-x-4 bg-white rounded-md overflow-hidden shadow/5 md:gap-x-10"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<div className="w-16 aspect-4/5 overflow-hidden md:w-[134px] xl:w-[140px]">
<img src="/image/testimonial.webp" alt="Mentor" className="w-full object-cover" />
</div>
<div className="flex-1 pe-4">
<h3 className="text-xs font-semibold mb-1 line-clamp-2 md:text-[19px] md:mb-2 xl:text-[23px]">
Muhammad Firdaus Oi Oi Oi, S.H., M.H.
</h3>
<p className="text-[10px] line-clamp-2 text-neutral-600 md:text-[15px] xl:text-[19px]">
UI Designer at Oray orayan Studios
</p>
</div>
</motion.div>
<motion.div
className="bg-white p-5 rounded-md shadow/5 md:px-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<h3 className="text-xs font-semibold mb-1.5 md:text-[15px] md:mb-2 xl:text-[19px]">
Langkah 1 dari 3
</h3>
<p className="text-[10px] text-neutral-600 mb-5 md:text-xs xl:text-[15px]">
Pilih topik yang ingin kamu diskusikan dengan Senpai kamu~
</p>
<div className="grid gap-3 md:grid-cols-2 md:content-center">
<For data={TOPICS}>
{(topic) => (
<div
key={topic.id}
className={cn(
"relative px-2.5 py-2 text-neutral-800 bg-white border border-primary-100 rounded-md shadow text-[10px] font-medium cursor-pointer",
"md:text-xs xl:text-xs xl:font-semibold",
selectedTopics.includes(topic.id) && "bg-primary-50 border-primary-200"
)}
onClick={() => handleSelectTopic(topic.id)}
>
<span>{topic.icon} </span>
<span>{topic.name}</span>
<Show condition={selectedTopics.includes(topic.id)}>
<div className="absolute top-2 right-2 bg-primary-500 size-[14px] rounded-sm flex justify-center items-center text-white">
<CheckOutlined />
</div>
</Show>
</div>
)}
</For>
</div>
</motion.div>
</>
)
}
@@ -0,0 +1,55 @@
import { For } from "@imphnen-frontend-service/utils"
import { motion } from "framer-motion"
const PAYMENT_STEP = [
'Buka aplikasi e-wallet atau m-banking kamu',
'Pergi ke fitur transfer via Virtual Account',
'Pilih bank dan kode VA sesuai dengan yang ada pada halaman ini',
'Konfimasi pembayaran, dan proses selesai.'
]
export const VAPaymentStep = () => {
return (
<motion.div
className="bg-white px-6 py-5 rounded-md md:flex md:items-center md:gap-11"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<div className="mb-6 md:mb-0">
<h3 className="text-xs font-semibold mb-4 text-neutral-600 text-center md:text-start">
Silahkan ikuti instruksi pembayaran di bawah ini
</h3>
<p className="text-[10px] text-neutral-400 mb-1.5">
Cara melakukan pembayaran
</p>
<ul className="list-decimal pl-2.5">
<For data={PAYMENT_STEP}>
{(step, index) => (
<li key={index} className="text-[10px] text-neutral-400 leading-tight font-medium">
{step}
</li>
)}
</For>
</ul>
</div>
<div className="text-center md:text-start">
<div className="w-max mx-auto mb-3 md:ms-0 md:mb-4">
<img src="/image/payment/bca.webp" alt="VA BCA" className="h-5 w-auto" />
</div>
<p className="text-[10px] text-neutral-400 mb-1 font-medium md:text-xs">
Kode Virtual Account
</p>
<p className="text-xs font-semibold text-neutral-700 mb-1.5 md:text-[15px]">
8091239861969812
</p>
<p className="text-[8px] text-neutral-400 md:text-[10px]">
A/N Unknown
</p>
</div>
</motion.div>
)
}
@@ -0,0 +1,33 @@
import { cn, For } from "@imphnen-frontend-service/utils"
const EDUCATION = [
{ name: 'Universitas Widyabakti', major: 'Intern Front End', duration: '2 Months', range: 'Oct 2024 - Present' },
{ name: 'SMKN 99 Banjaran', major: 'Rekayasa Perangkat Lunak', duration: '6 Months', range: 'May 2024 - Oct 2024' },
]
export const EducationSection = () => {
return (
<div className="px-7 py-8 rounded-md shadow-md">
<h2 className="text-xs font-semibold mb-5 md:text-[15px] xl:text-[19px]">Education</h2>
<div className="space-y-4 divide-y">
<For data={EDUCATION}>
{(item, index) => (
<div key={index} className={cn("flex items-center gap-x-4", index !== EDUCATION.length - 1 && "pb-4")}>
<div className="rounded-full size-6 bg-neutral-200 md:size-7 xl:size-8"></div>
<div className='flex-1 text-[10px] font-medium'>
<p className="text-neutral-800 md:text-xs xl:text-[15px]">{item.name}</p>
<p>
<span className="text-neutral-600 xl:text-xs">{item.major}</span>
<span className="text-neutral-300"> · </span>
<span className="text-neutral-400 font-normal">{item.duration}</span>
<span className="text-neutral-300"> · </span>
<span className="text-neutral-400 font-normal xl:font-medium">{item.range}</span>
</p>
</div>
</div>
)}
</For>
</div>
</div>
)
}
@@ -0,0 +1,34 @@
import { cn, For } from "@imphnen-frontend-service/utils"
import { FC } from "react"
const EXPERIENCE = [
{ name: 'Sunday.com', position: 'Intern Front End', duration: '2 Months', range: 'Oct 2024 - Present' },
{ name: 'CodeX Digital', position: 'Intern Front End', duration: '6 Months', range: 'May 2024 - Oct 2024' },
]
export const ExperienceSection: FC = () => {
return (
<div className="px-7 py-8 rounded-md shadow-md">
<h2 className="text-xs font-semibold mb-5 md:text-[15px] xl:text-[19px]">Experience</h2>
<div className="space-y-4 divide-y">
<For data={EXPERIENCE}>
{(item, index) => (
<div key={index} className={cn("flex items-center gap-x-4", index !== EXPERIENCE.length - 1 && "pb-4")}>
<div className="rounded-full size-6 bg-neutral-200 md:size-7 xl:size-8"></div>
<div className='flex-1 text-[10px] font-medium'>
<p className="text-neutral-800 md:text-xs xl:text-[15px]">{item.name}</p>
<p>
<span className="text-neutral-600 xl:text-xs">{item.position}</span>
<span className="text-neutral-300"> · </span>
<span className="text-neutral-400 font-normal">{item.duration}</span>
<span className="text-neutral-300"> · </span>
<span className="text-neutral-400 font-normal xl:font-medium">{item.range}</span>
</p>
</div>
</div>
)}
</For>
</div>
</div>
)
}
@@ -0,0 +1,93 @@
import { StarFilled } from "@ant-design/icons"
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { cn, For } from "@imphnen-frontend-service/utils"
type Props = {
onBook: () => void
}
export const ProfileSection: React.FC<Props> = ({ onBook }) => {
return (
<div className="bg-white px-4 py-5 space-y-6 md:px-8 md:pt-6 md:pb-0 xl:space-y-0 xl:py-[30px] xl:flex xl:gap-x-9 xl:justify-between">
<div
className={cn(
"flex flex-col items-center justify-center",
"md:flex-row md:justify-start md:gap-x-8 md:items-start",
"xl:items-center xl:gap-x-10",
)}
>
<div
className={cn(
"mb-5 h-20 w-auto aspect-4/5 rounded-sm overflow-hidden",
"md:aspect-33/40 md:h-40 md:rounded-md md:border md:border-primary-50 md:mb-0",
"xl:aspect-5/6 md:h-60 md:rounded-lg"
)}
>
<img src="/image/testimonial.webp" alt="Mentor" className="w-full object-cover" />
</div>
<div className="md:max-w-[242px] xl:max-w-[306px]">
<h1
className={cn(
"mb-1 text-[15px] font-semibold text-center leading-snug",
"md:text-[19px] md:mb-2 md:text-start xl:text-[23px]",
)}
>
Muhammad Firdaus Oi Oi Oi, S.H., M.H.
</h1>
<p className="text-xs mb-4 text-neutral-600 md:mb-5 md:text-[15px] xl:text-[19px] xl:mb-5">
UI Designer at Oray orayan Studios
</p>
<div className="flex items-center gap-x-2.5 w-full max-w-max border border-primary-50 p-1.5 rounded-md mx-auto md:ms-0">
<div
className="bg-gradient-to-tr from-primary-500 to-primary-200 rounded-sm text-white size-5 flex justify-center items-center xl:size-[29.4px]"
>
<StarFilled className="text-xs xl:text-sm" />
</div>
<div>
<p className="text-[10px] font-medium mb-1 text-neutral-800 xl:text-xs">Excelent Sensei</p>
<p className="text-[8px] text-neutral-600 xl:text-[10px]">4.8/5.0</p>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-y-3 xl:gap-y-4 xl:max-w-[402px]">
<div>
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Expertise</h2>
<div className="p-4 bg-primary-50 border border-primary-100 rounded-md flex flex-wrap gap-3">
<For data={['UI Design', 'UX Reseacrh']}>
{(item) => (
<div key={item} className="bg-primary-300 text-primary-600 px-3 py-2 rounded-md text-[10px] md:font-medium xl:text-xs xl:font-semibold">
{item}
</div>
)}
</For>
</div>
</div>
<div>
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Soft Skills</h2>
<div className="p-4 bg-primary-50 border border-primary-100 rounded-md flex flex-wrap gap-3">
<For data={['Design Thinking', 'Communication', 'Problem Solving', '19:00 WIB']}>
{(item) => (
<div key={item} className="bg-primary-300 text-primary-600 px-3 py-2 rounded-md text-[10px] md:font-medium xl:text-xs xl:font-semibold">
{item}
</div>
)}
</For>
</div>
</div>
</div>
<div className="hidden md:flex xl:hidden justify-between">
<div>
<p className="text-primary-500 text-[15px] font-semibold">Jum, 4 April 2025</p>
<p className="text-neutral-500 text-xs font-medium">Jum, 4 April 2025</p>
</div>
<Button type="button" size="sm" onClick={onBook}>
Book Your Senpai!
</Button>
</div>
</div>
)
}
@@ -0,0 +1,75 @@
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { cn, For } from "@imphnen-frontend-service/utils"
import { FC } from "react"
const SCHEDULES = [
{
date: "Kamis, 20 Maret 2025",
availableCount: 10,
schedule: [
{ time: "19:00 WIB", available: true },
{ time: "20:00 WIB", available: true },
{ time: "21:00 WIB", available: true },
{ time: "22:00 WIB", available: true },
]
},
{
date: "Jumat, 21 Maret 2025",
availableCount: 1,
schedule: [
{ time: "19:00 WIB", available: true },
{ time: "20:00 WIB", available: true },
{ time: "21:00 WIB", available: false },
{ time: "22:00 WIB", available: true },
]
}
]
type Props = {
onBook: () => void
}
export const SenpaiScheduleSection: FC<Props> = ({ onBook }) => {
return (
<div>
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Senpai Schedule</h2>
<div className="p-4 bg-primary-50 border border-primary-100 rounded-md space-y-5">
<For data={SCHEDULES}>
{(item) => (
<div key={item.date}>
<div className="flex justify-between items-center mb-4">
<p className="text-[15px] font-medium">{item.date}</p>
<p
className={cn(
"rounded-4xl py-1 px-2.5 text-xs font-medium",
item.availableCount > 5 ? "bg-primary-200 text-primary-500" : "bg-danger-100 text-danger-600"
)}
>
{item.availableCount} slot tersisa
</p>
</div>
<div className="flex gap-x-3">
<For data={item.schedule}>
{(schedule, index) => (
<div
key={index}
className="bg-primary-300 text-primary-600 px-3 py-2 text-xs rounded-md font-semibold"
>
{schedule.time}
</div>
)}
</For>
</div>
</div>
)}
</For>
<hr className="border-primary-200" />
<Button type="button" size="sm" className="w-full" onClick={onBook}>
Book Your Senpai!
</Button>
</div>
</div>
)
}
@@ -0,0 +1,34 @@
import { StarFilled } from "@ant-design/icons";
import { For } from "@imphnen-frontend-service/utils";
import { FC } from "react";
const SENPAI_STATISTIC = [
{ name: 'Total Sessions', count: 8 },
{ name: 'Mentee Impact', count: 1000 },
{ name: 'Response Time', count: '30 Minute' }
]
export const StatisticsSection: FC = () => {
return (
<div className="md:mb-10">
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Senpai Statistics</h2>
<div className="grid gap-4 xl:flex">
<For data={SENPAI_STATISTIC}>
{(item, index) => (
<div key={index} className="px-2.5 py-2 border border-primary-50 rounded-md shadow flex items-center gap-x-2.5">
<div
className="bg-gradient-to-tr from-primary-500 to-primary-200 rounded-sm text-white size-7 flex justify-center items-center xl:size-[29.4px]"
>
<StarFilled className="text-sm" />
</div>
<div>
<p className="text-[10px] text-neutral-600 font-medium md:text-xs xl:font-semibold">{item.count}</p>
<p className="text-[8px] text-neutral-800 md:text-[10px]">{item.name}</p>
</div>
</div>
)}
</For>
</div>
</div>
)
};
@@ -0,0 +1,33 @@
import { For } from "@imphnen-frontend-service/utils"
import { FC } from "react"
export const TOPICS = [
{ id: 1, icon: '💼', name: 'Career & Self Development' },
{ id: 2, icon: '🏢', name: 'Industry Insight' },
{ id: 3, icon: '🗂️', name: 'Project Management & IT Tools' },
{ id: 4, icon: '🖥️', name: 'Basic IT' },
{ id: 5, icon: '💻', name: 'Programming/Software Dev' },
{ id: 6, icon: '🗃️', name: 'Data & Database' },
{ id: 7, icon: <span className="font-bold text-primary-500">AI</span>, name: 'AI Tips' }
]
export const TopicsSection: FC = () => {
return (
<div>
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Topics</h2>
<div className="p-5 bg-primary-50 border border-primary-100 rounded-md flex flex-wrap gap-2.5">
<For data={TOPICS}>
{(item, index) => (
<div
key={index}
className="px-2.5 py-2 text-neutral-800 bg-white border border-primary-100 rounded-md shadow text-[10px] font-medium"
>
<span>{item.icon} </span>
<span>{item.name}</span>
</div>
)}
</For>
</div>
</div>
)
}
@@ -0,0 +1,52 @@
import { FC, useState } from 'react'
import { ProfileSection } from './_components/sections/profile'
import { StatisticsSection } from './_components/sections/senpai-statistics'
import { TopicsSection } from './_components/sections/topics'
import { ExperienceSection } from './_components/sections/experience'
import { EducationSection } from './_components/sections/education'
import { SenpaiScheduleSection } from './_components/sections/senpai-schedule'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { AppointmentModal } from './_components/modals/appointment'
export const Components: FC = () => {
const [open, setOpen] = useState(false)
return (
<main>
<section className="w-full p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20">
<div className="max-w-7xl mx-auto space-y-8 md:bg-white xl:bg-transparent">
<ProfileSection onBook={() => setOpen(true)} />
<Button type="button" size="sm" className="w-full md:hidden" onClick={() => setOpen(true)}>
Book Your Senpai!
</Button>
<div className="bg-white px-4 py-5 md:px-8 md:pb-6 md:pt-0 xl:py-7 xl:flex xl:gap-x-10">
<div className="space-y-10 md:space-y-7 xl:flex-1">
<StatisticsSection />
<TopicsSection />
<div className="px-6 py-8 rounded-md shadow-md">
<h2 className="text-xs text-neutral-800 font-semibold mb-5 md:text-[15px] xl:text-[19px]">Senpai Resume</h2>
<p className="text-[10px] font-medium text-neutral-600 text-pretty md:text-[15px]">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
</p>
</div>
<ExperienceSection />
<EducationSection />
</div>
<div className="hidden xl:block xl:w-[400px]">
<SenpaiScheduleSection onBook={() => setOpen(true)} />
</div>
</div>
</div>
</section>
<AppointmentModal open={open} setOpen={setOpen} />
</main>
)
}
export default Components
@@ -0,0 +1,70 @@
import { cn } from '@imphnen-frontend-service/utils';
import { FC, useRef } from 'react';
import { motion, useInView, Variants } from 'framer-motion';
export const BannerSection: FC = () => {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}
const childVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
return (
<section ref={ref} className="w-full p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20">
<motion.div
className={cn(
"relative max-w-7xl mx-auto bg-white p-6 rounded-lg overflow-hidden shadow-md",
"md:flex md:justify-between md:items-center md:px-5 md:rounded-xl lg:px-16 lg:py-10",
)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<motion.div
className="hidden absolute -left-9 -top-24 w-[17rem] md:block lg:w-[28rem] lg:-left-14 lg:-top-52"
initial={{ x: -120, opacity: 0, rotate: -6 }}
animate={{ x: 0, opacity: 1, rotate: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<img src="/image/mentoring-banner.webp" alt="" className="w-full" />
</motion.div>
<motion.div
className="text-center md:text-start md:max-w-[calc(100%-14rem)] md:ms-auto lg:max-w-[calc(100%-20rem)]"
variants={containerVariants}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
viewport={{ once: true }}
>
<motion.h1 className="mb-4 text-xl font-semibold text-primary-500 leading-tight md:text-2xl lg:text-5xl lg:font-bold lg:mb-6" variants={childVariants}>
Find your perfect sensei
</motion.h1>
<motion.p className="text-xs font-semibold text-neutral-500 md:text-[15px] md:font-medium lg:text-xl" variants={childVariants}>
Di sini, kamu nggak cuma grinding sendiriansensei dari dunia nyata siap membimbingmu, dan AI mentor bakal jadi support system terbaikmu. Saatnya jadi protagonist dalam perjalanan karier IT-mu!
</motion.p>
</motion.div>
</motion.div>
</section>
)
}
@@ -0,0 +1,41 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { FC } from 'react';
import { Link } from 'react-router-dom';
export const MentorCard: FC = () => {
return (
<div className="p-2.5 rounded-md bg-white shadow flex gap-x-4 items-start md:p-4 md:flex-col md:rounded-lg md:gap-y-4">
<div className="size-[60px] rounded-md overflow-hidden md:w-full md:h-auto md:aspect-square">
<img src="/image/testimonial.webp" alt="Mentor" className="w-full object-cover" />
</div>
<div>
<Button variant="text" size="sm" className="bg-primary-100 h-auto px-1.5 py-1 text-[8px] font-normal mb-2 hover:bg-primary-100 md:text-[10px] md:font-medium md:mb-4">
3-5 Years Experience
</Button>
<h2 className="mb-1">
<Link to="/mentoring/detail" className="text-xs font-semibold text-primary-500 md:text-[15px] md:font-semibold lg:text-[19px]">
Fullname
</Link>
</h2>
<p className="text-[8px] text-neutral-500 mb-3 md:text-[10px] md:font-medium md:mb-4 lg:text-xs">
Full Stack Enjoyer at name company
</p>
<div>
<p className="text-[8px] text-neutral-500 mb-1 md:mb-2 lg:text-[10px]">Soft Skills :</p>
<div className="space-x-2">
<Button variant="text" size="sm" className="bg-primary-200 h-auto px-1.5 py-1 text-[8px] font-normal hover:bg-primary-200 lg:text-[10px]">
Communication
</Button>
<Button variant="text" size="sm" className="bg-primary-200 h-auto px-1.5 py-1 text-[8px] font-normal hidden hover:bg-primary-200 md:inline-block lg:text-[10px]">
Communication
</Button>
<Button variant="text" size="sm" className="h-auto px-1.5 py-1 text-[8px] font-normal text-neutral-500 hover:bg-transparent lg:text-[10px]">
+2
</Button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,73 @@
import { CloudServerOutlined, CodeOutlined, DatabaseOutlined, ProjectOutlined, TabletOutlined } from '@ant-design/icons'
import { Icon } from '@iconify/react'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { For } from '@imphnen-frontend-service/utils'
import { FC, ReactElement, useRef } from 'react'
import { motion, useInView, Variants } from 'framer-motion'
const TOPICS: {
name: string
icon: React.ReactNode
}[] = [
{ name: 'UI/UX & Design', icon: <TabletOutlined /> },
{ name: 'Software/Web Dev', icon: <CodeOutlined /> },
{ name: 'Data & AI', icon: <DatabaseOutlined /> },
{ name: 'Cloud & DevOps', icon: <CloudServerOutlined /> },
{ name: 'Cybersecurity', icon: <Icon icon="mage:security-shield" /> },
{ name: 'IT & Networking', icon: <Icon icon="carbon:data-structured" /> },
{ name: 'Project Management', icon: <ProjectOutlined /> },
{ name: 'QA & Testing', icon: <Icon icon="carbon:exam-mode" /> }
]
export const Topics: FC = (): ReactElement => {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.3,
delay: 0.4,
},
},
}
const childVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
return (
<div ref={ref}>
<motion.div
className="grid grid-cols-4 gap-6 px-3 md:px-12 md:gap-x-10 md:gap-y-14 lg:grid-cols-8 lg:px-0"
variants={containerVariants}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
>
<For data={TOPICS}>
{(topic) => (
<motion.div key={topic.name} className="flex flex-col items-center" variants={childVariants}>
<Button variant="secondary" size='sm' className="size-7 mb-3 p-0 md:mb-4 md:size-8 md:text-base lg:size-10 lg:text-lg">
{topic.icon}
</Button>
<p className="text-[8px] text-neutral-700 text-center break-words md:text-xs md:font-medium lg:text-[15px]">
{topic.name}
</p>
</motion.div>
)}
</For>
</motion.div>
</div>
)
}
@@ -0,0 +1,121 @@
import { FC, ReactElement, useRef, useState } from 'react';
import { BannerSection } from './_components/banner-section';
import { Topics } from './_components/topics';
import { Input } from '@imphnen-frontend-service/ui/atoms';
import { SearchOutlined } from '@ant-design/icons';
import { For } from '@imphnen-frontend-service/utils';
import { MentorCard } from './_components/mentor-card';
import { Pagination } from '@imphnen-frontend-service/ui/molecules';
import { getCoreRowModel, getPaginationRowModel, PaginationState, useReactTable } from '@tanstack/react-table';
import { motion, useInView, Variants } from 'framer-motion';
const TEMP_DATA = [
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'John Doe' },
{ id: 3, name: 'John Doe' },
{ id: 4, name: 'John Doe' },
]
export const Components: FC = (): ReactElement => {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 3,
});
const table = useReactTable({
data: TEMP_DATA,
columns: [],
state: { pagination },
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
});
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.3,
},
},
}
const childVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
}
return (
<main>
<BannerSection />
<section className="w-full px-8 md:px-[60px] md:pb-[60px] lg:px-20">
<motion.div
className="max-w-7xl mx-auto bg-white rounded-lg py-6 md:rounded-4xl"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
>
<div className="mb-10 md:px-6 lg:mb-[60px]">
<motion.h1
className="text-center text-[15px] text-primary-500 font-semibold mb-8 md:text-2xl md:mb-10 lg:text-3xl lg:mb-[60px]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.3 }}
>
Choose Your Topics
</motion.h1>
<Topics />
</div>
<motion.div ref={ref} className="md:px-6">
<motion.div
className="relative mb-6 md:px-12 lg:px-0 lg:w-[480px] lg:mx-auto"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
>
<Input
placeholder="Cari berdasarkan nama, posisi/peran"
className="relative min-w-full w-full"
/>
<SearchOutlined className="absolute right-2.5 top-1/2 -translate-y-1/2 text-primary-500 size-2.5 cursor-text md:me-12 lg:me-0" />
</motion.div>
<motion.div
className="grid gap-2 mb-10 md:grid-cols-2 md:gap-6 lg:grid-cols-4"
variants={containerVariants}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
>
<For data={Array.from({ length: 8 })}>
{(_, index) => (
<motion.div key={index} variants={childVariants}>
<MentorCard />
</motion.div>
)}
</For>
</motion.div>
<Pagination table={table} />
</motion.div>
</motion.div>
</section>
</main>
)
}
export default Components
@@ -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>
);
};
@@ -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';
@@ -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>
);
};
@@ -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>
);
};

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