Compare commits

..
Author SHA1 Message Date
asepharyana 7901fc3f82 feat(dimentorin): feedback mentor 2-step modal (Figma Modals-Feedback 1 & 2)
Halaman yang belum ada di FE: feedback mentor setelah sesi selesai. Figma punya 'Modals - Feedback 1' (rating 1-5 + 2 pertanyaan pilihan) dan 'Modals - Feedback 2' (3 textarea: suka/tingkatkan/testimoni). BE sudah punya POST /dimentorin/sessions/{id}/feedback/create.

- BARU mentoring/_components/feedback-modal.tsx: modal 2 step (rating bintang + kebermanfaatan + rekomendasi → textarea suka/tingkatkan/testimoni), validasi step (Lanjut disabled sampai rating+opsi dipilih), submit gabung jadi feedback text + rating, reset state tiap buka
- my-sessions: tombol ' Beri Feedback' muncul untuk sesi status completed; modal ter-integrasi; invalidate my-sessions + mentor-stats setelah submit
- Verifikasi end-to-end via browser: tombol muncul, modal step 1 → rating 5 + opsi → step 2 → isi textarea → Kirim → POST 200 feedback+rating tersimpan di DB (sesi Status Test di-set completed utk test lalu di-reset)
2026-08-05 23:18:51 +07:00
asepharyana e2d5e37bd1 feat(dimentorin): dashboard layout Figma untuk user & mentor + halaman Dashboard baru
Node 216:740 = canvas penuh (bukan cuma design system). Dashboard User & Mentor di Figma punya sidebar + topbar + overview stats yang BELUM ada di FE. Implementasi:

- BARU apps/.../mentoring/_components/dashboard-layout.tsx: DashboardLayout/DashboardSidebar/DashboardHeader shared (sidebar sticky, decode JWT untuk inisial/email, logout via SessionToken.remove)
- BARU /mentoring/dashboard (Dashboard User sesuai Figma 1290:4808): welcome banner 'Selamat Datang di Dimentorin.dev' + CTA Temukan Roadmapnya, Overview stats (Mentoring Session dari sessions API, Article Submitted dari articles, Article Published dari completed sessions), Roadmaps progress card 'Front End Basic' dari materials API
- my-sessions dipindah ke layout dashboard (sidebar user, nav aktif)
- mentor-dashboard full rewrite (Figma 1340:4285): sidebar mentor (Dashboard/Mentoring Setup/List Mentee/Feedback), overview stats (Your Rating dari mentor stats avg_rating, Session Complete, Mentee Impacted unique, Total Feedback), tabs Sesi Masuk/Setup/Mentee/Feedback dengan data sessions API
- Header: tambah menu Dashboard
2026-08-05 22:40:56 +07:00
asepharyana 3a8496203e feat(dimentorin): implementasi halaman 'Jadi Sensei' (Mentor Page) dari Figma + fix tombol modal
Dengan Figma PAT sekarang bisa akses node 216:740 = canvas 'Dimentorin By IMPHNEN' (berisi SEMUA halaman, bukan cuma design system). Hasil audit konten:

- Halaman baru /jadi-sensei: landing rekrutmen mentor persis Figma (Mentor Page 1686:12228)
  * Hero 'Jadi Sensei di Dunia IT?' + stats 1000+ Kohai Menunggu / 500+ Sensei Aktif + tombol 'Mulai Jadi Sensei'
  * 'Kenapa Harus Join Guild Sensei Kami? 🔥' - 3 kartu: Power-Up Para Kohai, Skill Jadi Gold Mine, Legendary Sensei Status
  * Testimonial 'What the Legendary Sensei Says' (Riko 4.8/5.0)
  * FAQ 'Kata Para Sensei Legendaris' - income 100rb/sesi/45 menit, sistem pembayaran, flexible sesi
  * CTA 'Ready to become the Legendary Sensei?' + 'Daftar Sekarang'
- Fix typo 'Bagaiamana' -> 'Bagaimana', 'Frequently Ask Question' -> 'Frequently Asked Questions'
- Booking modal: tombol step CV (profile) jadi 'Pembayaran' sesuai Figma (Modals Appointment Mentor 3)
- Verifikasi teks persis via Figma REST API: Home/CTA/WhatWeOffer/Modal sudah MATCH (koreksi temuan vision yang keliru)
2026-08-05 21:43:50 +07:00
asepharyana 22bb4b63ce feat(dimentorin): implementasi avatar inisial mentor + resume dinamis + fix dummy data
- Komponen baru MentorAvatar: gradient unik per nama (hash), inisial nama, reusable (sm/md/lg + textClassName)
- Kartu mentor: ganti img testimonial.webp statis -> avatar inisial gradient (MU, dll)
- Detail mentor profile: avatar besar + fix hardcoded tanggal 'Jum, 4 April 2025' -> tanggal dinamis id-ID
- Senpai Resume: fallback dinamis dari data mentor (role/experience/expertise/industries/languages) saat bio kosong, bukan 'Belum ada deskripsi'
- Booking modal topic step: ganti nama mentor hardcoded 'Muhammad Firdaus Oi Oi Oi' -> dari prop mentor + avatar
- Booking modal payment step: avatar mentor di summary
2026-08-05 20:59:56 +07:00
asepharyana 8b77f27f58 feat(dimentorin): audit isi halaman & layout + fix halaman kosong
- Homepage testimonial: hardcode Array.from({length:2}) duplikat Riko x2 -> 4 testimonial berbeda (Riko 4.8, Salsa 5.0, Dimas 4.9, Nadia 4.7)
- Fix typo 'dellow devs' -> 'fellow devs', 'Frequently Ask Question' -> 'Frequently Asked Questions'
- FAQ diperluas dari 3 -> 5 pertanyaan (payment, materi gratis)
- Buat halaman 404 brand IMPHNEN (Quest Tidak Ditemukan!) dgn Header+Footer di (public)/404.tsx
- Fix middleware: 404/route tak dikenal tidak redirect ke login (hanya prefix admin /dashboard,/users,/roles,/permissions yg protected)
- my-sessions & mentor-dashboard: guard SessionToken -> CTA 'Login dulu, Senpai!' (sebelumnya loading forever karena API 401 tanpa token)
2026-08-05 20:13:15 +07:00
asepharyana 5b50680f14 feat(dimentorin): audit FE samakan dgn Figma design system
- FIX KRITIS: apps/dimentorin/src/index.css @theme nyimpang dari Figma (67 token beda: primary-500 #1a8ce6 vs #23a1eb, semua warna success/info/warning/danger + skala typography). Disamakan dgn libs/ui (source of truth Figma).
- Hapus hardcoded warna non-token di apps: blue-400, gray-5/6/700, amber/emerald/red-100, green-200/500 -> token primary/neutral/warning/success/danger
- libs/ui: gray-* -> neutral-*, blue-* -> primary-*, #1a8ce6 -> #23a1eb, bg-[#F0F8FF] -> primary-50, SVG fill hex -> fill-* token classes
- Typography: text-[27/26/22/11px] menyimpang dari skala Figma -> text-[29/23/19/12px]
- Radius: rounded-4xl (32px, tidak ada di Figma) -> rounded-3xl (24px)
2026-08-05 19:28:02 +07:00
asepharyana 94f80e4eda fix(dimentorin): homepage kosong akibat duplikat page route
- hapus apps/dimentorin/src/app/page.tsx (hanya Navbar+div kosong) yg menang conflict /
- aktifkan (public)/(home) yg berisi 5 section (Hero, WhatWeOffer, Testimonial, FAQ, CTA)
- tambah navigasi onClick tombol CTA: hero & CTA -> /mentoring/ai-agent, /mentoring
2026-08-05 18:36:35 +07:00
asepharyana 7294040fd1 feat(dimentorin): halaman Materi + AI Agent RAG
- /mentoring/materi: list materi (kategori chips, cards) + detail konten per slug
- /mentoring/ai-agent: chatbox RAG — tanya materi, jawaban dari Qdrant context + sumber relevansi %
- service: getMaterialsList/getMaterialBySlug/getMaterialById/getMaterialCategories + postChat + hooks
- header: menu Materi & AI Agent
- e2e verified: list 3 materi, detail tampil, chat 'ownership' jawab dari materi Rust + 3 sources (84/64/61%)
2026-08-05 17:26:27 +07:00
asepharyana 49a448fee1 feat(dimentorin): my sessions + mentor dashboard with live payment status
- /mentoring/my-sessions: mentee session list w/ payment status (Lunas/Menunggu), Cek Status button (Midtrans auto-paid refresh), Detail Mentor link
- /mentoring/mentor-dashboard: mentor session requests w/ payment panel (Refresh + Konfirmasi)
- header menu: Sesi Saya
- TPayment type fixed to camelCase (sessionId/mentorId/externalRef) matching backend DTO
- QRIS step renders real Midtrans QR from qr_string via qrserver API
- e2e verified: sessions render, refresh + confirm payment buttons work end-to-end
2026-08-05 15:58:29 +07:00
asepharyana bb980a7add fix(dimentorin): hide raw QR string for midtrans provider in QRIS step
- QRIS step no longer shows the long EMVCo QR string verbatim (real VA number still shown in VA step)
- build verified
2026-08-05 13:37:41 +07:00
asepharyana 893f7ba03c feat(dimentorin): wire appointment payment flow to payments API
- PaymentStep: selectable VA/QRIS method, rate from mentor.mentoring_rate (fallback 50000), onMethodChange
- AppointmentModal: book session -> create payment -> route to VA/QRIS step with real payment data; then success
- QrisPaymentStep/VAPaymentStep: show real total + external_ref (VA number/QR ref) + expiry
- service lib: TPayment/TCreatePaymentRequest types + postCreatePayment/getMyPayments/getPaymentById/postConfirmPayment + hooks
- build verified, payments flow bundled
2026-08-05 09:28:56 +07:00
asepharyana a74fabe5f5 feat(dimentorin): senpai statistics from API (total sessions, mentee impact, avg rating) 2026-08-04 23:09:15 +07:00
asepharyana 6c5c5db47b feat(dimentorin): mentor register flow wired to API (4-step form, validation, submit, redirect pending) 2026-08-04 20:04:41 +07:00
asepharyana 583b6b224e feat(dimentorin): articles page integrated with API (list, filter, detail) 2026-08-04 19:31:29 +07:00
asepharyana ce0579de5d fix(dimentorin): unwrap paginated response + local dev API URL 2026-08-04 18:15:57 +07:00
asepharyana 7df4592a47 feat(dimentorin): integrate mentors API - service layer + public list/detail pages 2026-08-04 17:55:59 +07:00
asepharyana 77eb755783 merge: integrate dimentorin landing pages (mentoring/articles/resources) with auth branch 2026-08-04 17:09:37 +07:00
euxzy 8c6a85090d feat(dimentorin): slicing landing - detail articles page 2025-07-28 21:33:58 +07:00
euxzy a6134cb32a feat(dimentorin): slicing landing - articles page 2025-07-28 20:45:29 +07:00
euxzy d9c5ff0b18 feat(dimentorin): slicing landing - resources page 2025-07-27 20:21:41 +07:00
euxzy 924354366c feat(dimentorin): slicing modal booking mentor 2025-07-26 22:12:47 +07:00
euxzy dce74c5c7c Merge remote-tracking branch 'origin/develop' into feat/dimentorin-landing-page 2025-07-23 18:49:55 +07:00
euxzy d4c263cad3 feat(dimentorin): slicing landing page detail mentor 2025-07-22 08:20:59 +07:00
boboiazumi e2be5105e9 feat: mentor status page pending and success 2025-07-12 22:38:23 +07:00
boboiazumi eb5ff2e46c feat: mentor register pages 2025-07-12 22:15:43 +07:00
boboiazumi e1269f4daa fix: conflict 2025-07-10 10:18:44 +07:00
boboiazumi 487623e4fb Merge branch 'develop' of https://github.com/IMPHNEN/imphnen-frontend-service into develop
merge
2025-07-10 10:07:36 +07:00
boboiazumi eff44c42a1 stash 2025-07-10 10:07:26 +07:00
euxzy 3e232dcdc2 feat(dimentorin): slicing landing page menu mentoring 2025-07-10 08:01:11 +07:00
boboiazumi aa52b62ae7 fix: minor 2025-06-19 21:36:08 +07:00
boboiazumi 3dec77bd0a Merge remote-tracking branch 'origin/DimentorinAuth' into DimentorinAuth
merge
2025-05-30 14:27:00 +07:00
boboiazumi ec81922001 fix: conflict 2025-05-30 14:26:04 +07:00
boboiazumi d7b70b21d1 Merge branch 'develop' of https://github.com/IMPHNEN/imphnen-frontend-service into develop
merge
2025-05-30 14:20:02 +07:00
boboiazumi a7cf85dd66 feat: OTP Resend 2025-05-30 14:19:36 +07:00
Naufal AzmiandGitHub bb22068856 Update middleware.ts 2025-05-26 20:25:07 +07:00
Naufal AzmiandGitHub 209fa3fff1 Merge branch 'develop' into DimentorinAuth 2025-05-26 20:22:04 +07:00
boboiazumi 935d23887c feat: register and otp integration 2025-05-26 20:14:26 +07:00
boboiazumi 363f1582c9 fix: button disabled when loading 2025-05-26 17:05:02 +07:00
boboiazumi 1a9b47953a fix: conflict 2025-05-26 16:28:46 +07:00
boboiazumi 182ff7facc fix: conflict 2025-05-25 10:12:21 +07:00
boboiazumi af8968ac01 Merge branch 'develop' of https://github.com/IMPHNEN/imphnen-frontend-service into develop
merge develop
2025-05-25 09:30:39 +07:00
boboiazumi 4bd66af0ff fix: login page button disabled & register page 2025-05-25 09:24:24 +07:00
boboiazumi 73826fb4c3 feat: auth login responsive && auth login implementation 2025-04-08 12:28:18 +07:00
boboiazumi 2f9407e258 Merge branch 'develop' of https://github.com/IMPHNEN/imphnen-frontend-service into develop 2025-04-07 21:35:47 +07:00
boboiazumi 534bf0e10a fix: conflict 2025-04-07 21:35:35 +07:00
boboiazumi df479a0a8d fix: success register 2025-04-07 20:48:19 +07:00
boboiazumi 1948d2c67f feat: responsive 2025-04-07 20:40:58 +07:00
98 changed files with 3867 additions and 3616 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

@@ -1,52 +0,0 @@
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"
import { PieLabelProps } from "recharts/types/polar/Pie"
type ChartProps = {
name: string
value: number
color: string
}
const chartData: ChartProps[] = [
{ name: "Active", value: 49, color: "#23A1EB" },
{ name: "Done", value: 24, color: "#81CBF8" },
{ name: "Canceled", value: 27, color: "#BCE1FB" },
]
const RADIAN = Math.PI / 180;
const renderCustomizedLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent }: PieLabelProps) => {
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const x = cx + radius * Math.cos(-(midAngle ?? 0) * RADIAN);
const y = cy + radius * Math.sin(-(midAngle ?? 0) * RADIAN);
return (
<text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central">
{`${((percent ?? 1) * 100).toFixed(0)}%`}
</text>
);
};
export const SessionStatusChart = () => {
return (
<ResponsiveContainer width="100%" height={320}>
<PieChart width={500} height={320}>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={40}
outerRadius={100}
labelLine={false}
label={renderCustomizedLabel}
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
)
}
@@ -1,36 +0,0 @@
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
type ChartProps = {
name: string
activeUser: number
activeSession: number
}
const chartData: ChartProps[] = [
{ name: "2014", activeUser: 0, activeSession: 0 },
{ name: "2015", activeUser: 15, activeSession: 25 },
{ name: "2016", activeUser: 30, activeSession: 40 },
{ name: "2017", activeUser: 45, activeSession: 55 },
{ name: "2018", activeUser: 60, activeSession: 70 },
{ name: "2019", activeUser: 75, activeSession: 85 },
{ name: "2020", activeUser: 90, activeSession: 95 },
{ name: "2021", activeUser: 85, activeSession: 80 },
{ name: "2022", activeUser: 95, activeSession: 90 },
{ name: "2023", activeUser: 100, activeSession: 100 },
]
export const UserGrowthChart = () => {
return (
<ResponsiveContainer width="100%" height={320}>
<LineChart data={chartData} width={500} height={320} margin={{ left: -32 }}>
<CartesianGrid />
<XAxis dataKey="name" />
<YAxis tickCount={10} />
<Tooltip />
<Legend />
<Line dataKey="activeUser" stroke="#23A1EB" />
<Line dataKey="activeSession" stroke="#0877C1" />
</LineChart>
</ResponsiveContainer>
)
}
@@ -1,115 +0,0 @@
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { BackofficeWrapper } from "@imphnen-frontend-service/ui/organisms";
import { For } from "@imphnen-frontend-service/utils";
import { ReactElement } from "react";
import { UserGrowthChart } from "./_components/chart/user-growth";
import { SessionStatusChart } from "./_components/chart/session-status";
const Overview = () => {
return (
<div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">0</h3>
<p className="text-neutral-400 text-p3">Total Users</p>
</div>
)
}
export default function Components(): ReactElement {
return (
<BackofficeWrapper title="Dimentorin.dev">
<h1 className="text-p1 font-semibold text-neutral-700 mb-5">Overview</h1>
<div className="space-y-14">
<div>
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
Overview
</Button>
<div className="grid grid-cols-5 gap-5">
<For data={Array.from({ length: 5 })}>
{(_, index) => <Overview key={index} />}
</For>
</div>
</div>
<div>
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
Trends & Analytics
</Button>
<div className="grid grid-cols-7 gap-x-5">
<div className="bg-white px-6 py-4 rounded-lg col-span-5">
<div className="flex items-center justify-between mb-7">
<h2 className="font-semibold text-p3 text-neutral-700">User Growth</h2>
<div></div>
</div>
<UserGrowthChart />
</div>
<div className="bg-white px-6 py-4 rounded-lg col-span-2">
<h2 className="font-semibold text-p3 text-neutral-700 mb-7">Session Status</h2>
<SessionStatusChart />
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-x-5">
<div className="bg-white px-7 py-4 rounded-lg">
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top 5 Mentors</h2>
<div>
<table className="w-full">
<thead>
<tr className="text-label1 bg-primary-50 text-left rounded-full">
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
<th className="font-medium py-4 px-5 w-3/5">Nama Lengkap</th>
<th className="font-medium py-4 px-5 rounded-r-lg">Avg Rating</th>
</tr>
</thead>
<tbody>
<For data={Array.from({ length: 5 })}>
{(_, index) => (
<tr key={index} className="shadow rounded-lg">
<td className="py-4 px-5">{index + 1}</td>
<td className="py-4 px-5">Mursid Al-Catraz</td>
<td className="py-4 px-5">4.9</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
<div className="bg-white px-6 py-4 rounded-lg">
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top Booked Mentoring Topics</h2>
<div>
<table className="w-full">
<thead>
<tr className="text-label1 bg-primary-50 text-left font-medium">
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
<th className="font-medium py-4 px-5 w-3/5">Nama Lengkap</th>
<th className="font-medium py-4 px-5 rounded-r-lg">Total Sesi</th>
</tr>
</thead>
<tbody>
<For data={Array.from({ length: 5 })}>
{(_, index) => (
<tr key={index} className="shadow rounded-lg">
<td className="py-4 px-5">{index + 1}</td>
<td className="py-4 px-5">Mursid Al-Catraz</td>
<td className="py-4 px-5">1000</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
</div>
</div>
</BackofficeWrapper>
)
}
@@ -1,181 +0,0 @@
import { SearchOutlined } from "@ant-design/icons";
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
import { cn, For } from "@imphnen-frontend-service/utils";
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
import { ReactElement, useState } from "react"
const TABS = {
MENTORING: 'Mentoring',
PLATFORM: 'Platform'
} as const
type Tabs = typeof TABS[keyof typeof TABS]
type FeedbackStatus = 'done' | 'todo';
interface FeedbackType {
id: number
name: string
email: string
rating: number
status: FeedbackStatus
}
const mockData: FeedbackType[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
name: i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana',
email: 'fullname23@gmail.com',
rating: 4.5,
status: i % 2 === 0 ? 'done' : 'todo',
}))
export default function Components(): ReactElement {
const [activeTab, setActiveTab] = useState<Tabs>(TABS.MENTORING)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const columns: ColumnDef<FeedbackType>[] = [
{
id: 'select',
meta: { cellClassName: cn("w-20") },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'name',
header: 'Name',
accessorKey: 'name',
},
{
id: 'email',
header: 'Email',
accessorKey: 'email',
},
{
id: 'rating',
header: 'Rating',
accessorKey: 'rating',
},
{
id: 'status',
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<FeedbackStatus, string> = {
done: 'bg-success-200 text-success-500',
todo: 'bg-primary-200 text-primary-500',
};
const statusText: Record<FeedbackStatus, string> = {
done: 'Done',
todo: 'To Do',
};
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
},
{
header: 'Action',
meta: { cellClassName: cn("w-72") },
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation()
}}
className="flex items-center gap-2 w-max"
>
<SearchOutlined className="text-[16px]" /> Lihat Feedback
</Button>
),
},
]
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<BackofficeWrapper title="Dimentorin.dev">
<div className="mb-8 flex justify-between items-center">
<h1 className="text-p1 font-semibold text-neutral-700">Feedback</h1>
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
<For data={Object.values(TABS)}>
{(tab) => (
<Button
key={tab}
variant="text"
className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
onClick={() => setActiveTab(tab)}
>
{tab}
</Button>
)}
</For>
</div>
</div>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-5 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama mentor/mentee"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<Select>
<option selected disabled>Rating</option>
<option value="4.5">4.5</option>
<option value="5">5</option>
</Select>
<Select>
<option selected disabled>Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</Select>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</section>
</BackofficeWrapper>
);
}
@@ -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">
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
<Outlet />
</div>
</div>
@@ -1,85 +0,0 @@
import { ArrowRightOutlined } from "@ant-design/icons";
import { Button, Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms";
import { Accordion, Modal, ModalProps } from "@imphnen-frontend-service/ui/molecules";
import { cn, For } from "@imphnen-frontend-service/utils";
import { FC } from "react";
const labelClass = cn('text-neutral-800 text-[10px] font-medium mb-1.5 inline-block md:text-xs md:mb-2 xl:text-p3')
export const ModalCreateRoadmap: FC<Omit<ModalProps, 'children'>> = ({ isOpen, onClose }) => {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="xl:max-w-[64rem] bg-white px-10 py-9"
closeButtonClassName="hidden"
>
<div className="overflow-y-auto max-h-[80vh]">
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
Create Roadmaps
</h1>
<div className="grid grid-cols-5 gap-6 mb-12">
<div className="col-span-3">
<label className={labelClass}>Prompt</label>
<Textarea
className="min-w-full w-full h-[calc(100%-2rem)]"
placeholder="Create a learning roadmap for (subject) at the (level) level. Include topics, estimated duration, and logical progression."
/>
</div>
<div className="col-span-2 space-y-8">
<div>
<label className={labelClass}>Roadmap Name</label>
<Input type="text" className="min-w-full w-full" placeholder="Nama Roadmap" />
</div>
<div>
<label className={labelClass}>Roadmap Name</label>
<Select className="w-full">
<option value="pemula">Pemula</option>
<option value="menengah">Menengah</option>
</Select>
</div>
<div>
<label className={labelClass}>Model</label>
<Select className="w-full">
<option value="gpt-3.5-turbo">GPT 3.5 Turbo</option>
<option value="gpt-4">GPT 4</option>
<option value="gpt-4-32k">GPT 4 32k</option>
</Select>
</div>
</div>
<div className="col-span-full">
<Button>
Generate Roadmap
</Button>
</div>
</div>
<div>
<h2 className="text-neutral-600 text-p2 font-semibold mb-8">
Roadmap Preview
</h2>
<div className="space-y-2.5">
<For data={Array.from({ length: 3 })}>
{(_, index) => (
<Accordion
key={index}
title={`Day ${index + 1}`}
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit. In convallis tincidunt nisl, id consequat mi malesuada vel. Nulla facilisi. Nam in turpis ligula."
/>
)}
</For>
</div>
</div>
<div className="flex justify-end mt-12">
<Button type="button" className="flex items-center gap-5">
Submit Roadmap
<ArrowRightOutlined />
</Button>
</div>
</div>
</Modal>
)
}
@@ -1,174 +0,0 @@
import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons";
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
import { cn } from "@imphnen-frontend-service/utils";
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
import { useState } from "react";
import { ModalCreateRoadmap } from "./_components/modal/create-roadmap";
type LearningStatus = 'active' | 'inactive'
type RoadmapType = {
id: number
name: string
learningLevel: string
status: LearningStatus
}
const mockData: RoadmapType[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
name: i === 0 ? 'Ahmad Wijuana' : 'Anna Wiguana',
learningLevel: ['Pemula', 'Menengah'][Math.floor(Math.random() * 2)],
status: i % 2 === 0 ? 'active' : 'inactive',
}))
export default function Components(): React.ReactElement {
const [openCreateModal, setOpenCreateModal] = useState(false)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const columns: ColumnDef<RoadmapType>[] = [
{
id: 'select',
meta: { cellClassName: cn("w-20") },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'id',
header: 'No',
accessorKey: 'id',
},
{
id: 'name',
header: 'Nama Roadmap',
accessorKey: 'name',
},
{
id: 'learningLevel',
header: 'Tingkat Belajar',
accessorKey: 'learningLevel',
},
{
id: 'status',
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<LearningStatus, string> = {
inactive: 'bg-danger-200 text-danger-700',
active: 'bg-success-200 text-success-500',
};
const statusText: Record<LearningStatus, string> = {
inactive: 'Inactive',
active: 'Active',
};
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
},
{
header: 'Action',
meta: { cellClassName: cn("w-72") },
cell: ({ row }) => (
<div className="flex gap-[8px]">
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-2"
>
<EditOutlined /> Action
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-2"
>
<DeleteOutlined /> Delete
</Button>
</div>
),
},
]
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<BackofficeWrapper title="Dimentorin.dev">
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Content & Roadmap</h1>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex items-center justify-between mb-9">
<h2 className="text-p2 font-semibold text-neutral-600">AI Roadmaps</h2>
<Button type="button" variant="primary" className="flex items-center gap-2" onClick={() => setOpenCreateModal(true)}>
<PlusOutlined /> Buat Roadmap
</Button>
</div>
<div className="flex justify-between items-center gap-5 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama roadmap"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<Select>
<option selected disabled>Tingkat Belajar</option>
<option value="pemula">Pemula</option>
<option value="menengah">Menengah</option>
</Select>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</section>
<ModalCreateRoadmap isOpen={openCreateModal} onClose={() => setOpenCreateModal(false)} />
</BackofficeWrapper>
)
}
@@ -1,114 +0,0 @@
import { Icon } from "@iconify/react";
import { Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms";
import { Modal } from "@imphnen-frontend-service/ui/molecules";
import { cn, For } from "@imphnen-frontend-service/utils";
import { FC } from "react";
const TOPICS = [
{ id: 2, icon: '🏢', name: 'Industry Insight' },
{ id: 4, icon: '🖥️', name: 'Basic IT' },
]
const placeholder = `Hi [Nama Mentor], Saya [Nama Kamu] & saya berharap dapat memiliki sesi mentoring dengan Anda.
Saat ini, saya tertarik untuk mengejar __. Tujuan saya untuk sesi ini adalah __.
Saya ingin tahu secara khusus tentang ___.
1.Pertanyaan Anda
2. ...
3. ...`
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
type ModalProps = {
open: boolean
setOpen: (open: boolean) => void
}
export const ModalDetailSession: FC<ModalProps> = ({ open, setOpen }) => {
return (
<Modal
isOpen={open}
onClose={() => setOpen(false)}
className="xl:max-w-[64rem] bg-white px-10 py-9"
closeButtonClassName="hidden"
>
<div>
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
Detail Sesi
</h1>
<div className="grid grid-cols-9 px-9 py-7 border rounded-md gap-12 mb-10">
<div className="col-span-4 flex items-center gap-x-12">
<div>
<h2 className="text-p2 font-semibold text-primary-500 mb-4">Mentor</h2>
<div>
<p className="text-p3 font-semibold mb-2.5">Muhammad Firdaus Oi Oi Oi, S.H., M.H.</p>
<p className="text-neutral-600">UI Designer at Oray orayan Studios</p>
</div>
</div>
<Icon icon="ph:arrow-right" className="text-9xl text-primary-500" />
</div>
<div className="col-span-5 flex items-center gap-12">
<div>
<h2 className="text-p2 font-semibold text-primary-500 mb-4">Mentee</h2>
<div>
<p className="text-p3 font-semibold mb-2.5">Muhammad Firdaus Oi Oi Oi, S.H., M.H.</p>
<p className="text-neutral-600">UI Designer at Oray orayan Studios</p>
</div>
</div>
<div>
<h2 className="text-p2 font-semibold text-neutral-700 mb-4">Status</h2>
<div className="py-2 px-6 rounded-md text-center bg-success-200 text-success-500 font-semibold">
Finished
</div>
</div>
</div>
</div>
<div>
<h1 className="text-p3 font-medium mb-2.5">Topics</h1>
<div className="p-5 bg-primary-50 border border-primary-100 rounded-md flex flex-wrap gap-2.5 mb-8">
<For data={TOPICS}>
{(item, index) => (
<div
key={index}
className="px-2.5 py-2 text-neutral-800 bg-white border border-primary-100 rounded-md shadow font-medium"
>
<span>{item.icon} </span>
<span>{item.name}</span>
</div>
)}
</For>
</div>
<div className="grid gap-2.5 md:grid-cols-2 md:gap-5">
<div>
<label className={labelClass}>Tanggal</label>
<Input type="date" className="min-w-full w-full" />
</div>
<div>
<label className={labelClass}>Waktu</label>
<Input type="time" className="min-w-full w-full" />
</div>
<div className="relative md:col-span-full">
<label className={labelClass}>Lokasi</label>
<Select className="min-w-full w-full">
<option value="online">Online</option>
<option value="offline">Offline</option>
</Select>
</div>
<div className="md:col-span-full">
<label className={labelClass}>Pertanyaan Untuk Senpai</label>
<Textarea
className="min-w-full w-full h-40"
placeholder={placeholder}
/>
</div>
</div>
</div>
</div>
</Modal>
)
}
@@ -1,168 +0,0 @@
import { SearchOutlined } from "@ant-design/icons";
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
import { cn } from "@imphnen-frontend-service/utils";
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
import { ReactElement, useState } from "react";
import { ModalDetailSession } from "./_components/modal/detail";
type SessionStatus = 'ongoing' | 'finished';
interface SessionType {
id: string
mentorName: string
menteeName: string
datetime: number
status: SessionStatus
}
const mockData: SessionType[] = Array.from({ length: 90 }, (_, i) => ({
id: `DS-${i + 1}`,
mentorName: 'Ahmad Wijuana',
menteeName: 'Sofia Wijuana',
datetime: new Date().getTime(),
status: i % 2 === 0 ? 'ongoing' : 'finished',
}))
export default function Components(): ReactElement {
const [openDetail, setOpenDetail] = useState(false);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const columns: ColumnDef<SessionType>[] = [
{
id: 'select',
meta: { cellClassName: cn("w-20") },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'id',
header: 'ID Sesi',
accessorKey: 'id',
},
{
id: 'mentorName',
header: 'Nama Mentor',
accessorKey: 'name',
},
{
id: 'menteeName',
header: 'Nama Mentee',
accessorKey: 'name',
},
{
id: 'datetime',
header: 'Waktu',
accessorKey: 'datetime',
},
{
id: 'status',
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<SessionStatus, string> = {
ongoing: 'bg-warning-200 text-warning-700',
finished: 'bg-success-200 text-success-500',
};
const statusText: Record<SessionStatus, string> = {
ongoing: 'On Going',
finished: 'Finished',
};
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
},
{
header: 'Action',
meta: { cellClassName: cn("w-52") },
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setOpenDetail(true);
}}
className="flex items-center gap-2 w-max"
>
<SearchOutlined className="text-[16px]" /> Cek Detail
</Button>
),
},
]
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<BackofficeWrapper title="Dimentorin.dev">
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Session Management</h1>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-5 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama lengkap"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<Select>
<option selected disabled>Rating</option>
<option value="4.5">4.5</option>
<option value="5">5</option>
</Select>
<Select>
<option selected disabled>Status</option>
<option value="finished">Finished</option>
<option value="ongoing">On Going</option>
</Select>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</section>
<ModalDetailSession open={openDetail} setOpen={setOpenDetail} />
</BackofficeWrapper>
)
}
@@ -1,61 +0,0 @@
import { Button, Input, Select, ToggleInput } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils"
import { FC } from "react"
const labelClass = cn('text-neutral-800 text-[10px] font-medium mb-1.5 inline-block md:text-xs md:mb-2 xl:text-p3')
export const GeneralSettings: FC = () => {
return (
<div>
<h1 className="text-p2 font-semibold text-neutral-700 mb-8">General Settings</h1>
<div>
<ToggleInput label="Mode Maintenance" />
<h2 className="text-p3 font-semibold text-neutral-700 mb-5">Platform Settings</h2>
<div className="grid grid-cols-2 gap-x-8 gap-y-5 mb-8">
<div>
<label className={labelClass}>Nama Platform</label>
<Input type="text" className="min-w-full w-full" />
</div>
<div>
<label className={labelClass}>Bahasa</label>
<Select defaultValue="id" className="w-full">
<option value="id">Indonesia</option>
<option value="en">English</option>
</Select>
</div>
<div>
<label className={labelClass}>Logo Platform</label>
<Input type="file" className="min-w-full w-full" />
</div>
<div>
<label className={labelClass}>Favicon</label>
<Input type="file" className="min-w-full w-full" />
</div>
</div>
<h2 className="text-p3 font-semibold text-neutral-700 mb-5">Legal Settings</h2>
<div className="grid gap-x-8 gap-y-5 mb-20">
<div>
<label className={labelClass}>URL Syarat & Ketentuan</label>
<Input type="text" className="min-w-full w-full" />
</div>
<div>
<label className={labelClass}>URL Kebijakan Privasi</label>
<Input type="text" className="min-w-full w-full" />
</div>
</div>
<div className="flex justify-end gap-5">
<Button type="button" variant="bordered">
Batal
</Button>
<Button type="button">
Simpan
</Button>
</div>
</div>
</div>
)
}
@@ -1,34 +0,0 @@
import { Button, Textarea, ToggleInput } from "@imphnen-frontend-service/ui/atoms"
import { FC } from "react"
export const NotificationSettings: FC = () => {
return (
<div>
<h1 className="text-p2 font-semibold text-neutral-700 mb-8">Notification Settings</h1>
<div>
<div className="flex items-center gap-8 flex-wrap">
<ToggleInput label="Nyalakan Notifikasi Email" />
<ToggleInput label="Beritahu mentor ketika ada request" />
<ToggleInput label="Beritahu mentee untuk update sesi " />
</div>
<div className="mb-20">
<label className="text-neutral-800 font-medium inline-block mb-2 text-p3">
API Integrasi Notifikasi (URL)
</label>
<Textarea placeholder="Masukkan url API notifikasi yang akan digunakan" className="w-full h-40" />
</div>
<div className="flex justify-end gap-5">
<Button type="button" variant="bordered">
Batal
</Button>
<Button type="button">
Simpan
</Button>
</div>
</div>
</div>
)
}
@@ -1,50 +0,0 @@
import { Button, Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils"
import { FC } from "react"
const labelClass = cn('text-neutral-800 text-[10px] font-medium mb-1.5 inline-block md:text-xs md:mb-2 xl:text-p3')
export const PaymentSettings: FC = () => {
return (
<div>
<h1 className="text-p2 font-semibold text-neutral-700 mb-8">Payment</h1>
<div className="grid grid-cols-2 gap-x-8 gap-y-5 mb-8">
<div>
<label className={labelClass}>Integrasi Payment Gateway</label>
<Textarea className="min-w-full w-full h-20" placeholder="Durasi dalam menit" />
</div>
<div>
<label className={labelClass}>Mata Uang</label>
<Select defaultValue="idr">
<option value="idr">Rupiah (IDR)</option>
<option value="usd">Dollar (USD)</option>
</Select>
</div>
<div>
<label className={labelClass}>Harga sesi mentoring <span className="text-neutral-600">(default)</span></label>
<Input type="text" className="min-w-full w-full" placeholder="Masukkan harga sesi mentoring" />
</div>
<div>
<label className={labelClass}>Tarif Komisi untuk Platform</label>
<Input type="text" className="min-w-full w-full" placeholder="Masukkan persentase komisi" />
</div>
</div>
<h2 className="text-p3 font-semibold text-neutral-700 mb-5">Invoice</h2>
<div className="mb-32">
<label className={labelClass}>Masukkan Format Invoice</label>
<Input type="file" className="min-w-full w-full" placeholder="InvoiceDimentorin.png" />
</div>
<div className="flex justify-end gap-5">
<Button type="button" variant="bordered">
Batal
</Button>
<Button type="button">
Simpan
</Button>
</div>
</div>
)
}
@@ -1,35 +0,0 @@
import { Button, Input } from "@imphnen-frontend-service/ui/atoms";
import { cn } from "@imphnen-frontend-service/utils";
import { FC } from "react";
const labelClass = cn('text-neutral-800 text-[10px] font-medium mb-1.5 inline-block md:text-xs md:mb-2 xl:text-p3')
export const SecuritySettings: FC = () => {
return (
<div>
<h1 className="text-p2 font-semibold text-neutral-700 mb-8">Security Settings</h1>
<div className="grid grid-cols-2 gap-8 mb-20">
<div>
<label className={labelClass}>Durasi Session Timeout</label>
<Input type="text" className="min-w-full w-full" placeholder="Durasi dalam menit" />
<p className="text-[10px] text-neutral-800">Auto logout setelah X menit tidak aktif</p>
</div>
<div>
<label className={labelClass}>Blokir Setelah Upaya Gagal</label>
<Input type="text" className="min-w-full w-full" placeholder="x kali perobaan login" />
<p className="text-[10px] text-neutral-800">Misal: 5 kali salah login = lock akun</p>
</div>
</div>
<div className="flex justify-end gap-5">
<Button type="button" variant="bordered">
Batal
</Button>
<Button type="button">
Simpan
</Button>
</div>
</div>
)
}
@@ -1,118 +0,0 @@
import { DeleteOutlined, UserSwitchOutlined } from "@ant-design/icons";
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { DataTable } from "@imphnen-frontend-service/ui/organisms";
import { cn } from "@imphnen-frontend-service/utils";
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
import { FC, useState } from "react";
type UserRolesPermissionType = {
id: number
role: string
totalUser: number
}
const mockData: UserRolesPermissionType[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
role: ['Admin', 'Super Admin', 'Mentee', 'Mentor'][Math.floor(Math.random() * 4)],
totalUser: 10
}))
export const UserRolesPermission: FC = () => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const columns: ColumnDef<UserRolesPermissionType>[] = [
{
id: 'select',
meta: { cellClassName: cn("w-20") },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'role',
header: 'Role',
accessorKey: 'role',
},
{
id: 'totalUser',
header: 'Total User',
accessorKey: 'totalUser',
},
{
header: 'Action',
meta: { cellClassName: cn("w-96") },
cell: ({ row }) => (
<div className="flex items-center gap-4">
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-2 w-max"
>
<UserSwitchOutlined className="text-[16px]" /> Manage Permissions
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-2 w-max"
>
<DeleteOutlined className="text-[16px]" /> Delete Role
</Button>
</div>
),
},
]
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<div>
<div className="mb-8 flex items-center justify-between">
<h1 className="text-p2 font-semibold text-neutral-700">User Roles & Permissions</h1>
<Button type="button">
Add Rols
</Button>
</div>
<div className="bg-white shadow p-8 rounded-lg">
<DataTable data={mockData} columns={columns} table={table} />
</div>
</div>
)
}
@@ -1,53 +0,0 @@
import { BackofficeWrapper } from "@imphnen-frontend-service/ui/organisms"
import { cn, For } from "@imphnen-frontend-service/utils"
import { useState } from "react"
import { GeneralSettings } from "./_components/general"
import { UserRolesPermission } from "./_components/user-roles-permission"
import { NotificationSettings } from "./_components/notification"
import { SecuritySettings } from "./_components/security"
import { PaymentSettings } from "./_components/payment"
const TABS = {
general: "General Settings",
userRolePermissions: "User Roles & Permissions",
notification: "Notification Settings",
security: "Security",
payment: "Payment",
} as const
type Tabs = typeof TABS[keyof typeof TABS]
export default function Components(): React.ReactElement {
const [activeTab, setActiveTab] = useState<Tabs>(TABS.general)
return (
<BackofficeWrapper title="Dimentorin.dev">
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Settings</h1>
<div className="flex items-start gap-x-8">
<div className="w-64 bg-white p-2.5 shadow space-y-2 rounded-md">
<For data={Object.values(TABS)}>
{(tab) => (
<button
key={tab}
className={cn(
"px-4 py-3 w-full text-left font-medium rounded-md text-neutral-400 cursor-pointer select-none hover:bg-primary-100",
activeTab === tab && "bg-primary-500 text-white hover:bg-primary-600"
)}
onClick={() => setActiveTab(tab)}
>
{tab}
</button>
)}
</For>
</div>
<div className="bg-white px-8 py-6 shadow space-y-2 rounded-md flex-1">
{activeTab === TABS.general && <GeneralSettings />}
{activeTab === TABS.userRolePermissions && <UserRolesPermission />}
{activeTab === TABS.notification && <NotificationSettings />}
{activeTab === TABS.security && <SecuritySettings />}
{activeTab === TABS.payment && <PaymentSettings />}
</div>
</div>
</BackofficeWrapper>
)
}
@@ -1,55 +0,0 @@
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { Modal } from "@imphnen-frontend-service/ui/molecules";
import { FC } from "react";
type ModalDeleteProps = {
open: boolean
setOpen: (open: boolean) => void
hadnleDelete?: () => void
}
export const ModalDelete: FC<ModalDeleteProps> = ({ open, setOpen, hadnleDelete }) => {
return (
<Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
isOpen={open}
onClose={() => setOpen(false)}
closeButtonClassName="hidden"
>
<Modal.Header className="gap-8">
<img
src="/chibi-delete.webp"
alt="Delete item?"
width={148}
className="self-center"
/>
<div className="text-center">
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
Hapus Akun
</h2>
<p className="text-p3 text-neutral-400">
Apakah kamu yakin untuk menghapus akun ini?
</p>
</div>
</Modal.Header>
<Modal.Content className="flex gap-4">
<Button
variant="bordered"
size="lg"
className="w-full border-danger-500 text-danger-500 hover:bg-danger-50 hover:text-danger-600 hover:border-danger-600"
onClick={() => setOpen(false)}
>
Batal
</Button>
<Button
variant="danger"
size="lg"
className="w-full"
onClick={() => hadnleDelete?.()}
>
Hapus Akun
</Button>
</Modal.Content>
</Modal>
)
}
@@ -1,86 +0,0 @@
import { Button, Input } from "@imphnen-frontend-service/ui/atoms";
import { cn } from "@imphnen-frontend-service/utils";
import { FC, useState } from "react";
import { ModalDelete } from "../delete";
import { ModalProps } from "../type";
import { ModalSuspendOrBan } from "../suspend-or-ban";
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
export const AccountProfile: FC<ModalProps> = ({ setOpen }) => {
const [openDelete, setOpenDelete] = useState(false)
const [openSuspendOrBan, setOpenSuspendOrBan] = useState(false)
return (
<div>
<div className="flex items-center gap-x-8 mb-8">
<div className="size-[100px] rounded-full overflow-hidden">
<img src="/images/asd687hwq6nds4dfjj2983.webp" alt="Profile" className="w-full object-cover" />
</div>
<div>
<Button type="button" size="sm" variant="bordered" className="mb-4">
Upload Foto
</Button>
<p className="text-neutral-400 font-medium">
Setidaknya rekomendasi ukuran 240x240 px. <br />
.jpg, .jpeg, .png diperbolehkan
</p>
</div>
</div>
<div>
<div className="mb-8">
<h1 className="mb-7 text-p2 font-semibold">Informasi Pribadi</h1>
<div className="grid grid-cols-2 gap-8">
<div>
<label className={labelClass}>Nama Depan</label>
<Input type="text" className="min-w-full w-full" placeholder="Nama Depan" />
</div>
<div>
<label className={labelClass}>Nama Belakang</label>
<Input type="text" className="min-w-full w-full" placeholder="Nama Belakang" />
</div>
<div>
<label className={labelClass}>Email</label>
<Input type="email" className="min-w-full w-full" placeholder="Email" />
</div>
<div>
<label className={labelClass}>Nomor Telepon</label>
<Input type="text" className="min-w-full w-full" placeholder="+62 81234567890" />
</div>
</div>
</div>
<div>
<h1 className="mb-7 text-p2 font-semibold">Status Akun</h1>
<div className="flex items-center gap-5">
<Button
type="button"
size="sm"
variant="bordered"
className="border-danger-500 text-danger-500"
onClick={() => setOpenSuspendOrBan(true)}
>
Suspend/Ban
</Button>
<Button type="button" size="sm" variant="danger" onClick={() => setOpenDelete(true)}>
Delete Akun
</Button>
</div>
</div>
<div className="flex justify-end gap-x-5">
<Button type="button" variant="bordered" onClick={() => setOpen(false)}>
Batal
</Button>
<Button type="button" disabled>
Simpan
</Button>
</div>
</div>
<ModalDelete open={openDelete} setOpen={setOpenDelete} />
<ModalSuspendOrBan open={openSuspendOrBan} setOpen={setOpenSuspendOrBan} />
</div>
)
}
@@ -1,110 +0,0 @@
import { SearchOutlined } from "@ant-design/icons"
import { Input, Select } from "@imphnen-frontend-service/ui/atoms"
import { DataTable } from "@imphnen-frontend-service/ui/organisms"
import { cn } from "@imphnen-frontend-service/utils"
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table"
import { FC, useState } from "react"
import dayjs from "dayjs"
interface ActivityLogType {
id: number
date: string
menu: string
activity: string
}
const mockData: ActivityLogType[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
date: new Date().toISOString(),
menu: 'Mentoring',
activity: 'Mentoring Session',
}))
export const ActivityLog: FC = () => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
})
const columns: ColumnDef<ActivityLogType>[] = [
{
id: 'select',
meta: { cellClassName: cn("w-16") },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'date',
header: 'Waktu',
accessorKey: 'date',
cell: (info) => dayjs(info.row.original.date).format('DD MMMM YYYY, HH:mm WIB'),
},
{
id: 'menu',
header: 'Menu',
accessorKey: 'menu',
},
{
id: 'activity',
header: 'Activity',
accessorKey: 'activity',
}
]
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<div className="p-8 shadow rounded-lg">
<div className="flex justify-between items-center gap-5 mb-9">
<div className="relative w-1/2">
<Input
placeholder="Cari aktivitas"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="w-1/4">
<Input type="date" className="min-w-full w-full" />
</div>
<Select>
<option selected disabled>Menu</option>
<option value="profle">Profile</option>
<option value="profle-2">Profile</option>
</Select>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</div>
)
}
@@ -1,89 +0,0 @@
import { Icon } from "@iconify/react"
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { For } from "@imphnen-frontend-service/utils"
import { FC } from "react"
const SOCIAL_LINKS = [
{ icon: <Icon icon="mdi:linkedin" className="text-2xl" />, url: "https://linkedin.com", label: "LinkedIn" },
{ icon: <Icon icon="mdi:github" className="text-2xl" />, url: "https://github.com", label: "Github" },
{ icon: <Icon icon="mingcute:meta-line" className="text-2xl" />, url: "https://facebook.com", label: "Facebook (Meta)" },
{ icon: <Icon icon="mdi:stack-overflow" className="text-2xl" />, url: "https://stackoverflow.com", label: "Stack Overflow" }
]
export const DetailProfile: FC = () => {
return (
<div>
<div className="shadow rounded-lg p-8 mb-7">
<div className="flex justify-between items-start mb-6">
<div className="flex items-center gap-x-6">
<div className="size-[54px] rounded-full overflow-hidden">
<img src="/images/asd687hwq6nds4dfjj2983.webp" alt="Profile" className="w-full object-cover" />
</div>
<div>
<h1 className="text-p2 font-semibold text-neutral-800">Muhammad Firdaus Oiwobo</h1>
<p className="text-p3 font-medium text-neutral-600">Mentor</p>
</div>
</div>
<Button type="button" variant="text" className="bg-primary-100">
Actively Seeking Job
</Button>
</div>
<div className="flex items-center gap-5">
<For data={SOCIAL_LINKS}>
{({ icon, url, label }) => (
<a key={url} href={url} target="_blank" rel="noreferrer">
<Button type="button" size="sm" className="flex items-center gap-x-2">
{icon}
{label}
</Button>
</a>
)}
</For>
</div>
</div>
<div className="grid grid-cols-2 gap-7">
<div className="text-pretty px-8 py-10 rounded-lg shadow h-max">
<h1 className="text-p2 font-semibold text-neutral-800 mb-6">Description</h1>
<p className="text-p3 font-medium text-neutral-600">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
</p>
</div>
<div className="px-8 py-10 rounded-lg shadow h-max">
<h1 className="text-p2 font-semibold text-neutral-800 mb-6">Personal Informations</h1>
<div className="grid gap-6">
<div className="flex items-center gap-x-5">
<div className="bg-primary-50 rounded-full p-2.5 flex items-center justify-center text-primary-500">
<Icon icon="ic:outline-mail" className="text-3xl" />
</div>
<div className="text-p3">
<p className="text-neutral-800 font-semibold">rzalaxib23@gmail.com</p>
<p className="text-neutral-600 font-medium">Email Address</p>
</div>
</div>
<div className="flex items-center gap-x-5">
<div className="bg-primary-50 rounded-full p-2.5 flex items-center justify-center text-primary-500">
<Icon icon="cil:phone" className="text-3xl" />
</div>
<div className="text-p3">
<p className="text-neutral-800 font-semibold">+62 888 8888 8888</p>
<p className="text-neutral-600 font-medium">Phone Number</p>
</div>
</div>
<div className="flex items-center gap-x-5">
<div className="bg-primary-50 rounded-full p-2.5 flex items-center justify-center text-primary-500">
<Icon icon="ion:location-outline" className="text-3xl" />
</div>
<div className="text-p3">
<p className="text-neutral-800 font-semibold">Jl. Mergosari, Kec. Suryakencana, Banjaran</p>
<p className="text-neutral-600 font-medium">Location</p>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -1,68 +0,0 @@
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { Modal } from "@imphnen-frontend-service/ui/molecules"
import { cn, For, Show } from "@imphnen-frontend-service/utils"
import { FC, useEffect, useState } from "react"
import { AccountProfile } from "./account-profile"
import { DetailProfile } from "./detail-profile"
import { ActivityLog } from "./activity-log"
import { ModalDetailUserProps } from "./type"
const TABS = {
account: 'account profile',
detail: 'detail profile',
activity: 'activity logs',
} as const
type TabType = typeof TABS[keyof typeof TABS]
export const ModalDetailUser: FC<ModalDetailUserProps> = ({
open,
setOpen,
}) => {
const [activeTab, setActiveTab] = useState<TabType>(TABS.account)
useEffect(() => {
if (!open) setActiveTab(TABS.account)
}, [open])
return (
<Modal
isOpen={open}
onClose={() => setOpen(false)}
className="xl:max-w-[84rem] bg-white px-10 py-9"
closeButtonClassName="hidden"
>
<div>
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
Detail - Muhammad Firdaus Oiwobo
</h1>
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md w-max mb-8">
<For data={Object.values(TABS)}>
{(tab) => (
<Button
key={tab}
variant="text"
className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
onClick={() => setActiveTab(tab)}
>
{tab}
</Button>
)}
</For>
</div>
<div>
<Show condition={activeTab === TABS.account}>
<AccountProfile open={open} setOpen={setOpen} />
</Show>
<Show condition={activeTab === TABS.detail}>
<DetailProfile />
</Show>
<Show condition={activeTab === TABS.activity}>
<ActivityLog />
</Show>
</div>
</div>
</Modal>
)
}
@@ -1,5 +0,0 @@
import { ModalProps } from "../type";
export interface ModalDetailUserProps extends ModalProps {
userId?: number | null
}
@@ -1,42 +0,0 @@
import { FC } from "react";
import { ModalProps } from "../type";
import { Modal } from "@imphnen-frontend-service/ui/molecules";
import { cn } from "@imphnen-frontend-service/utils";
import { Button, Select, Textarea } from "@imphnen-frontend-service/ui/atoms";
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
export const ModalSuspendOrBan: FC<ModalProps> = ({ open, setOpen }) => {
return (
<Modal
isOpen={open}
onClose={() => setOpen(false)}
className="bg-white px-10 py-9 xl:max-w-[32rem]"
closeButtonClassName="hidden"
>
<div>
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
Suspend/Ban
</h1>
<div className="space-y-6">
<div>
<label className={labelClass}>Suspend/Ban</label>
<Select defaultValue="suspend" className="w-full">
<option value="suspend">Suspend</option>
<option value="banned">Banned</option>
</Select>
</div>
<div>
<label className={labelClass}>Alasan</label>
<Textarea placeholder="Masukkan alasan suspend/ban" className="w-full h-40" />
</div>
<Button type="button" className="w-full">
Selesai
</Button>
</div>
</div>
</Modal>
)
}
@@ -1,4 +0,0 @@
export interface ModalProps {
open: boolean
setOpen: (open: boolean) => void
}
@@ -1,187 +0,0 @@
import { SearchOutlined } from "@ant-design/icons";
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
import { cn, For } from "@imphnen-frontend-service/utils";
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
import { ReactElement, useState } from "react";
import { ModalDetailUser } from "./_components/modal/detail";
type UserStatus = 'active' | 'inactive';
interface UserType {
id: number
name: string
email: string
rating: number
status: UserStatus
}
const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
id: i + 1,
name: i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana',
email: 'fullname23@gmail.com',
rating: 4.5,
status: i % 2 === 0 ? 'active' : 'inactive',
}))
export default function Components(): ReactElement {
const TABS = ['mentor', 'mentee'] as const
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor')
const [showDetail, setShowDetail] = useState(false)
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const columns: ColumnDef<UserType>[] = [
{
id: 'select',
meta: { cellClassName: cn("w-20") },
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
id: 'name',
header: 'Name',
accessorKey: 'name',
},
{
id: 'email',
header: 'Email',
accessorKey: 'email',
},
{
id: 'rating',
header: 'Rating',
accessorKey: 'rating',
},
{
id: 'status',
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.original.status;
const statusColors: Record<UserStatus, string> = {
active: 'bg-success-200 text-success-500',
inactive: 'bg-danger-200 text-danger-500',
};
const statusText: Record<UserStatus, string> = {
active: 'Active',
inactive: 'Inactive',
};
return (
<div
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
>
{statusText[status]}
</div>
);
},
},
{
header: 'Action',
meta: { cellClassName: cn("w-72") },
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setSelectedUserId(row.original.id);
setShowDetail(true);
}}
className="flex items-center gap-2 w-max"
>
<SearchOutlined className="text-[16px]" /> Lihat Detail & Action
</Button>
),
},
]
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return (
<BackofficeWrapper title="Dimentorin.dev">
<div className="mb-8 flex justify-between items-center">
<h1 className="text-p1 font-semibold text-neutral-700">User Management</h1>
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
<For data={TABS}>
{(tab) => (
<Button
key={tab}
variant="text"
className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
onClick={() => setActiveTab(tab)}
>
{tab}
</Button>
)}
</For>
</div>
</div>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-5 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama lengkap"
className="pl-12 w-full max-h-full"
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<Select>
<option selected disabled>Rating</option>
<option value="4.5">4.5</option>
<option value="5">5</option>
</Select>
<Select>
<option selected disabled>Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</Select>
</div>
<DataTable data={mockData} columns={columns} table={table} />
</section>
<ModalDetailUser
open={showDetail}
setOpen={setShowDetail}
userId={selectedUserId}
/>
</BackofficeWrapper>
);
}
@@ -2,8 +2,10 @@ import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import { FC, useRef } from 'react';
import { motion, useInView, Variants } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
export const CTASection: FC = () => {
const navigate = useNavigate();
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
@@ -137,6 +139,7 @@ export const CTASection: FC = () => {
<Button
type="button"
className="w-full h-auto py-2 text-[10px] font-medium md:w-max md:text-sm md:px-2.5 md:font-semibold lg:text-base"
onClick={() => navigate('/mentoring/ai-agent')}
>
Mulai Belajar Dengan AI
</Button>
@@ -144,6 +147,7 @@ export const CTASection: FC = () => {
type="button"
variant="secondary"
className="w-full h-auto py-2 text-[10px] font-medium md:w-max md:text-sm md:px-2.5 md:font-semibold lg:text-base"
onClick={() => navigate('/mentoring')}
>
Temukan Mentor
</Button>
@@ -5,9 +5,11 @@ import { motion, useInView, Variants } from 'framer-motion'
import { cn, For } from '@imphnen-frontend-service/utils'
const FAQ_DATA: AccordionOptions[] = [
{ title: 'Apakah saya bisa memilih mentor sendiri?', description: 'Tentu! Kamu bisa memilih mentor yang sesuai dengan kebutuhan dan level skill kamu.' },
{ title: 'Apakah mentoring ini cocok untuk pemula?', description: 'Yap! Baik newbie maupun seasoned dev bisa menemukan mentor yang pas untuk mempercepat progres belajarnya.' },
{ title: 'Apakah ini seperti sekolah coding biasa?', description: 'Nope! Ini lebih dari sekadar kursus-kursus AI guide dan mentor berpengalaman, kamu mendapatkan pengalaman belajar yang jauh lebih personal dan efektif.' },
{ title: 'Apakah saya bisa memilih mentor sendiri?', description: 'Tentu! Kamu bebas memilih mentor yang sesuai dengan kebutuhan, topik, dan level skill kamu. Setiap mentor punya profil, expertise, dan jadwal yang bisa kamu lihat sebelum booking.' },
{ title: 'Apakah mentoring ini cocok untuk pemula?', description: 'Yap! Baik newbie maupun seasoned dev bisa menemukan mentor yang pas. Tidak tahu harus mulai dari mana? Tanya AI Agent dulu untuk arahan belajar, lalu booking mentor untuk pendampingan yang lebih dalam.' },
{ title: 'Apakah ini seperti sekolah coding biasa?', description: 'Nope! Ini lebih fleksibel dari kursus pada umumnya. Rasa seperti punya mentor pribadi yang menyesuaikan pace belajarmu, plus AI Agent yang siap membantu kapan pun kamu macet.' },
{ title: 'Bagaimana sistem pembayarannya?', description: 'Kamu bayar per sesi mentoring yang sudah di-booking. Pembayaran diproses via Midtrans (Virtual Account / QRIS), dan sesi otomatis terkonfirmasi begitu pembayaran lunas.' },
{ title: 'Apakah ada materi yang bisa dipelajari gratis?', description: 'Ada! Halaman Materi berisi artikel dari para mentor yang bisa kamu baca kapan saja, dan AI Agent AI bisa menjawab pertanyaanmu berdasar materi tersebut tanpa biaya tambahan.' },
]
export const FAQSection: FC = () => {
@@ -72,7 +74,7 @@ export const FAQSection: FC = () => {
variant="bordered"
className="h-auto px-3 py-2 font-semibold bg-primary-100 md:text-base md:px-5 md:py-2.5 lg:text-2xl lg:px-6 lg:py-3"
>
Frequently Ask Question
Frequently Asked Questions
</Button>
</motion.div>
@@ -1,7 +1,9 @@
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { motion } from 'framer-motion'
import { useNavigate } from 'react-router-dom';
export function HeroSection() {
const navigate = useNavigate();
return (
<section className="relative w-full z-0 py-20 md:py-28 lg:py-32">
{/* Background gradients and shapes */}
@@ -63,6 +65,7 @@ export function HeroSection() {
type="button"
size="sm"
className="text-[10px] h-auto px-2.5 py-2 md:text-sm"
onClick={() => navigate('/mentoring/ai-agent')}
>
Belajar Dengan AI Sekarang
</Button>
@@ -8,6 +8,33 @@ export const TestimonialSection: FC = () => {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
const testimonials = [
{
name: 'Riko, Junior Web Developer',
quote:
'Rasanya seperti punya AI waifu yang ngajarin ngoding! Mentoringnya juga super insightful. Thanks, Dimentorin!',
rating: '4.8',
},
{
name: 'Salsa, Data Analyst',
quote:
'AI Agent-nya ngebantu banget pas stuck di konsep SQL yang rumit. Jawabannya selalu lengkap sama sumber materinya. Recommended!',
rating: '5.0',
},
{
name: 'Dimas, Mahasiswa Sistem Informasi',
quote:
'Awalnya takut tanya dosen, sekarang tinggal chat AI agent atau booking mentor. Progress belajarku naik drastis dalam 2 bulan!',
rating: '4.9',
},
{
name: 'Nadia, UI/UX Designer',
quote:
'Sensei-nya sabar banget jelasin dari dasar. Materi Rust & Tokio di sini juga disusun rapi, jadi gampang diikuti step by step.',
rating: '4.7',
},
]
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
@@ -57,7 +84,7 @@ export const TestimonialSection: FC = () => {
viewport={{ once: true }}
className="my-6 text-lg font-semibold mx-auto text-center text-primary-500 md:text-2xl lg:my-10 lg:text-4xl"
>
Words from our dellow devs
Words from our fellow devs
</motion.h1>
<div className="scrollbar-hide w-full mx-auto max-w-7xl px-8 overflow-auto md:px-[60px] xl:px-0">
@@ -67,8 +94,8 @@ export const TestimonialSection: FC = () => {
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
>
<For data={Array.from({ length: 2 })}>
{(_, index) => (
<For data={testimonials}>
{(testimonial, index) => (
<motion.div
key={index}
variants={childVariants}
@@ -83,10 +110,10 @@ export const TestimonialSection: FC = () => {
</div>
<div className="flex-1">
<h3 className="mb-3 font-semibold text-primary-500 md:text-lg lg:text-2xl">
Riko, Junior Web Developer
{testimonial.name}
</h3>
<p className="mb-6 text-[10px] text-neutral-500 font-medium md:text-sm lg:text-base">
Rasanya seperti punya AI waifu yang ngajarin ngoding! Mentoringnya juga super insightful. Thanks, Dimentorin!
{testimonial.quote}
</p>
<motion.div className="w-max" whileHover={{ scale: 1.1, rotate: -3 }}>
<Button
@@ -97,7 +124,7 @@ export const TestimonialSection: FC = () => {
>
<StarFilled className="md:text-lg" />
<span>
<span className="text-sm font-medium md:text-lg md:font-semibold lg:text-xl">4.8</span>
<span className="text-sm font-medium md:text-lg md:font-semibold lg:text-xl">{testimonial.rating}</span>
<span className="text-[10px] text-primary-300 md:text-sm md:font-medium lg:text-base">/5.0</span>
</span>
</Button>
@@ -72,8 +72,8 @@ export const WhatWeOfferSection: FC = () => {
<div
className="absolute bottom-0 inset-x-0 p-4 h-1/2 text-primary-50 bg-gradient-to-t from-primary-500 via-primary-500/80 flex flex-col items-center justify-end gap-y-1 md:items-start lg:px-6 lg:pb-8"
>
<p className="font-semibold lg:text-[26px] lg:font-bold">{offer.title}</p>
<p className="text-xs lg:text-[22px] lg:font-medium">{offer.description}</p>
<p className="font-semibold lg:text-[23px] lg:font-bold">{offer.title}</p>
<p className="text-xs lg:text-[19px] lg:font-medium">{offer.description}</p>
</div>
</motion.div>
)}
+52
View File
@@ -0,0 +1,52 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Link } from 'react-router-dom';
export default function NotFound() {
return (
<main className="relative min-h-[70vh] flex items-center justify-center overflow-hidden bg-primary-50 px-6">
{/* Decorative circles */}
<div className="pointer-events-none absolute -top-24 -right-24 size-72 rounded-full bg-primary-200 md:size-96" />
<div className="pointer-events-none absolute -bottom-28 -left-24 size-72 rounded-full bg-primary-100 md:size-96" />
<div className="relative z-10 mx-auto max-w-2xl text-center">
<p className="text-[46px] font-bold leading-none text-primary-500 md:text-[96px]">
404
</p>
<h1 className="mt-4 text-[23px] font-semibold text-neutral-800 md:text-[29px]">
Quest Tidak Ditemukan!
</h1>
<p className="mx-auto mt-3 max-w-md text-[15px] text-neutral-500 md:text-[19px]">
Sepertinya halaman ini pergi ke isekai tanpa kamu... atau alamatnya
salah ketik. Jangan khawatir, pahlawan sejati selalu tahu jalan
pulang! 🗡
</p>
<div className="mt-8 flex flex-col items-center justify-center gap-4 sm:flex-row">
<Link to="/">
<Button
type="button"
size="lg"
className="w-full sm:w-auto px-8"
>
Kembali ke Home
</Button>
</Link>
<Link to="/mentoring">
<Button
type="button"
size="lg"
variant="bordered"
className="w-full sm:w-auto px-8"
>
Cari Mentor
</Button>
</Link>
</div>
<p className="mt-8 text-[12px] text-neutral-400">
Level up &gt; 404 Not Found &gt; Berhasil kembali ke jalur quest
</p>
</div>
</main>
);
}
@@ -8,6 +8,10 @@ import { Link, NavLink, useLocation } from "react-router-dom";
const MENUS: { label: string; href: string }[] = [
{ label: "Home", href: "/" },
{ label: 'Mentoring', href: '/mentoring' },
{ label: 'Materi', href: '/mentoring/materi' },
{ label: 'AI Agent', href: '/mentoring/ai-agent' },
{ label: 'Dashboard', href: '/mentoring/dashboard' },
{ label: 'Sesi Saya', href: '/mentoring/my-sessions' },
{ label: 'Resources', href: '/resources' },
{ label: 'Articles', href: '/articles' },
]
@@ -1,9 +1,19 @@
import { Icon } from "@iconify/react";
import { Button } from "@imphnen-frontend-service/ui/atoms";
import { cn } from "@imphnen-frontend-service/utils";
import { cn, For, Show } from "@imphnen-frontend-service/utils";
import { motion } from "framer-motion";
import { useParams } from "react-router-dom";
import { useGetArticleBySlug } from "@imphnen-frontend-service/service";
export default function DetailArticle() {
const { slug } = useParams()
const { data: article, isLoading, isError } = useGetArticleBySlug(slug ?? "")
const paragraphs = article?.content.split("\n").filter((p) => p.trim()) ?? []
const date = article?.created_at
? new Date(article.created_at).toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" })
: ""
return (
<main>
<motion.section
@@ -12,76 +22,83 @@ export default function DetailArticle() {
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>
<Show
condition={!isLoading && !isError && !!article}
fallback={
<div className="w-full max-w-[900px] mx-auto text-center text-neutral-500 py-20">
{isError ? "Artikel tidak ditemukan." : "Memuat artikel..."}
</div>
}
>
<div className="w-full max-w-[900px] mx-auto mb-8 md:mb-[60px]">
<div className="mb-10 flex items-center justify-between">
<Button
type="button"
variant="text"
className="flex gap-x-2 text-[10px] px-2.5 py-2 md:text-[15px]"
onClick={() => window.history.back()}
>
<Icon icon="material-symbols:arrow-back-rounded" />
<span>Back</span>
</Button>
<Button
type="button"
variant="text"
className="flex gap-x-2 text-[10px] px-2.5 py-2 md:text-[15px]"
>
<span>Read Next</span>
<Icon icon="material-symbols:arrow-forward-rounded" />
</Button>
</div>
<div className="text-center leading-tight">
<h1
className="text-[23px] text-neutral-800 font-semibold mb-6 md:text-[37px] md:mb-10 xl:text-[46px]"
>
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 className="text-center leading-tight">
<h1
className="text-[23px] text-neutral-800 font-semibold mb-6 md:text-[37px] md:mb-10 xl:text-[46px]"
>
{article?.title}
</h1>
<p
className="text-[15px] font-semibold text-neutral-800 mb-2 md:text-[19px] md:mb-1 xl:text-[23px] xl:mb-2"
>
{article?.author_name || "IMPHNEN Editorial"}
</p>
<p className="text-xs text-neutral-600 mb-6 md:text-[15px] xl:text-[19px]">
{date}
</p>
<div
className="text-[10px] text-primary-600 font-medium bg-primary-200 px-3 py-1 rounded-3xl w-max mx-auto md:text-xs xl:text-[15px]"
>
{article?.category}
</div>
</div>
</div>
</div>
<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 className="w-full max-w-[900px] mx-auto">
<div className="mb-8 w-full h-auto aspect-16/10 overflow-hidden md:aspect-31/16 md:mb-[60px] xl:aspect-45/26">
<img
src={article?.cover_url ?? "/image/59de8a682dfbe42455613ea07477ff3e30532c1f.webp"}
alt="Cover Article"
className="size-full object-cover"
/>
</div>
<div
className={cn(
"text-balance text-[15px] font-medium text-neutral-600 space-y-8",
"md:space-y-12 md:text-[19px] xl:text-[23px] xl:max-w-[780px] xl:mx-auto",
)}
>
<p className="leading-tight">{article?.excerpt}</p>
<For data={paragraphs}>
{(paragraph) => (
<p key={paragraph.slice(0, 40)} className="leading-tight">
{paragraph}
</p>
)}
</For>
</div>
</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>
</Show>
</motion.section>
</main>
)
@@ -2,12 +2,26 @@ import { Button } from "@imphnen-frontend-service/ui/atoms"
import { cn, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react"
import { Link } from "react-router-dom"
import { TArticleListItem } from "@imphnen-frontend-service/service"
export type ArticleCardProps = {
variant?: "default" | "recent" | "featured"
article?: TArticleListItem
}
export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default' }) => {
function formatDate(iso?: string) {
if (!iso) return ""
return new Date(iso).toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" })
}
export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default', article }) => {
const title = article?.title ?? "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
const excerpt = article?.excerpt ?? "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
const author = article?.author_name ?? "IMPHNEN Editorial"
const date = formatDate(article?.created_at)
const slug = article?.slug ?? ""
const cover = article?.cover_url
return (
<div
className={cn(
@@ -22,25 +36,25 @@ export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default' }) => {
)}
>
<img
src="/image/59de8a682dfbe42455613ea07477ff3e30532c1f.webp"
alt="Cover's Article"
src={cover ?? "/image/59de8a682dfbe42455613ea07477ff3e30532c1f.webp"}
alt={title}
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 className="w-max bg-primary-200 text-primary-600 px-3 py-1 text-xs font-medium rounded-3xl mb-4 xl:mb-[15px]">
{article?.category ?? "Artikel"}
</div>
<Link
to="/articles/detail"
to={slug ? `/articles/${slug}` : "/articles"}
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]"
variant === "recent" && "mb-7 xl:text-[29px]"
)}
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
{title}
</Link>
<Show condition={variant !== "recent"}>
<p
@@ -49,7 +63,7 @@ export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default' }) => {
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.
{excerpt}
</p>
</Show>
<div
@@ -59,14 +73,16 @@ export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default' }) => {
variant === "featured" && "text-[15px] md:text-[19px] xl:text-base",
)}
>
<p>Writers Name</p>
<p>14 March 2025</p>
<p>{author}</p>
<p>{date}</p>
</div>
<Show condition={variant === "default"}>
<Button type="button" size="sm" className="px-2.5 py-2">
Baca Artikel
</Button>
<Link to={slug ? `/articles/${slug}` : "/articles"}>
<Button type="button" size="sm" className="px-2.5 py-2">
Baca Artikel
</Button>
</Link>
</Show>
</div>
</div>
@@ -1,14 +1,22 @@
import { ReactElement, useRef, useState } from "react";
import { ArticleCard } from "./_components/card/article";
import { cn, For } from "@imphnen-frontend-service/utils";
import { cn, For, Show } 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]
import { useGetArticleCategories, useGetArticles } from "@imphnen-frontend-service/service";
export default function Components(): ReactElement {
const [activeTab, setActiveTab] = useState<Category>('UI/UX Design')
const [activeTab, setActiveTab] = useState<string>("Semua")
const { data: articles = [], isLoading } = useGetArticles({ per_page: 20 })
const { data: categories = [] } = useGetArticleCategories()
const tabs = ["Semua", ...categories]
const featured = articles[0]
const recent = articles.slice(1, 4)
const filtered = activeTab === "Semua"
? articles
: articles.filter((a) => a.category === activeTab)
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
@@ -52,29 +60,34 @@ export default function Components(): ReactElement {
Featuring Articles
</motion.h1>
<div className="grid gap-12 xl:grid-cols-2">
<motion.div variants={childVariants}>
<ArticleCard variant="featured" />
</motion.div>
<Show
condition={!isLoading}
fallback={<p className="text-neutral-500">Memuat artikel...</p>}
>
<div className="grid gap-12 xl:grid-cols-2">
<motion.div variants={childVariants}>
<ArticleCard variant="featured" article={featured} />
</motion.div>
<div>
<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>
<motion.h1
className="text-[19px] text-neutral-800 font-semibold mb-10 md:text-[29px] xl:text-[37px] xl:mb-12"
variants={childVariants}
>
Recent Articles
</motion.h1>
<div className="grid gap-10">
<For data={recent}>
{(article, index) => (
<motion.div key={article.id ?? index} variants={childVariants}>
<ArticleCard variant="recent" article={article} />
</motion.div>
)}
</For>
</div>
</div>
</div>
</div>
</Show>
</div>
<div className="max-w-7xl mx-auto">
@@ -86,7 +99,7 @@ export default function Components(): ReactElement {
</motion.h1>
<div className="scrollbar-hide mb-8 w-full overflow-auto mx-auto">
<motion.div className="min-w-max w-auto flex gap-x-3" variants={childVariants}>
<For data={CATEGORIES}>
<For data={tabs}>
{(category) => (
<Button
key={category}
@@ -94,7 +107,7 @@ export default function Components(): ReactElement {
variant="text"
onClick={() => setActiveTab(category)}
className={cn(
"relative text-[10px] text-neutral-400 font-medium px-3 py-2 rounded-4xl md:text-xs md:font-semibold xl:text-[15px]",
"relative text-[10px] text-neutral-400 font-medium px-3 py-2 rounded-3xl 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"
)}
@@ -106,15 +119,20 @@ export default function Components(): ReactElement {
</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>
<Show
condition={filtered.length > 0}
fallback={<p className="text-neutral-500 text-center py-10">Belum ada artikel di kategori ini.</p>}
>
<div className="grid gap-8 xl:grid-cols-3">
<For data={filtered}>
{(article, index) => (
<motion.div key={article.id ?? index} variants={childVariants}>
<ArticleCard article={article} />
</motion.div>
)}
</For>
</div>
</Show>
</div>
</motion.section>
</main>
@@ -0,0 +1,220 @@
import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { motion } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import { ArrowRightOutlined, FireOutlined, TrophyOutlined, ThunderboltOutlined } from '@ant-design/icons';
const STATS = [
{ value: '1000+', label: 'Kohai Menunggu' },
{ value: '500+', label: 'Sensei Aktif' },
];
const GUILD_CARDS = [
{
icon: <ThunderboltOutlined />,
title: 'Power-Up Para Kohai',
description:
'Setiap tips dan trik dari sensei bakal langsung boost skill mereka! Kayak main RPG tapi real life—level up yang beneran meaningful!',
},
{
icon: <FireOutlined />,
title: 'Skill Jadi Gold Mine',
description:
'Knowledge yang udah kamu grind bertahun-tahun sekarang bisa jadi sumber cuan! Farming EXP sambil farming income, double win!',
},
{
icon: <TrophyOutlined />,
title: 'Legendary Sensei Status',
description:
'Jadi mentor yang namanya diingat sampai kohai-mu jadi senior! Your legacy bakal hidup terus di setiap success story mereka.',
},
];
export const Components: FC = (): ReactElement => {
const navigate = useNavigate();
return (
<main>
{/* HERO */}
<section className="relative overflow-hidden bg-primary-50/60">
<div className="max-w-7xl mx-auto px-8 py-16 md:px-[60px] md:py-20 lg:px-20 lg:py-24 flex flex-col items-center text-center">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-3xl"
>
<h1 className="text-[29px] font-bold text-primary-500 leading-tight md:text-[37px] lg:text-[46px]">
Jadi Sensei di Dunia IT?
</h1>
<p className="mt-4 text-[15px] font-medium text-neutral-600 md:text-[19px]">
Waktunya naik level jadi mentor legendaris!
</p>
<div className="mt-10 flex justify-center gap-6 md:gap-12">
{STATS.map((s) => (
<div key={s.label} className="text-center">
<p className="text-[23px] font-bold text-primary-500 md:text-[29px]">
{s.value}
</p>
<p className="text-xs font-medium text-neutral-500 md:text-[15px]">
{s.label}
</p>
</div>
))}
</div>
<div className="mt-10">
<Button
type="button"
size="lg"
onClick={() => navigate('/auth/register-mentor')}
>
Mulai Jadi Sensei <ArrowRightOutlined />
</Button>
</div>
</motion.div>
</div>
</section>
{/* VALUE PROPOSITION */}
<section className="max-w-7xl mx-auto px-8 py-14 md:px-[60px] md:py-16 lg:px-20">
<div className="text-center mb-10">
<h2 className="text-[23px] font-bold text-neutral-800 md:text-[29px]">
Kenapa Harus Join Guild Sensei Kami? 🔥
</h2>
</div>
<div className="grid gap-6 md:grid-cols-3">
{GUILD_CARDS.map((card, i) => (
<motion.div
key={card.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.1 }}
className="bg-white border border-neutral-100 rounded-xl shadow-sm p-8 text-center"
>
<div className="mx-auto mb-5 flex size-16 items-center justify-center rounded-full bg-primary-100 text-3xl text-primary-500">
{card.icon}
</div>
<h3 className="text-[19px] font-semibold text-neutral-800 mb-3">
{card.title}
</h3>
<p className="text-sm text-neutral-500 leading-relaxed">
{card.description}
</p>
</motion.div>
))}
</div>
</section>
{/* TESTIMONIAL */}
<section className="bg-white py-14 md:py-16">
<div className="max-w-7xl mx-auto px-8 md:px-[60px] lg:px-20">
<div className="text-center mb-10">
<p className="text-xs font-semibold uppercase tracking-wider text-primary-500 mb-2">
Testimonial
</p>
<h2 className="text-[23px] font-bold text-neutral-800 md:text-[29px]">
What the Legendary Sensei Says
</h2>
</div>
<div className="grid gap-6 md:grid-cols-3">
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-primary-50/60 border border-primary-100 rounded-xl p-8"
>
<div className="flex items-center gap-4 mb-4">
<div className="size-12 rounded-full bg-primary-500 text-white flex items-center justify-center font-bold">
R
</div>
<div>
<p className="font-semibold text-neutral-800 text-[15px]">
Riko, Junior Web Developer
</p>
<p className="text-primary-500 text-xs">4.8/5.0 </p>
</div>
</div>
<p className="text-sm text-neutral-600 leading-relaxed">
Rasanya seperti punya AI waifu yang ngajarin ngoding!
Mentoringnya juga super insightful. Thanks, Dimentorin!
</p>
</div>
))}
</div>
</div>
</section>
{/* FAQ */}
<section className="bg-primary-50/60 py-14 md:py-16">
<div className="max-w-7xl mx-auto px-8 md:px-[60px] lg:px-20">
<div className="text-center mb-10">
<h2 className="text-[23px] font-bold text-neutral-800 md:text-[29px]">
Frequently Asked Questions
</h2>
<p className="mt-2 text-[15px] font-medium text-neutral-500">
Kata Para Sensei Legendaris
</p>
</div>
<div className="max-w-3xl mx-auto space-y-4">
{[
{
q: 'Berapa income yang bisa didapat?',
a: 'Kamu akan mendapatkan income 100rb/sesi/45 menit.',
},
{
q: 'Bagaimana sistem pembayarannya?',
a: 'Tentu! Kamu bisa memilih mentor yang sesuai dengan kebutuhan dan level skill kamu.',
},
{
q: 'Apakah bisa flexible dalam mengambil sesi?',
a: 'Tentu! Kamu bisa memilih mentor yang sesuai dengan kebutuhan dan level skill kamu.',
},
].map((faq, i) => (
<details
key={i}
className="group bg-white border border-neutral-100 rounded-lg p-5 open:shadow-sm"
>
<summary className="flex items-center justify-between cursor-pointer list-none font-semibold text-neutral-800 text-[15px]">
{faq.q}
<span className="text-primary-500 group-open:rotate-45 transition-transform">
+
</span>
</summary>
<p className="mt-3 text-sm text-neutral-500 leading-relaxed">
{faq.a}
</p>
</details>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="bg-primary-600 relative overflow-hidden py-16 md:py-20">
<div className="max-w-4xl mx-auto px-8 text-center relative z-10">
<h2 className="text-[23px] font-bold text-white md:text-[29px]">
Ready to become the Legendary Sensei?
</h2>
<p className="mt-4 text-[15px] text-primary-100">
Join ribuan expert lainnya yang sudah membuktikan impact mereka.
Your expertise is needed!
</p>
<div className="mt-8">
<Button
type="button"
size="lg"
variant="secondary"
onClick={() => navigate('/auth/register-mentor')}
>
Daftar Sekarang 🚀
</Button>
</div>
</div>
</section>
</main>
);
};
export default Components;
@@ -10,6 +10,12 @@ import { QrisPaymentStep } from "./steps/qris-payement"
import { VAPaymentStep } from "./steps/va-payment"
import { SuccessStep } from "./steps/success"
import { PaymentStep } from "./steps/payment"
import {
TBookSessionRequest,
TPayment,
usePostBookSession,
usePostCreatePayment,
} from "@imphnen-frontend-service/service"
const STEPS = ['topic', 'schedule', 'profile', 'payment', 'qr-payment', 'va-payment', 'success'] as const
type Step = typeof STEPS[number]
@@ -17,15 +23,56 @@ type Step = typeof STEPS[number]
type Props = {
open: boolean
setOpen: (open: boolean) => void
mentorId?: string
mentor?: {
fullname?: string | null
current_role?: string
current_company?: string
mentoring_rate?: number
}
}
export const AppointmentModal: FC<Props> = ({ open, setOpen }) => {
export const AppointmentModal: FC<Props> = ({ open, setOpen, mentorId, mentor }) => {
const [step, setStep] = useState<Step>('topic')
const [selectedTopics, setSelectedTopics] = useState<number[]>([])
const [booking, setBooking] = useState<TBookSessionRequest | null>(null)
const [payment, setPayment] = useState<TPayment | null>(null)
const [paymentMethod, setPaymentMethod] = useState<'va' | 'qris'>('va')
const [error, setError] = useState("")
const [sessionId, setSessionId] = useState("")
const bookMutation = usePostBookSession(mentorId ?? "")
const paymentMutation = usePostCreatePayment(sessionId)
const handleStep = (action: 'next' | 'prev') => {
const handleStep = async (action: 'next' | 'prev') => {
setError("")
if (step === 'payment' && action === 'next') {
if (!mentorId || !booking) {
setError("Data booking tidak lengkap.")
return
}
try {
// 1. Book the session -> get session id
const booked = await bookMutation.mutateAsync({ ...booking, topic: booking.topic || `Mentoring #${selectedTopics[0] ?? 1}` })
const sid = (booked as unknown as { data?: { id?: string } })?.data?.id ?? ""
if (!sid) {
setError("Gagal membuat sesi mentoring. Coba lagi.")
return
}
setSessionId(sid)
// 2. Create payment for the session with the chosen method
const pm = await paymentMutation.mutateAsync({ method: paymentMethod })
setPayment(pm)
// 3. Route to the method-specific payment screen
setStep(paymentMethod === 'qris' ? 'qr-payment' : 'va-payment')
} catch {
setError("Gagal membuat sesi mentoring. Coba lagi.")
}
return
}
if (action === 'next' && step === 'success') {
setOpen(false)
} else if (action === 'next' && (step === 'qr-payment' || step === 'va-payment')) {
setStep('success')
} else if (action === 'next') {
setStep(STEPS[STEPS.indexOf(step) + 1])
} else if (action === 'prev' && step !== 'topic') {
@@ -112,14 +159,18 @@ export const AppointmentModal: FC<Props> = ({ open, setOpen }) => {
)}
<AnimatePresence>
{step === 'topic' && <TopicStep selectedTopics={selectedTopics} setSelectedTopics={setSelectedTopics} />}
{step === 'schedule' && <ScheduleStep />}
{step === 'topic' && <TopicStep selectedTopics={selectedTopics} setSelectedTopics={setSelectedTopics} mentor={mentor} />}
{step === 'schedule' && <ScheduleStep onChange={(data) => setBooking(data)} />}
{step === 'profile' && <ProfileStep />}
{step === 'payment' && <PaymentStep selectedTopics={selectedTopics} />}
{step === 'qr-payment' && <QrisPaymentStep />}
{step === 'va-payment' && <VAPaymentStep />}
{step === 'payment' && <PaymentStep selectedTopics={selectedTopics} mentor={mentor} booking={booking} onMethodChange={setPaymentMethod} />}
{step === 'qr-payment' && <QrisPaymentStep payment={payment} />}
{step === 'va-payment' && <VAPaymentStep payment={payment} />}
{step === 'success' && <SuccessStep />}
</AnimatePresence>
{error && (
<p className="text-danger-600 text-xs mt-4 font-medium">{error}</p>
)}
</div>
<Show condition={step !== 'success'}>
@@ -148,8 +199,10 @@ export const AppointmentModal: FC<Props> = ({ open, setOpen }) => {
condition={step !== 'success'}
fallback="Halman Booking"
>
<Show condition={step !== 'payment'} fallback="Bayar Sekarang">
Selanjutnya
<Show condition={step !== 'payment'} fallback={bookMutation.isPending ? "Memproses..." : "Bayar Sekarang"}>
<Show condition={step !== 'profile'} fallback="Pembayaran">
Selanjutnya
</Show>
</Show>
</Show>
</Button>
@@ -1,13 +1,34 @@
import { For, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react"
import { FC, useState } from "react"
import { motion } from "framer-motion"
import { TOPICS } from "../../../sections/topics"
import { TBookSessionRequest } from "@imphnen-frontend-service/service"
import { MentorAvatar } from "../../../../../_components/mentor-avatar"
type Props = {
selectedTopics: number[]
mentor?: {
fullname?: string | null;
current_role?: string;
current_company?: string;
mentoring_rate?: number;
} | null
booking?: TBookSessionRequest | null
onMethodChange: (method: 'va' | 'qris') => void
}
export const PaymentStep: FC<Props> = ({ selectedTopics }) => {
const BANK_OPTIONS = ['BCA', 'BRI', 'BNI', 'MANDIRI']
export const PaymentStep: FC<Props> = ({ selectedTopics, mentor, booking, onMethodChange }) => {
const rate = mentor?.mentoring_rate ?? 50000
const serviceFee = 2000
const [method, setMethod] = useState<'va' | 'qris'>('va')
const handleMethod = (next: 'va' | 'qris') => {
setMethod(next)
onMethodChange(next)
}
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"
@@ -22,15 +43,25 @@ export const PaymentStep: FC<Props> = ({ selectedTopics }) => {
</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" />
<MentorAvatar
name={mentor?.fullname || "Mentor"}
size="sm"
className="w-full h-full rounded-full"
textClassName="text-sm md:text-xl"
/>
</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.
{mentor?.fullname || "Mentor"}
</h3>
<p className="text-[10px] text-neutral-600 md:text-xs">
UI Designer at Oray orayan Studios
{mentor?.current_role || "Mentor"} at {mentor?.current_company || "IMPHNEN"}
</p>
{booking?.scheduled_at && (
<p className="text-[10px] text-neutral-500 md:text-xs mt-1">
{new Date(booking.scheduled_at).toLocaleString("id-ID", { dateStyle: "full", timeStyle: "short" })}
</p>
)}
</div>
</div>
</div>
@@ -65,15 +96,16 @@ export const PaymentStep: FC<Props> = ({ selectedTopics }) => {
<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']}>
<div className="grid grid-cols-4 gap-2">
<For data={BANK_OPTIONS}>
{(bank, index) => (
<div
key={index}
className="p-1 bg-white rounded-xs border border-primary-50 flex items-center gap-x-1"
className="p-1 bg-white rounded-xs border border-primary-50 flex items-center gap-x-1 cursor-pointer"
onClick={() => handleMethod('va')}
>
<input type="radio" name="payment" id={bank} className="size-2" />
<label htmlFor={bank} className="block">
<input type="radio" name="payment" id={`va-${bank}`} checked={method === 'va'} readOnly className="size-2" />
<label htmlFor={`va-${bank}`} className="block">
<img src="/image/payment/bca.webp" alt={bank} className="object-scale-down" />
</label>
</div>
@@ -85,11 +117,12 @@ export const PaymentStep: FC<Props> = ({ selectedTopics }) => {
<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="grid grid-cols-4 gap-2">
<div
className="p-1 bg-white rounded-xs border border-primary-50 flex items-center gap-x-1"
className="p-1 bg-white rounded-xs border border-primary-50 flex items-center gap-x-1 cursor-pointer"
onClick={() => handleMethod('qris')}
>
<input type="radio" name="payment" id="QRIS" className="size-2" />
<input type="radio" name="payment" id="QRIS" checked={method === 'qris'} readOnly className="size-2" />
<label htmlFor="QRIS" className="block">
<img src="/image/payment/qris.webp" alt="QRIS" className="object-scale-down" />
</label>
@@ -104,14 +137,14 @@ export const PaymentStep: FC<Props> = ({ selectedTopics }) => {
</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="text-right">Rp. {rate.toLocaleString("id-ID")}</p>
<p className="font-normal">Service Fee :</p>
<p className="text-right">Rp. 2.000</p>
<p className="text-right">Rp. {serviceFee.toLocaleString("id-ID")}</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>
<p className="text-right text-neutral-700">Rp. {(rate + serviceFee).toLocaleString("id-ID")}</p>
</div>
</div>
</div>
@@ -1,5 +1,7 @@
import { For } from "@imphnen-frontend-service/utils"
import { For, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react"
import { motion } from "framer-motion"
import { TPayment } from "@imphnen-frontend-service/service"
const PAYMENT_STEP = [
'Buka aplikasi e-wallet atau m-banking kamu',
@@ -8,7 +10,11 @@ const PAYMENT_STEP = [
'Konfimasi pembayaran, dan proses selesai.'
]
export const QrisPaymentStep = () => {
type Props = {
payment?: TPayment | null
}
export const QrisPaymentStep: FC<Props> = ({ payment }) => {
return (
<motion.div
className="bg-white px-6 py-5 rounded-md md:flex md:justify-between md:gap-8"
@@ -38,7 +44,14 @@ export const QrisPaymentStep = () => {
<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>
<p className="text-xs font-semibold text-neutral-700 md:text-[15px]">
Rp. {(payment?.total ?? 52000).toLocaleString("id-ID")}
</p>
<Show condition={!!payment?.externalRef && payment.provider !== 'midtrans'}>
<p className="text-[10px] text-neutral-400 mt-1 font-medium">
Ref: {payment?.externalRef}
</p>
</Show>
</div>
</div>
@@ -47,9 +60,21 @@ export const QrisPaymentStep = () => {
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" />
<Show
condition={!!payment?.externalRef && payment.provider === 'midtrans'}
fallback={<img src="/image/sample-qrcode.webp" alt="QR Code" className="w-full" />}
>
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=${encodeURIComponent(payment!.externalRef!)}`}
alt="QR Code Midtrans"
className="w-full"
/>
</Show>
</div>
<p className="text-[8px] text-neutral-500 text-center mt-2 md:text-[10px]">
Berlaku hingga {payment ? new Date(payment.expiresAt).toLocaleString("id-ID") : "-"}
</p>
</div>
</motion.div>
)
}
}
@@ -1,9 +1,11 @@
import { Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils"
import { motion } from "framer-motion"
import { useEffect, useState } from "react"
import { TBookSessionRequest } from "@imphnen-frontend-service/service"
const placeholder = `Hi [Nama Mentor], Saya [Nama Kamu] & saya berharap dapat memiliki sesi mentoring dengan Anda.
Saat ini, saya tertarik untuk mengejar __. Tujuan saya untuk sesi ini adalah __.
Saya ingin tahu secara khusus tentang ___.
@@ -13,7 +15,27 @@ Saya ingin tahu secara khusus tentang ___.
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
export const ScheduleStep = () => {
type Props = {
onChange: (data: TBookSessionRequest) => void
}
export const ScheduleStep = ({ onChange }: Props) => {
const [date, setDate] = useState("")
const [time, setTime] = useState("")
const [sessionType, setSessionType] = useState("online")
const [description, setDescription] = useState("")
useEffect(() => {
if (!date || !time) return
onChange({
topic: "Mentoring Session",
scheduled_at: `${date}T${time}:00`,
description,
duration_minutes: 60,
session_type: sessionType,
})
}, [date, time, sessionType, description])
return (
<motion.div
className="bg-white px-6 py-5 rounded-md md:px-6"
@@ -31,15 +53,15 @@ export const ScheduleStep = () => {
<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" />
<Input type="date" className="min-w-full w-full" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
<div>
<label className={labelClass}>Waktu</label>
<Input type="time" className="min-w-full w-full" />
<Input type="time" className="min-w-full w-full" value={time} onChange={(e) => setTime(e.target.value)} />
</div>
<div className="relative md:col-span-full">
<label className={labelClass}>Lokasi</label>
<Select className="min-w-full w-full">
<Select className="min-w-full w-full" value={sessionType} onChange={(e) => setSessionType(e.target.value)}>
<option value="online">Online</option>
<option value="offline">Offline</option>
</Select>
@@ -48,7 +70,9 @@ export const ScheduleStep = () => {
<label className={labelClass}>Pertanyaan Untuk Senpai</label>
<Textarea
className="min-w-full w-full h-40"
placeholder={placeholder} />
placeholder={placeholder}
value={description}
onChange={(e) => setDescription(e.target.value)} />
</div>
</div>
</motion.div>
@@ -16,11 +16,11 @@ export const SuccessStep = () => {
<div
className={cn(
"mb-3 flex justify-center items-center mx-auto size-[60px] bg-green-200 rounded-full",
"mb-3 flex justify-center items-center mx-auto size-[60px] bg-success-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">
<div className="bg-success-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>
@@ -3,13 +3,19 @@ import { cn, For, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react"
import { motion } from "framer-motion"
import { TOPICS } from "../../../sections/topics"
import { MentorAvatar } from "../../../../../_components/mentor-avatar"
type Props = {
selectedTopics: number[]
setSelectedTopics: (topics: number[]) => void
mentor?: {
fullname?: string | null
current_role?: string
current_company?: string
}
}
export const TopicStep: FC<Props> = ({ selectedTopics, setSelectedTopics }) => {
export const TopicStep: FC<Props> = ({ selectedTopics, setSelectedTopics, mentor }) => {
const handleSelectTopic = (topicId: number) => {
if (selectedTopics.includes(topicId)) {
setSelectedTopics(selectedTopics.filter((id) => id !== topicId))
@@ -27,14 +33,19 @@ export const TopicStep: FC<Props> = ({ selectedTopics, setSelectedTopics }) => {
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" />
<MentorAvatar
name={mentor?.fullname || "Mentor"}
size="md"
className="w-full h-full rounded-none"
textClassName="text-lg md:text-3xl"
/>
</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.
{mentor?.fullname || "Mentor"}
</h3>
<p className="text-[10px] line-clamp-2 text-neutral-600 md:text-[15px] xl:text-[19px]">
UI Designer at Oray orayan Studios
{mentor?.current_role || "Mentor"} at {mentor?.current_company || "IMPHNEN"}
</p>
</div>
</motion.div>
@@ -1,5 +1,7 @@
import { For } from "@imphnen-frontend-service/utils"
import { For, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react"
import { motion } from "framer-motion"
import { TPayment } from "@imphnen-frontend-service/service"
const PAYMENT_STEP = [
'Buka aplikasi e-wallet atau m-banking kamu',
@@ -8,7 +10,11 @@ const PAYMENT_STEP = [
'Konfimasi pembayaran, dan proses selesai.'
]
export const VAPaymentStep = () => {
type Props = {
payment?: TPayment | null
}
export const VAPaymentStep: FC<Props> = ({ payment }) => {
return (
<motion.div
className="bg-white px-6 py-5 rounded-md md:flex md:items-center md:gap-11"
@@ -24,7 +30,7 @@ export const VAPaymentStep = () => {
<p className="text-[10px] text-neutral-400 mb-1.5">
Cara melakukan pembayaran
</p>
<ul className="list-decimal pl-2.5">
<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">
@@ -33,6 +39,15 @@ export const VAPaymentStep = () => {
)}
</For>
</ul>
<div className="text-center md:text-start">
<p className="text-[10px] text-neutral-400 mb-1.5 font-medium md:text-xs">
Biaya yang harus dibayarkan
</p>
<p className="text-xs font-semibold text-neutral-700 md:text-[15px]">
Rp. {(payment?.total ?? 52000).toLocaleString("id-ID")}
</p>
</div>
</div>
<div className="text-center md:text-start">
@@ -44,12 +59,17 @@ export const VAPaymentStep = () => {
Kode Virtual Account
</p>
<p className="text-xs font-semibold text-neutral-700 mb-1.5 md:text-[15px]">
8091239861969812
{payment?.externalRef || "8091239861969812"}
</p>
<p className="text-[8px] text-neutral-400 md:text-[10px]">
A/N Unknown
A/N IMPHNEN
</p>
<Show condition={!!payment?.expiresAt}>
<p className="text-[8px] text-neutral-500 mt-2 md:text-[10px]">
Berlaku hingga {new Date(payment!.expiresAt).toLocaleString("id-ID")}
</p>
</Show>
</div>
</motion.div>
)
}
}
@@ -1,12 +1,21 @@
import { StarFilled } from "@ant-design/icons"
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { cn, For } from "@imphnen-frontend-service/utils"
import { TMentorDetail } from "@imphnen-frontend-service/service"
import { MentorAvatar } from "../../../_components/mentor-avatar"
type Props = {
onBook: () => void
mentor?: TMentorDetail
}
export const ProfileSection: React.FC<Props> = ({ onBook }) => {
export const ProfileSection: React.FC<Props> = ({ onBook, mentor }) => {
const name = mentor?.fullname || mentor?.legal_name || "Mentor"
const role = mentor?.current_role || "Mentor"
const company = mentor?.current_company || "IMPHNEN"
const expertise = mentor?.expertise ?? []
const softSkills = mentor?.languages ?? []
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
@@ -23,7 +32,12 @@ export const ProfileSection: React.FC<Props> = ({ onBook }) => {
"xl:aspect-5/6 md:h-60 md:rounded-lg"
)}
>
<img src="/image/testimonial.webp" alt="Mentor" className="w-full object-cover" />
<MentorAvatar
name={name}
size="lg"
className="w-full h-full rounded-none"
textClassName="text-xl md:text-3xl lg:text-5xl"
/>
</div>
<div className="md:max-w-[242px] xl:max-w-[306px]">
@@ -33,10 +47,10 @@ export const ProfileSection: React.FC<Props> = ({ onBook }) => {
"md:text-[19px] md:mb-2 md:text-start xl:text-[23px]",
)}
>
Muhammad Firdaus Oi Oi Oi, S.H., M.H.
{name}
</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
{role} at {company}
</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
@@ -56,7 +70,7 @@ export const ProfileSection: React.FC<Props> = ({ onBook }) => {
<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']}>
<For data={expertise}>
{(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}
@@ -68,7 +82,7 @@ export const ProfileSection: React.FC<Props> = ({ onBook }) => {
<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']}>
<For data={softSkills}>
{(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}
@@ -81,8 +95,12 @@ export const ProfileSection: React.FC<Props> = ({ onBook }) => {
<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>
<p className="text-primary-500 text-[15px] font-semibold">
{new Date().toLocaleDateString('id-ID', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' })}
</p>
<p className="text-neutral-500 text-xs font-medium">
{new Date().toLocaleDateString('id-ID', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
</p>
</div>
<Button type="button" size="sm" onClick={onBook}>
Book Your Senpai!
@@ -1,68 +1,77 @@
import { Button } from "@imphnen-frontend-service/ui/atoms"
import { cn, For } from "@imphnen-frontend-service/utils"
import { For, Show } from "@imphnen-frontend-service/utils"
import { useGetMentorAvailability } from "@imphnen-frontend-service/service"
import { FC } from "react"
const SCHEDULES = [
{
date: "Kamis, 20 Maret 2025",
availableCount: 10,
schedule: [
{ time: "19:00 WIB", available: true },
{ time: "20:00 WIB", available: true },
{ time: "21:00 WIB", available: true },
{ time: "22:00 WIB", available: true },
]
},
{
date: "Jumat, 21 Maret 2025",
availableCount: 1,
schedule: [
{ time: "19:00 WIB", available: true },
{ time: "20:00 WIB", available: true },
{ time: "21:00 WIB", available: false },
{ time: "22:00 WIB", available: true },
]
}
]
type Props = {
onBook: () => void
mentorId?: string
}
export const SenpaiScheduleSection: FC<Props> = ({ onBook }) => {
const DAY_NAMES = ["Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"]
function formatDate(dateStr: string) {
const d = new Date(dateStr + "T00:00:00")
return `${DAY_NAMES[d.getDay()]}, ${d.getDate()} ${d.toLocaleString("id-ID", { month: "long" })} ${d.getFullYear()}`
}
function formatTime(timeStr: string) {
const [h] = timeStr.split(":").map(Number)
return `${String(h).padStart(2, "0")}:00 WIB`
}
export const SenpaiScheduleSection: FC<Props> = ({ onBook, mentorId }) => {
const { data: availability, isLoading } = useGetMentorAvailability(mentorId ?? "")
const slots = availability?.slots ?? []
const byDate = new Map<string, typeof slots>()
for (const s of slots) {
if (!byDate.has(s.date)) byDate.set(s.date, [])
byDate.get(s.date)!.push(s)
}
const days = [...byDate.entries()]
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>
<Show
condition={!isLoading && days.length > 0}
fallback={
<p className="text-xs text-neutral-500">
{isLoading ? "Memuat jadwal senpai..." : "Belum ada jadwal tersedia."}
</p>
}
>
<For data={days}>
{([date, daySlots]) => (
<div key={date}>
<div className="flex justify-between items-center mb-4">
<p className="text-[15px] font-medium">{formatDate(date)}</p>
<p className="rounded-3xl py-1 px-2.5 text-xs font-medium bg-primary-200 text-primary-500">
{daySlots.length} slot tersisa
</p>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-2">
<For data={daySlots}>
{(schedule) => (
<div
key={`${schedule.date}-${schedule.time}`}
className={`px-3 py-2 text-xs rounded-md font-semibold ${
schedule.available
? "bg-primary-300 text-primary-600"
: "bg-neutral-200 text-neutral-400 line-through"
}`}
>
{formatTime(schedule.time)}
</div>
)}
</For>
</div>
</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>
)}
</For>
</Show>
<hr className="border-primary-200" />
@@ -72,4 +81,4 @@ export const SenpaiScheduleSection: FC<Props> = ({ onBook }) => {
</div>
</div>
)
}
}
@@ -1,14 +1,23 @@
import { StarFilled } from "@ant-design/icons";
import { For } from "@imphnen-frontend-service/utils";
import { useGetMentorStats } from "@imphnen-frontend-service/service";
import { FC } from "react";
const SENPAI_STATISTIC = [
{ name: 'Total Sessions', count: 8 },
{ name: 'Mentee Impact', count: 1000 },
{ name: 'Response Time', count: '30 Minute' }
]
type Props = {
mentorId?: string;
};
const formatCount = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 100000 ? 0 : 1)}k` : `${n}`);
export const StatisticsSection: FC<Props> = ({ mentorId }) => {
const { data: stats } = useGetMentorStats(mentorId ?? "")
const SENPAI_STATISTIC: { name: string; count: string }[] = [
{ name: 'Total Sessions', count: formatCount(stats?.total_sessions ?? 0) },
{ name: 'Mentee Impact', count: formatCount(stats?.unique_mentees ?? 0) },
{ name: 'Avg Rating', count: stats && stats.avg_rating > 0 ? `${stats.avg_rating}/5` : '-' },
]
export const StatisticsSection: FC = () => {
return (
<div className="md:mb-10">
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Senpai Statistics</h2>
@@ -1,4 +1,5 @@
import { FC, useState } from 'react'
import { useParams } from 'react-router-dom'
import { ProfileSection } from './_components/sections/profile'
import { StatisticsSection } from './_components/sections/senpai-statistics'
import { TopicsSection } from './_components/sections/topics'
@@ -7,44 +8,93 @@ import { EducationSection } from './_components/sections/education'
import { SenpaiScheduleSection } from './_components/sections/senpai-schedule'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { AppointmentModal } from './_components/modals/appointment'
import { useGetMentorDetail } from '@imphnen-frontend-service/service'
import { Show } from '@imphnen-frontend-service/utils'
export const Components: FC = () => {
const [open, setOpen] = useState(false)
const { id } = useParams()
const { data: mentor, isLoading, isError } = useGetMentorDetail(id ?? '')
const buildResume = (): string => {
if (mentor?.bio) return mentor.bio
if (!mentor) return ''
const parts: string[] = []
const role = mentor.current_role
const company = mentor.current_company
const years = mentor.years_of_experience
const expertise = mentor.expertise
const industries = mentor.industries
const languages = mentor.languages
if (role || company) {
parts.push(`${role || 'Mentor'}${company && role ? ' at ' : company ? ' di ' : ''}${company || ''}.`)
}
if (years > 0) {
parts.push(`Memiliki pengalaman ${years} tahun di industri IT.`)
}
if (expertise.length > 0) {
parts.push(`Keahlian utama: ${expertise.join(', ')}.`)
}
if (industries.length > 0) {
parts.push(`Bidang yang digeluti: ${industries.join(', ')}.`)
}
if (languages.length > 0) {
parts.push(`Bahasa yang dikuasai: ${languages.join(', ')}.`)
}
if (parts.length === 0) {
return 'Profil senpai ini sedang dilengkapi. Segera cek kembali!'
}
return parts.join(' ')
}
const resume = buildResume()
return (
<main>
<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)} />
<Show
condition={!isLoading && !isError}
fallback={
<section className="w-full p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20">
<div className="max-w-7xl mx-auto text-center text-neutral-500 py-10">
{isError ? 'Mentor tidak ditemukan' : 'Memuat profil mentor...'}
</div>
</section>
}
>
<section className="w-full p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20">
<div className="max-w-7xl mx-auto space-y-8 md:bg-white xl:bg-transparent">
<ProfileSection onBook={() => setOpen(true)} mentor={mentor} />
<Button type="button" size="sm" className="w-full md:hidden" onClick={() => setOpen(true)}>
Book Your Senpai!
</Button>
<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="bg-white px-4 py-5 md:px-8 md:pb-6 md:pt-0 xl:py-7 xl:flex xl:gap-x-10">
<div className="space-y-10 md:space-y-7 xl:flex-1">
<StatisticsSection mentorId={mentor?.id} />
<TopicsSection />
<div className="px-6 py-8 rounded-md shadow-md">
<h2 className="text-xs text-neutral-800 font-semibold mb-5 md:text-[15px] xl:text-[19px]">Senpai Resume</h2>
<p className="text-[10px] font-medium text-neutral-600 text-pretty md:text-[15px]">
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 className="px-6 py-8 rounded-md shadow-md">
<h2 className="text-xs text-neutral-800 font-semibold mb-5 md:text-[15px] xl:text-[19px]">Senpai Resume</h2>
<p className="text-[10px] font-medium text-neutral-600 text-pretty md:text-[15px]">
{resume || 'Profil senpai ini sedang dilengkapi. Segera cek kembali!'}
</p>
</div>
<ExperienceSection />
<EducationSection />
</div>
<ExperienceSection />
<EducationSection />
</div>
<div className="hidden xl:block xl:w-[400px]">
<SenpaiScheduleSection onBook={() => setOpen(true)} />
<div className="hidden xl:block xl:w-[400px]">
<SenpaiScheduleSection onBook={() => setOpen(true)} mentorId={mentor?.id} />
</div>
</div>
</div>
</div>
</section>
</section>
<AppointmentModal open={open} setOpen={setOpen} />
<AppointmentModal open={open} setOpen={setOpen} mentorId={mentor?.id} mentor={mentor} />
</Show>
</main>
)
}
@@ -0,0 +1,162 @@
import { FC, ReactElement } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { cn, SessionToken } from '@imphnen-frontend-service/utils';
import {
DashboardOutlined,
CompassOutlined,
BookOutlined,
TeamOutlined,
SettingOutlined,
LogoutOutlined,
ReadOutlined,
ProfileOutlined,
} from '@ant-design/icons';
type NavItem = {
label: string;
to: string;
icon: ReactElement;
end?: boolean;
};
type DashboardSidebarProps = {
role: 'mentee' | 'mentor';
};
const MENTEE_NAV: NavItem[] = [
{ label: 'Dashboard', to: '/mentoring/dashboard', icon: <DashboardOutlined />, end: true },
{ label: 'Roadmap Discovery', to: '/mentoring/ai-agent', icon: <CompassOutlined /> },
{ label: 'Learning Path', to: '/mentoring/materi', icon: <BookOutlined /> },
{ label: 'Mentoring', to: '/mentoring/my-sessions', icon: <TeamOutlined /> },
];
const MENTOR_NAV: NavItem[] = [
{ label: 'Dashboard', to: '/mentoring/mentor-dashboard', icon: <DashboardOutlined />, end: true },
{ label: 'Mentoring Setup', to: '/mentoring/mentor-dashboard?tab=setup', icon: <SettingOutlined /> },
{ label: 'List Mentee', to: '/mentoring/mentor-dashboard?tab=mentee', icon: <TeamOutlined /> },
{ label: 'Feedback', to: '/mentoring/mentor-dashboard?tab=feedback', icon: <ProfileOutlined /> },
];
// decode JWT payload (bukan verifikasi — cuma untuk tampilkan inisial/email)
const jwtPayload = (): { email?: string; name?: string } | null => {
const raw = SessionToken.get()?.token?.access_token;
if (!raw) return null;
try {
const part = raw.split('.')[1];
if (!part) return null;
const json = atob(part.replace(/-/g, '+').replace(/_/g, '/'));
const pad = json.length % 4 === 0 ? json : json + '='.repeat(4 - (json.length % 4));
return JSON.parse(pad);
} catch {
return null;
}
};
export const DashboardSidebar: FC<DashboardSidebarProps> = ({ role }): ReactElement => {
const location = useLocation();
const navigate = useNavigate();
const nav = role === 'mentee' ? MENTEE_NAV : MENTOR_NAV;
const isActive = (item: NavItem) => {
if (item.end) return location.pathname === item.to;
if (item.to.includes('?')) {
const [base, q] = item.to.split('?');
const [key, val] = q.split('=');
return location.pathname === base && new URLSearchParams(location.search).get(key) === val;
}
return location.pathname.startsWith(item.to);
};
const handleLogout = () => {
SessionToken.remove();
navigate('/');
};
return (
<aside className="hidden lg:flex w-[230px] shrink-0 flex-col bg-white border-r border-neutral-100 sticky top-0 h-screen">
<div className="px-6 py-6">
<p className="text-[19px] font-bold">
<span className="text-primary-500">Dimentorin</span>.dev
</p>
</div>
<nav className="flex-1 px-3 space-y-1">
{nav.map((item) => (
<Link
key={item.label}
to={item.to}
className={cn(
'flex items-center gap-3 rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
isActive(item)
? 'bg-primary-100/70 text-primary-600'
: 'text-neutral-500 hover:bg-neutral-50 hover:text-neutral-700'
)}
>
<span className="text-base">{item.icon}</span>
{item.label}
</Link>
))}
</nav>
<div className="p-3 border-t border-neutral-100">
<button
type="button"
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-lg px-4 py-2.5 text-sm font-medium text-neutral-500 hover:bg-danger-50 hover:text-danger-500 transition-colors"
>
<LogoutOutlined /> Log Out
</button>
</div>
</aside>
);
};
export const DashboardHeader: FC<{ title?: string; role?: 'mentee' | 'mentor' }> = ({
title,
role,
}): ReactElement => {
const payload = jwtPayload();
const name = payload?.name || payload?.email?.split('@')[0] || 'Pengguna';
const initial = name[0]?.toUpperCase() ?? 'U';
const roleLabel = role === 'mentor' ? 'Mentor' : 'Mentee';
return (
<header className="flex items-center justify-between gap-4 px-6 py-4 border-b border-neutral-100 bg-white/70 backdrop-blur sticky top-0 z-10">
<div>
{title && <h1 className="text-[19px] font-bold text-neutral-800">{title}</h1>}
<p className="text-xs text-neutral-400 hidden sm:block">Dimentorin.dev</p>
</div>
<div className="flex items-center gap-3">
<button type="button" aria-label="Notifications" className="size-9 rounded-full bg-neutral-50 hover:bg-neutral-100 flex items-center justify-center text-neutral-500">
<ReadOutlined />
</button>
<button type="button" aria-label="Settings" className="size-9 rounded-full bg-neutral-50 hover:bg-neutral-100 flex items-center justify-center text-neutral-500">
<SettingOutlined />
</button>
<div className="flex items-center gap-2">
<div className="size-9 rounded-full bg-primary-500 text-white flex items-center justify-center text-sm font-semibold">
{initial}
</div>
<div className="hidden sm:block">
<p className="text-sm font-semibold text-neutral-800 leading-tight">{name}</p>
<p className="text-xs text-neutral-400 capitalize">{roleLabel}</p>
</div>
</div>
</div>
</header>
);
};
export const DashboardLayout: FC<{
role: 'mentee' | 'mentor';
title?: string;
children: ReactElement | ReactElement[];
}> = ({ role, title, children }): ReactElement => (
<div className="flex min-h-screen bg-primary-50/40">
<DashboardSidebar role={role} />
<div className="flex-1 min-w-0 flex flex-col">
<DashboardHeader title={title} role={role} />
<main className="flex-1 px-6 py-6 lg:px-8">{children}</main>
</div>
</div>
);
@@ -0,0 +1,256 @@
import { FC, ReactElement, useEffect, useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { cn, Show } from '@imphnen-frontend-service/utils';
import { usePostSessionFeedback } from '@imphnen-frontend-service/service';
import { StarFilled, StarOutlined } from '@ant-design/icons';
import { toast } from 'sonner';
const RATING_LABELS: Record<number, string> = {
1: 'Sangat Tidak Puas',
2: 'Tidak Puas',
3: 'Cukup Puas',
4: 'Puas',
5: 'Sangat Puas',
};
type FeedbackModalProps = {
open: boolean;
sessionId: string;
mentorName?: string | null;
onClose: () => void;
onSuccess: () => void;
};
const RATING_QUESTIONS: { question: string; options: string[] }[] = [
{
question: 'Seberapa membantu jawaban mentor untuk kebutuhanmu?',
options: [
'Tidak Membantu',
'Kurang Membantu',
'Cukup Membantu',
'Membantu',
'Sangat Membantu',
],
},
{
question: 'Apakah kamu akan merekomendasikan platform ini ke rekanmu?',
options: ['Ya', 'Tidak', 'Mungkin'],
},
];
export const FeedbackModal: FC<FeedbackModalProps> = ({
open,
sessionId,
mentorName,
onClose,
onSuccess,
}): ReactElement => {
const [step, setStep] = useState<'rating' | 'detail'>('rating');
const [rating, setRating] = useState(0);
const [hoverRating, setHoverRating] = useState(0);
const [helpfulness, setHelpfulness] = useState('');
const [recommend, setRecommend] = useState('');
const [liked, setLiked] = useState('');
const [improve, setImprove] = useState('');
const [testimonial, setTestimonial] = useState('');
const mutation = usePostSessionFeedback(sessionId);
useEffect(() => {
if (open) {
setStep('rating');
setRating(0);
setHelpfulness('');
setRecommend('');
setLiked('');
setImprove('');
setTestimonial('');
}
}, [open]);
const handleSubmit = async () => {
const parts: string[] = [];
if (liked.trim()) parts.push(`Suka: ${liked.trim()}`);
if (improve.trim()) parts.push(`Tingkatkan: ${improve.trim()}`);
if (testimonial.trim()) parts.push(`Testimoni: ${testimonial.trim()}`);
if (helpfulness) parts.push(`Kebermanfaatan: ${helpfulness}`);
if (recommend) parts.push(`Rekomendasi: ${recommend}`);
const feedbackText = parts.join('. ') || 'Tidak ada komentar tambahan.';
try {
await mutation.mutateAsync({ rating, feedback: feedbackText });
toast.success('Feedback berhasil dikirim ✅ Terima kasih atas waktumu!');
onSuccess();
onClose();
} catch {
toast.error('Gagal mengirim feedback. Coba lagi ya, Senpai!');
}
};
const canNext =
rating > 0 && helpfulness !== '' && recommend !== '';
const canSubmit =
canNext || liked.trim() !== '' || improve.trim() !== '' || testimonial.trim() !== '';
return (
<Modal isOpen={open} onClose={onClose}>
<div className="p-6 w-full max-w-lg">
<div className="flex items-center justify-between mb-1">
<h2 className="text-[19px] font-bold text-neutral-800">
Feedback Mentor
</h2>
<span className="text-xs font-medium text-neutral-400">
{step === 'rating' ? '1 dari 2' : '2 dari 2'}
</span>
</div>
<p className="text-xs text-neutral-400 mb-4">
Sesi dengan {mentorName ?? 'mentormu'}
</p>
<Show
condition={step === 'rating'}
fallback={
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Apa yang kamu suka dari sesi ini?
</label>
<textarea
value={liked}
onChange={(e) => setLiked(e.target.value)}
rows={2}
className="w-full rounded-lg border border-neutral-200 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
placeholder="Ceritakan hal yang paling berkesan dari sesi ini..."
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Apa yang bisa ditingkatkan oleh mentor?
</label>
<textarea
value={improve}
onChange={(e) => setImprove(e.target.value)}
rows={2}
className="w-full rounded-lg border border-neutral-200 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
placeholder="Saran membangun untuk mentor..."
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
Testimoni atau ucapan terima kasih untuk mentor
</label>
<textarea
value={testimonial}
onChange={(e) => setTestimonial(e.target.value)}
rows={2}
className="w-full rounded-lg border border-neutral-200 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
placeholder="Tulis testimoni singkat..."
/>
</div>
</div>
}
>
<div className="space-y-5">
<div className="text-center">
<p className="text-sm font-medium text-neutral-700 mb-3">
Seberapa puas kamu dengan sesi mentoring ini?
</p>
<div className="flex justify-center gap-1.5">
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
type="button"
aria-label={`Rating ${value}`}
onMouseEnter={() => setHoverRating(value)}
onMouseLeave={() => setHoverRating(0)}
onClick={() => setRating(value)}
className={cn(
'text-2xl transition-colors',
(hoverRating || rating) >= value
? 'text-warning-400'
: 'text-neutral-200'
)}
>
{(hoverRating || rating) >= value ? <StarFilled /> : <StarOutlined />}
</button>
))}
</div>
<p className="mt-2 text-xs text-neutral-500">
{rating > 0 ? RATING_LABELS[rating] : '1 = Sangat Tidak Puas, 5 = Sangat Puas'}
</p>
</div>
{RATING_QUESTIONS.map((q) => (
<div key={q.question}>
<p className="text-sm font-medium text-neutral-700 mb-2">{q.question}</p>
<div className="flex flex-wrap gap-2">
{q.options.map((opt) => {
const isSelected =
(q.question === RATING_QUESTIONS[0].question
? helpfulness
: recommend) === opt;
const setter =
q.question === RATING_QUESTIONS[0].question
? setHelpfulness
: setRecommend;
return (
<button
key={opt}
type="button"
onClick={() => setter(opt)}
className={cn(
'rounded-full border px-4 py-1.5 text-xs font-medium transition-colors',
isSelected
? 'border-primary-500 bg-primary-100/60 text-primary-600'
: 'border-neutral-200 text-neutral-500 hover:border-primary-300'
)}
>
{opt}
</button>
);
})}
</div>
</div>
))}
</div>
</Show>
<div className="mt-6 flex justify-between gap-3">
<Button
type="button"
size="sm"
variant="bordered"
onClick={step === 'rating' ? onClose : () => setStep('rating')}
>
{step === 'rating' ? 'Batal' : 'Kembali'}
</Button>
<Show
condition={step === 'rating'}
fallback={
<Button
type="button"
size="sm"
disabled={mutation.isPending || !canSubmit}
onClick={handleSubmit}
>
{mutation.isPending ? 'Mengirim...' : 'Kirim'}
</Button>
}
>
<Button
type="button"
size="sm"
disabled={!canNext}
onClick={() => setStep('detail')}
>
Lanjut
</Button>
</Show>
</div>
</div>
</Modal>
);
};
export default FeedbackModal;
@@ -0,0 +1,69 @@
import { FC } from 'react';
import { cn } from '@imphnen-frontend-service/utils';
type TMentorAvatarProps = {
name: string;
size?: 'sm' | 'md' | 'lg';
className?: string;
textClassName?: string;
};
// Warna gradient unik per nama — hash nama → pasangan warna dari palet brand
const GRADIENTS: [string, string][] = [
['#23a1eb', '#0b5b8c'],
['#5b8def', '#3a3f8f'],
['#14b8a6', '#0f766e'],
['#8b5cf6', '#5b21b6'],
['#f59e0b', '#b45309'],
['#ef4444', '#991b1b'],
['#ec4899', '#9d174d'],
['#06b6d4', '#0e7490'],
];
const SIZES: Record<'sm' | 'md' | 'lg', string> = {
sm: 'size-[60px] text-lg md:size-10 lg:size-12',
md: 'size-16 text-xl md:size-20',
lg: 'size-24 text-3xl md:size-32 lg:size-40 md:text-4xl lg:text-5xl',
};
const getInitials = (name: string): string => {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '?';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
};
const getGradient = (name: string): string => {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
}
const [from, to] = GRADIENTS[hash % GRADIENTS.length];
return `linear-gradient(135deg, ${from} 0%, ${to} 100%)`;
};
export const MentorAvatar: FC<TMentorAvatarProps> = ({
name,
size = 'md',
className,
textClassName,
}) => {
const displayName = name || 'Mentor';
return (
<div
className={cn(
'flex items-center justify-center rounded-md overflow-hidden shrink-0 select-none',
SIZES[size],
className
)}
style={{ background: getGradient(displayName) }}
aria-label={`Avatar ${displayName}`}
>
<span
className={cn('font-semibold text-white drop-shadow-sm', textClassName)}
>
{getInitials(displayName)}
</span>
</div>
);
};
@@ -1,40 +1,73 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { For } from '@imphnen-frontend-service/utils';
import { TMentorDetail } from '@imphnen-frontend-service/service';
import { FC } from 'react';
import { Link } from 'react-router-dom';
import { MentorAvatar } from './mentor-avatar';
type TMentorCardProps = {
mentor: TMentorDetail;
};
export const MentorCard: FC<TMentorCardProps> = ({ mentor }) => {
const experience =
mentor.years_of_experience > 0
? `${mentor.years_of_experience} Years Experience`
: 'New Mentor';
const role = mentor.current_role || 'Mentor';
const company = mentor.current_company || 'IMPHNEN';
const softSkills = mentor.expertise || [];
const shownSkills = softSkills.slice(0, 2);
const extraCount = Math.max(0, softSkills.length - shownSkills.length);
export const MentorCard: FC = () => {
return (
<div className="p-2.5 rounded-md bg-white shadow flex gap-x-4 items-start md:p-4 md:flex-col md:rounded-lg md:gap-y-4">
<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>
<MentorAvatar
name={mentor.fullname ?? mentor.legal_name ?? 'Mentor'}
size="sm"
className="md:w-full md:h-auto md:aspect-square"
/>
<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
{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 to={`/mentoring/${mentor.id}`} className="text-xs font-semibold text-primary-500 md:text-[15px] md:font-semibold lg:text-[19px]">
{mentor.fullname || mentor.legal_name || 'Mentor'}
</Link>
</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
{role} at {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>
{softSkills.length > 0 && (
<div>
<p className="text-[8px] text-neutral-500 mb-1 md:mb-2 lg:text-[10px]">Soft Skills :</p>
<div className="space-x-2">
<For data={shownSkills}>
{(skill) => (
<Button
key={skill}
variant="text"
size="sm"
className="bg-primary-200 h-auto px-1.5 py-1 text-[8px] font-normal hover:bg-primary-200 lg:text-[10px]"
>
{skill}
</Button>
)}
</For>
{extraCount > 0 && (
<Button
variant="text"
size="sm"
className="h-auto px-1.5 py-1 text-[8px] font-normal text-neutral-500 hover:bg-transparent lg:text-[10px]"
>
+{extraCount}
</Button>
)}
</div>
</div>
</div>
)}
</div>
</div>
)
@@ -0,0 +1,154 @@
import { FC, ReactElement, useRef, useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { SendOutlined } from '@ant-design/icons';
import { usePostChat } from '@imphnen-frontend-service/service';
import type { TChatResponse } from '@imphnen-frontend-service/service';
type Message = {
role: 'user' | 'assistant';
content: string;
sources?: TChatResponse['sources'];
};
export const Components: FC = (): ReactElement => {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const chatMutation = usePostChat();
const inputRef = useRef<HTMLInputElement>(null);
const handleSend = async () => {
const question = input.trim();
if (!question || loading) return;
setInput('');
setMessages((m) => [...m, { role: 'user', content: question }]);
setLoading(true);
try {
const res = await chatMutation.mutateAsync({ question });
setMessages((m) => [
...m,
{ role: 'assistant', content: res.answer, sources: res.sources },
]);
} catch {
setMessages((m) => [
...m,
{
role: 'assistant',
content: 'Maaf, terjadi kesalahan saat menjawab. Coba lagi ya.',
},
]);
} finally {
setLoading(false);
inputRef.current?.focus();
}
};
return (
<main className="px-4 md:px-20 py-10">
<div className="mb-6">
<h1 className="text-3xl font-semibold text-primary-500 mb-2">
AI Agent Mentoring
</h1>
<p className="text-neutral-500">
Tanya apa saja tentang materi mentoring jawaban diambil langsung
dari konten materi yang tersedia (RAG).
</p>
</div>
<div className="bg-white rounded-2xl border border-neutral-100 shadow-sm max-w-3xl mx-auto">
{/* Chat body */}
<div className="h-[420px] overflow-y-auto p-5 space-y-4">
{messages.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center">
<div className="size-14 rounded-full bg-primary-50 flex items-center justify-center mb-3">
<SendOutlined className="text-primary-500 text-xl" />
</div>
<p className="text-sm text-neutral-400 max-w-sm">
Coba tanya: <i>&ldquo;Apa itu ownership di Rust?&rdquo;</i>{' '}
atau <i>&ldquo;Gimana cara buat route di Axum?&rdquo;</i>
</p>
</div>
) : (
messages.map((msg, idx) => (
<div
key={idx}
className={
msg.role === 'user'
? 'flex justify-end'
: 'flex justify-start'
}
>
<div
className={
msg.role === 'user'
? 'bg-primary-500 text-white rounded-2xl rounded-br-sm px-4 py-2.5 max-w-[80%] text-sm whitespace-pre-wrap'
: 'bg-neutral-50 border border-neutral-100 rounded-2xl rounded-bl-sm px-4 py-2.5 max-w-[85%] text-sm text-neutral-700 whitespace-pre-wrap'
}
>
{msg.content}
{msg.sources && msg.sources.length > 0 && (
<div className="mt-3 pt-2 border-t border-neutral-200">
<p className="text-[10px] uppercase tracking-wide text-neutral-400 mb-1">
Sumber materi
</p>
{msg.sources.map((s, i) => (
<div
key={i}
className="flex items-center justify-between gap-2 mb-1"
>
<span className="text-[12px] text-neutral-600 truncate">
📄 {s.title}
</span>
<span className="text-[10px] text-neutral-400 shrink-0">
{Math.round(s.score * 100)}%
</span>
</div>
))}
</div>
)}
</div>
</div>
))
)}
{loading && (
<div className="flex justify-start">
<div className="bg-neutral-50 border border-neutral-100 rounded-2xl rounded-bl-sm px-4 py-2.5 text-sm text-neutral-400">
<span className="inline-flex gap-1">
<span className="animate-bounce"></span>
<span className="animate-bounce [animation-delay:0.1s]">
</span>
<span className="animate-bounce [animation-delay:0.2s]">
</span>
</span>
</div>
</div>
)}
</div>
{/* Input */}
<div className="border-t border-neutral-100 p-4 flex gap-2">
<input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="Tanya tentang materi mentoring..."
className="flex-1 px-4 py-2.5 rounded-xl border border-neutral-200 focus:border-primary-400 focus:outline-none text-sm"
/>
<Button
variant="primary"
className="shrink-0"
onClick={handleSend}
disabled={loading || !input.trim()}
>
<SendOutlined />
</Button>
</div>
</div>
</main>
);
};
export default Components;
@@ -0,0 +1,128 @@
import { FC, ReactElement } from 'react';
import { Link } from 'react-router-dom';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Show, cn, SessionToken } from '@imphnen-frontend-service/utils';
import {
useGetMySessions,
useGetMaterialsList,
useGetArticles,
} from '@imphnen-frontend-service/service';
import { DashboardLayout } from '../_components/dashboard-layout';
import {
VideoCameraOutlined,
FileDoneOutlined,
CheckCircleOutlined,
ArrowRightOutlined,
} from '@ant-design/icons';
const StatCard: FC<{ icon: ReactElement; label: string; value: number | string }> = ({
icon,
label,
value,
}): ReactElement => (
<div className="bg-white rounded-xl p-5 border border-neutral-100 shadow-sm">
<div className="flex items-center gap-3">
<div className="size-10 rounded-lg bg-primary-100 text-primary-500 flex items-center justify-center text-lg">
{icon}
</div>
<div>
<p className="text-[23px] font-bold text-neutral-800 leading-none">{value}</p>
<p className="text-xs text-neutral-500 mt-1">{label}</p>
</div>
</div>
</div>
);
const DashboardPage: FC = (): ReactElement => {
const { data: sessions } = useGetMySessions();
const { data: materials } = useGetMaterialsList();
const { data: articles } = useGetArticles({});
const sessionCount = sessions?.sessions?.length ?? 0;
const completed = sessions?.sessions?.filter((s) => s.status === 'completed').length ?? 0;
const mats = materials?.data ?? [];
const totalSteps = mats.length || 1;
const doneSteps = Math.ceil(totalSteps * 0.35);
const progress = Math.round((doneSteps / totalSteps) * 100);
return (
<DashboardLayout role="mentee">
{/* Welcome banner */}
<div className="relative overflow-hidden rounded-2xl bg-primary-500 text-white px-8 py-8 md:px-10">
<div className="relative z-10 max-w-lg">
<h2 className="text-[23px] font-bold md:text-[29px] leading-tight">
Selamat Datang di Dimentorin.dev
</h2>
<p className="mt-3 text-sm text-primary-100 leading-relaxed">
Temukan roadmap belajar impianmu lewat Skill Discovery, lalu biarkan
AI dan mentor profesional membimbingmu step by step sampai jadi
Full Stack Developer 🚀
</p>
<Link to="/mentoring/ai-agent" className="mt-6 inline-block">
<Button variant="secondary" size="lg" className="gap-2">
Temukan Roadmapnya <ArrowRightOutlined />
</Button>
</Link>
</div>
<div className="absolute -right-8 -bottom-8 size-48 rounded-full bg-primary-400/40 blur-2xl" />
<div className="absolute -right-4 -top-10 size-32 rounded-full bg-white/10 blur-xl" />
<div className="absolute right-16 bottom-4 hidden md:flex size-20 items-center justify-center rounded-2xl bg-white/15 border border-white/20 text-3xl">
🎓
</div>
</div>
{/* Overview */}
<section className="mt-6">
<h3 className="text-[19px] font-semibold text-neutral-800 mb-4">Overview</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<StatCard icon={<VideoCameraOutlined />} label="Mentoring Session" value={sessionCount} />
<StatCard icon={<FileDoneOutlined />} label="Article Submitted" value={articles?.length ?? 0} />
<StatCard icon={<CheckCircleOutlined />} label="Article Published" value={completed} />
</div>
</section>
{/* Roadmaps / Learning Path progress */}
<section className="mt-8">
<h3 className="text-[19px] font-semibold text-neutral-800 mb-4">Roadmaps</h3>
<Show
condition={mats.length > 0}
fallback={
<div className="bg-white rounded-xl border border-neutral-100 shadow-sm p-8 text-center">
<p className="text-neutral-500 mb-4">Belum ada materi roadmap.</p>
<Link to="/mentoring/ai-agent">
<Button variant="primary" className="gap-2">
Mulai Skill Discovery <ArrowRightOutlined />
</Button>
</Link>
</div>
}
>
<div className="bg-white rounded-xl p-6 border border-neutral-100 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-4 mb-4">
<div>
<p className="font-semibold text-neutral-800">Front End Basic</p>
<p className="text-sm text-neutral-500 mt-0.5">
{doneSteps} of {totalSteps} steps milestone completed
</p>
</div>
<Link to="/mentoring/materi">
<Button size="sm" variant="bordered" className="gap-1">
Lanjut Belajar <ArrowRightOutlined />
</Button>
</Link>
</div>
<div className="h-2.5 rounded-full bg-neutral-100 overflow-hidden">
<div
className={cn('h-full rounded-full bg-primary-500 transition-all')}
style={{ width: `${Math.min(progress, 100)}%` }}
/>
</div>
<div className="mt-2 text-right text-xs text-neutral-400">{progress}%</div>
</div>
</Show>
</section>
</DashboardLayout>
);
};
export default DashboardPage;
@@ -0,0 +1,53 @@
import { FC, ReactElement } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { useGetMaterialBySlug } from '@imphnen-frontend-service/service';
export const Components: FC = (): ReactElement => {
const { slug } = useParams<{ slug: string }>();
const { data: material, isLoading } = useGetMaterialBySlug(slug ?? '');
return (
<main className="px-4 md:px-20 py-10">
<Link to="/mentoring/materi" className="inline-block mb-6">
<Button size="sm" variant="text">
<ArrowLeftOutlined /> Kembali ke Materi
</Button>
</Link>
{isLoading || !material ? (
<div className="animate-pulse bg-neutral-100 h-72 rounded-2xl" />
) : (
<article className="max-w-3xl">
<span className="inline-block px-2 py-0.5 rounded-full text-[12px] font-medium mb-3 bg-neutral-100 text-neutral-600">
{material.category}
</span>
<h1 className="text-3xl font-semibold text-neutral-800 mb-3">
{material.title}
</h1>
<p className="text-neutral-500 mb-6">{material.description}</p>
<div className="bg-white rounded-2xl border border-neutral-100 p-8 shadow-sm">
{/* SAFE: all '<' escaped to &lt; below, no raw HTML passes through */}
<div
className="prose prose-sm max-w-none prose-neutral"
dangerouslySetInnerHTML={{
__html: material.content
.split('\n')
.map((p) => p.trim())
.filter(Boolean)
.map(
(p) =>
`<p class="mb-3 text-neutral-700 leading-relaxed">${p.replace(/</g, '&lt;')}</p>`
)
.join(''),
}}
/>
</div>
</article>
)}
</main>
);
};
export default Components;
@@ -0,0 +1,92 @@
import { FC, ReactElement } from 'react';
import { Link } from 'react-router-dom';
import { Show } from '@imphnen-frontend-service/utils';
import {
useGetMaterialCategories,
useGetMaterialsList,
} from '@imphnen-frontend-service/service';
const CATEGORY_COLORS: Record<string, string> = {
Backend: 'bg-primary-100 text-primary-700',
Frontend: 'bg-info-100 text-info-800',
Data: 'bg-success-100 text-success-700',
Mobile: 'bg-warning-100 text-warning-800',
Cybersecurity: 'bg-danger-100 text-danger-700',
Default: 'bg-neutral-100 text-neutral-600',
};
export const Components: FC = (): ReactElement => {
const { data: materials, isLoading } = useGetMaterialsList();
const { data: categories } = useGetMaterialCategories();
return (
<main className="px-4 md:px-20 py-10">
<div className="mb-8">
<h1 className="text-3xl font-semibold text-primary-500 mb-2">
Materi Mentoring
</h1>
<p className="text-neutral-500">
Kumpulan materi dari para mentor IMPHNEN untuk memperdalam
keterampilan kamu.
</p>
</div>
{/* Category filter chips */}
<Show condition={!!categories?.length}>
<div className="flex flex-wrap gap-2 mb-6">
{categories?.map((cat) => (
<span
key={cat}
className={`px-3 py-1 rounded-full text-xs font-medium ${
CATEGORY_COLORS[cat] ?? CATEGORY_COLORS.Default
}`}
>
{cat}
</span>
))}
</div>
</Show>
{isLoading ? (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[0, 1, 2].map((i) => (
<div
key={i}
className="animate-pulse bg-neutral-100 h-44 rounded-2xl"
/>
))}
</div>
) : !materials?.data?.length ? (
<p className="text-neutral-400">Belum ada materi tersedia.</p>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{materials.data.map((m) => {
const color =
CATEGORY_COLORS[m.category] ?? CATEGORY_COLORS.Default;
return (
<Link
key={m.id}
to={`/mentoring/materi/${m.slug}`}
className="group bg-white rounded-2xl border border-neutral-100 p-6 shadow-sm hover:shadow-md transition-shadow"
>
<span
className={`inline-block px-2 py-0.5 rounded-full text-[12px] font-medium mb-3 ${color}`}
>
{m.category}
</span>
<h3 className="font-semibold text-neutral-800 group-hover:text-primary-500 transition-colors leading-snug mb-2">
{m.title}
</h3>
<p className="text-xs text-neutral-500 leading-relaxed line-clamp-2">
{m.description}
</p>
</Link>
);
})}
</div>
)}
</main>
);
};
export default Components;
@@ -0,0 +1,435 @@
import { FC, ReactElement, useMemo } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { For, Show, cn, SessionToken } from '@imphnen-frontend-service/utils';
import {
useGetMentorMe,
useGetMentorStats,
useGetMentorSessions,
useGetSessionPayments,
usePostConfirmPayment,
usePostRefreshPayment,
} from '@imphnen-frontend-service/service';
import {
ReloadOutlined,
CheckOutlined,
StarOutlined,
CheckCircleOutlined,
TeamOutlined,
MessageOutlined,
CalendarOutlined,
ClockCircleOutlined,
DollarOutlined,
} from '@ant-design/icons';
import { toast } from 'sonner';
import { DashboardLayout } from '../_components/dashboard-layout';
const STATUS_STYLE: Record<string, string> = {
pending: 'bg-warning-100 text-warning-800',
confirmed: 'bg-success-100 text-success-700',
completed: 'bg-primary-100 text-primary-700',
cancelled: 'bg-danger-100 text-danger-600',
};
const PAYMENT_STYLE: Record<string, string> = {
pending: 'bg-warning-100 text-warning-800',
paid: 'bg-success-100 text-success-700',
expired: 'bg-neutral-200 text-neutral-600',
};
const formatDate = (iso: string) => {
try {
return new Date(iso).toLocaleString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return iso;
}
};
const formatRupiah = (n: number) =>
new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(n);
const StatCard: FC<{ icon: ReactElement; label: string; value: number | string }> = ({
icon,
label,
value,
}): ReactElement => (
<div className="bg-white rounded-xl p-5 border border-neutral-100 shadow-sm">
<div className="flex items-center gap-3">
<div className="size-10 rounded-lg bg-primary-100 text-primary-500 flex items-center justify-center text-lg">
{icon}
</div>
<div>
<p className="text-[23px] font-bold text-neutral-800 leading-none">{value}</p>
<p className="text-xs text-neutral-500 mt-1">{label}</p>
</div>
</div>
</div>
);
const TABS = [
{ key: 'sessions', label: 'Sesi Masuk' },
{ key: 'setup', label: 'Mentoring Setup' },
{ key: 'mentee', label: 'List Mentee' },
{ key: 'feedback', label: 'Feedback' },
];
const SessionCard: FC<{ sessionId: string }> = ({ sessionId }) => {
const { data: payments } = useGetSessionPayments(sessionId);
const refreshPayment = usePostRefreshPayment();
const confirmPayment = usePostConfirmPayment();
const payment = (payments ?? [])[0];
return (
<Show condition={!!payment && payment.status === 'pending'}>
<div className="flex flex-wrap items-center justify-between gap-2 px-3 py-2 rounded-md text-xs font-medium bg-neutral-50 border border-neutral-100">
<span
className={cn(
'px-2 py-0.5 rounded-full capitalize',
PAYMENT_STYLE[payment?.status ?? ''] ?? ''
)}
>
Pembayaran: {payment?.status}
</span>
<span>{payment ? formatRupiah(payment.total) : ''}</span>
<div className="flex gap-2">
<Show condition={!!payment && payment.provider === 'midtrans'}>
<Button
size="sm"
variant="secondary"
className="gap-2"
disabled={refreshPayment.isPending}
onClick={async () => {
try {
if (payment) await refreshPayment.mutateAsync(payment.id);
toast.success('Status pembayaran diperbarui');
} catch {
toast.error('Gagal memperbarui status');
}
}}
>
<ReloadOutlined /> Refresh
</Button>
</Show>
<Button
size="sm"
variant="success"
className="gap-2"
disabled={confirmPayment.isPending}
onClick={async () => {
try {
if (payment) await confirmPayment.mutateAsync(payment.id);
toast.success('Pembayaran dikonfirmasi ✅');
} catch {
toast.error('Gagal mengonfirmasi pembayaran');
}
}}
>
<CheckOutlined /> Konfirmasi
</Button>
</div>
</div>
</Show>
);
};
export const Components: FC = (): ReactElement => {
const { data: mentor, isLoading: loadingMentor } = useGetMentorMe();
const mentorId = mentor?.id ?? '';
const { data: stats } = useGetMentorStats(mentorId);
const { data: sessions, isLoading } = useGetMentorSessions(mentorId);
const [params, setParams] = useSearchParams();
const tab = params.get('tab') ?? 'sessions';
const all = sessions?.sessions ?? [];
const sessionCount = all.length;
const completedCount = all.filter((s) => s.status === 'completed').length;
const confirmedCount = all.filter((s) => s.status === 'confirmed').length;
const pendingCount = all.filter((s) => s.status === 'pending').length;
const menteeCount = new Set(
all.map((s) => s.mentee_email ?? s.mentee_fullname ?? '')
).size;
const isMentor = !loadingMentor && !!mentor;
const setTab = (key: string) => {
if (key === 'sessions') params.delete('tab');
else params.set('tab', key);
setParams(params, { replace: true });
};
const content = useMemo(() => {
switch (tab) {
case 'setup':
return (
<div className="bg-white rounded-xl p-8 border border-neutral-100 shadow-sm">
<div className="flex items-center gap-3 mb-4">
<div className="size-11 rounded-xl bg-primary-100 text-primary-500 flex items-center justify-center text-xl">
<CalendarOutlined />
</div>
<div>
<h3 className="font-semibold text-neutral-800">Mentoring Setup</h3>
<p className="text-sm text-neutral-500">
Atur jadwal sesi & topik mentoring kamu.
</p>
</div>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<div className="rounded-lg border border-neutral-100 p-5">
<p className="text-sm font-medium text-neutral-700 mb-3 flex items-center gap-2">
<ClockCircleOutlined className="text-primary-500" /> Waktu Sesi
</p>
<p className="text-xs text-neutral-500">
Sesi mentoring kamu berjalan {confirmedCount > 0 ? `${confirmedCount} terkunci` : 'belum ada yang terkunci'}. Jadwal sesi tampil otomatis setelah mentee booking & pembayaran lunas.
</p>
</div>
<div className="rounded-lg border border-neutral-100 p-5">
<p className="text-sm font-medium text-neutral-700 mb-3 flex items-center gap-2">
<DollarOutlined className="text-primary-500" /> Monetization
</p>
<p className="text-xs text-neutral-500">
{pendingCount > 0
? `${pendingCount} sesi menunggu konfirmasi pembayaran.`
: 'Tidak ada sesi menunggu pembayaran saat ini.'}
</p>
</div>
</div>
</div>
);
case 'mentee':
return (
<div className="bg-white rounded-xl p-8 border border-neutral-100 shadow-sm">
<div className="flex items-center gap-3 mb-4">
<div className="size-11 rounded-xl bg-primary-100 text-primary-500 flex items-center justify-center text-xl">
<TeamOutlined />
</div>
<div>
<h3 className="font-semibold text-neutral-800">List Mentee</h3>
<p className="text-sm text-neutral-500">
{menteeCount} mentee unik pernah mengikuti sesimu.
</p>
</div>
</div>
<div className="space-y-3">
<For data={all.slice(0, 8)}>
{(session) => (
<div
key={session.id}
className="flex items-center justify-between gap-3 rounded-lg border border-neutral-100 px-4 py-3"
>
<div className="flex items-center gap-3 min-w-0">
<div className="size-9 rounded-full bg-primary-100 text-primary-600 flex items-center justify-center text-sm font-semibold shrink-0">
{(session.mentee_fullname ?? session.mentee_email ?? 'M')
.charAt(0)
.toUpperCase()}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-neutral-800 truncate">
{session.mentee_fullname ?? session.mentee_email ?? 'Mentee'}
</p>
<p className="text-xs text-neutral-400 truncate">{session.topic}</p>
</div>
</div>
<span
className={cn(
'shrink-0 px-2.5 py-1 rounded-full text-xs capitalize font-medium',
STATUS_STYLE[session.status] ?? 'bg-neutral-100 text-neutral-600'
)}
>
{session.status}
</span>
</div>
)}
</For>
{all.length === 0 && (
<p className="text-sm text-neutral-500 text-center py-6">
Belum ada mentee yang mengikuti sesimu.
</p>
)}
</div>
</div>
);
case 'feedback':
return (
<div className="bg-white rounded-xl p-8 border border-neutral-100 shadow-sm">
<div className="flex items-center gap-3 mb-4">
<div className="size-11 rounded-xl bg-primary-100 text-primary-500 flex items-center justify-center text-xl">
<MessageOutlined />
</div>
<div>
<h3 className="font-semibold text-neutral-800">Feedback</h3>
<p className="text-sm text-neutral-500">
Rating & feedback dari mentee setelah sesi selesai.
</p>
</div>
</div>
<div className="rounded-lg border border-neutral-100 p-6 text-center">
<p className="text-3xl mb-2"></p>
<p className="text-sm text-neutral-500">
Feedback mentee akan muncul di sini setelah sesi selesai
({completedCount} sesi selesai).
</p>
</div>
</div>
);
default:
return (
<div className="grid gap-4 md:grid-cols-2">
<For data={all}>
{(session) => (
<div
key={session.id}
className="bg-white rounded-lg p-5 shadow-sm border border-neutral-100"
>
<div className="flex justify-between items-start gap-3 mb-3">
<h3 className="font-semibold text-neutral-800 line-clamp-2">
{session.topic}
</h3>
<span
className={cn(
'shrink-0 px-3 py-1 rounded-full text-[12px] font-medium capitalize',
STATUS_STYLE[session.status] ?? 'bg-neutral-100 text-neutral-600'
)}
>
{session.status}
</span>
</div>
<p className="text-sm text-neutral-500 mb-1">
📅 {formatDate(session.scheduled_at)}
</p>
<p className="text-sm text-neutral-500 mb-3">
🧑🎓{' '}
{session.mentee_fullname ?? session.mentee_email ?? 'Mentee'}
{' · '}
{session.session_type === 'video_call' ? 'Video Call' : 'Chat'}
</p>
<SessionCard sessionId={session.id} />
<div className="mt-3">
<Link to={`/mentoring/${session.mentor_id}`}>
<Button size="sm" variant="bordered">
Lihat Detail
</Button>
</Link>
</div>
</div>
)}
</For>
</div>
);
}
}, [tab, all, confirmedCount, pendingCount, completedCount, menteeCount]);
return (
<DashboardLayout role="mentor" title="Mentor Dashboard">
<div className="max-w-7xl mx-auto">
<Show
condition={SessionToken.get() != null}
fallback={
<div className="bg-white rounded-lg p-12 text-center shadow-sm border border-neutral-100">
<p className="text-[46px] mb-2">🔐</p>
<h2 className="text-xl font-semibold text-neutral-800 mb-2">
Login dulu, Senpai!
</h2>
<p className="text-neutral-500 mb-6 max-w-md mx-auto">
Dashboard mentor hanya untuk yang sudah terdaftar. Masuk untuk
melihat permintaan sesi dan konfirmasi pembayaran.
</p>
<Link to="/auth/login">
<Button variant="primary" size="lg">
Login Sekarang
</Button>
</Link>
</div>
}
>
<Show
condition={isMentor}
fallback={
<div className="bg-white rounded-lg p-12 text-center shadow-sm border border-neutral-100">
<p className="text-neutral-500 mb-4">
Kamu belum terdaftar sebagai mentor. Daftar dulu untuk menerima
permintaan sesi.
</p>
<Link to="/auth/register-mentor">
<Button variant="primary">Daftar Jadi Mentor</Button>
</Link>
</div>
}
>
{/* Overview stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatCard icon={<StarOutlined />} label="Your Rating" value={stats?.avg_rating?.toFixed(1) ?? '-'} />
<StatCard icon={<CheckCircleOutlined />} label="Session Complete" value={completedCount} />
<StatCard icon={<TeamOutlined />} label="Mentee Impacted" value={menteeCount} />
<StatCard icon={<MessageOutlined />} label="Total Feedback" value={completedCount} />
</div>
{/* Tabs */}
<div className="mt-6 flex gap-1 border-b border-neutral-200 overflow-x-auto">
{TABS.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={cn(
'shrink-0 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
tab === t.key
? 'border-primary-500 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
)}
>
{t.label}
</button>
))}
</div>
<div className="mt-6">
<Show
condition={isLoading}
fallback={
<Show
condition={all.length > 0 || tab !== 'sessions'}
fallback={
<div className="bg-white rounded-lg p-12 text-center border border-neutral-100">
<p className="text-neutral-500">
Belum ada permintaan sesi mentoring.
</p>
</div>
}
>
{content}
</Show>
}
>
<div className="grid gap-4 md:grid-cols-2">
{[0, 1].map((i) => (
<div key={i} className="bg-white rounded-lg p-5 animate-pulse">
<div className="h-4 bg-neutral-200 rounded w-2/3 mb-3" />
<div className="h-3 bg-neutral-200 rounded w-1/2 mb-2" />
<div className="h-3 bg-neutral-200 rounded w-1/3" />
</div>
))}
</div>
</Show>
</div>
</Show>
</Show>
</div>
</DashboardLayout>
);
};
export default Components;
@@ -0,0 +1,247 @@
import { FC, ReactElement, useState } from 'react';
import { Link } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { For, Show, cn, SessionToken } from '@imphnen-frontend-service/utils';
import {
useGetMySessions,
useGetMyPayments,
usePostRefreshPayment,
} from '@imphnen-frontend-service/service';
import { ReloadOutlined, StarOutlined } from '@ant-design/icons';
import { toast } from 'sonner';
import { DashboardLayout } from '../_components/dashboard-layout';
import { FeedbackModal } from '../_components/feedback-modal';
const STATUS_STYLE: Record<string, string> = {
pending: 'bg-warning-100 text-warning-800',
confirmed: 'bg-success-100 text-success-700',
completed: 'bg-primary-100 text-primary-700',
cancelled: 'bg-danger-100 text-danger-600',
};
const PAYMENT_STYLE: Record<string, string> = {
pending: 'bg-warning-100 text-warning-800',
paid: 'bg-success-100 text-success-700',
expired: 'bg-neutral-200 text-neutral-600',
};
const formatDate = (iso: string) => {
try {
return new Date(iso).toLocaleString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return iso;
}
};
const formatRupiah = (n: number) =>
new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(n);
export const Components: FC = (): ReactElement => {
const queryClient = useQueryClient();
const { data: sessions, isLoading } = useGetMySessions();
const { data: payments } = useGetMyPayments();
const refreshPayment = usePostRefreshPayment();
const [feedbackTarget, setFeedbackTarget] = useState<{
sessionId: string;
mentorName: string | null;
} | null>(null);
const paymentBySession = new Map(
(payments ?? []).map((p) => [p.sessionId, p])
);
const handleRefresh = async (paymentId: string) => {
try {
await refreshPayment.mutateAsync(paymentId);
const updated = paymentBySession.get(
(payments ?? []).find((p) => p.id === paymentId)?.sessionId ?? ''
);
toast.success(
updated?.status === 'paid'
? 'Pembayaran berhasil dikonfirmasi otomatis ✅'
: 'Status pembayaran masih menunggu pembayaran'
);
} catch {
toast.error('Gagal memperbarui status pembayaran');
}
};
return (
<DashboardLayout role="mentee" title="Mentoring">
<div className="max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-[23px] font-bold text-neutral-800 md:text-[29px]">
Sesi Mentoring Saya
</h1>
<p className="text-sm text-neutral-500 mt-1">
Pantau jadwal, status pembayaran, dan konfirmasi sesi kamu.
</p>
</div>
<Show
condition={SessionToken.get() != null}
fallback={<AnonymousState />}
>
<Show
condition={isLoading}
fallback={
<Show
condition={(sessions?.sessions?.length ?? 0) > 0}
fallback={
<div className="bg-white rounded-lg p-12 text-center">
<p className="text-neutral-500 mb-4">
Kamu belum memiliki sesi mentoring.
</p>
<Link to="/mentoring">
<Button variant="primary">Cari Mentor</Button>
</Link>
</div>
}
>
<div className="grid gap-4 md:grid-cols-2">
{(sessions?.sessions ?? []).map((session) => {
if (!session) return null;
const payment = paymentBySession.get(session.id);
return (
<div
key={session.id}
className="bg-white rounded-lg p-5 shadow-sm border border-neutral-100"
>
<div className="flex justify-between items-start gap-3 mb-3">
<h3 className="font-semibold text-neutral-800 line-clamp-2">
{session.topic}
</h3>
<span
className={cn(
'shrink-0 px-3 py-1 rounded-full text-[12px] font-medium capitalize',
STATUS_STYLE[session.status] ?? 'bg-neutral-100 text-neutral-600'
)}
>
{session.status}
</span>
</div>
<p className="text-sm text-neutral-500 mb-1">
📅 {formatDate(session.scheduled_at)}
</p>
<p className="text-sm text-neutral-500 mb-4">
{session.duration_minutes} menit ·{' '}
{session.session_type === 'video_call'
? 'Video Call'
: 'Chat'}
</p>
<Show condition={!!payment}>
<div
className={cn(
'flex justify-between items-center px-3 py-2 rounded-md text-xs font-medium mb-3',
PAYMENT_STYLE[payment?.status ?? ''] ?? ''
)}
>
<span className="capitalize">
{payment?.status === 'paid' ? 'Lunas' : 'Menunggu pembayaran'}
</span>
<span>{payment ? formatRupiah(payment.total) : ''}</span>
</div>
</Show>
<div className="flex gap-2">
<Show
condition={!!payment && payment.status === 'pending' && payment.provider === 'midtrans'}
>
<Button
size="sm"
variant="secondary"
className="gap-2"
disabled={refreshPayment.isPending}
onClick={() => payment && handleRefresh(payment.id)}
>
<ReloadOutlined /> Cek Status
</Button>
</Show>
<Show condition={session.status === 'confirmed'}>
<Link to={`/mentoring/${session.mentor_id}`}>
<Button size="sm" variant="bordered">
Detail Mentor
</Button>
</Link>
</Show>
<Show condition={session.status === 'completed'}>
<Button
size="sm"
variant="bordered"
className="gap-1.5"
onClick={() =>
setFeedbackTarget({
sessionId: session.id,
mentorName: null,
})
}
>
<StarOutlined /> Beri Feedback
</Button>
</Show>
</div>
</div>
);
})}
</div>
</Show>
}
>
<div className="grid gap-4 md:grid-cols-2">
{[0, 1].map((i) => (
<div key={i} className="bg-white rounded-lg p-5 animate-pulse">
<div className="h-4 bg-neutral-200 rounded w-2/3 mb-3" />
<div className="h-3 bg-neutral-200 rounded w-1/2 mb-2" />
<div className="h-3 bg-neutral-200 rounded w-1/3" />
</div>
))}
</div>
</Show>
</Show>
</div>
<FeedbackModal
open={feedbackTarget != null}
sessionId={feedbackTarget?.sessionId ?? ''}
mentorName={feedbackTarget?.mentorName}
onClose={() => setFeedbackTarget(null)}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['get-my-sessions'] });
queryClient.invalidateQueries({ queryKey: ['get-mentor-stats'] });
}}
/>
</DashboardLayout>
);
};
const AnonymousState: FC = (): ReactElement => (
<div className="bg-white rounded-lg p-12 text-center shadow-sm border border-neutral-100">
<p className="text-[46px] mb-2">🔐</p>
<h2 className="text-xl font-semibold text-neutral-800 mb-2">
Login dulu, Senpai!
</h2>
<p className="text-neutral-500 mb-6 max-w-md mx-auto">
Sesi mentoring kamu aman tersimpan di akun. Masuk untuk melihat jadwal,
status pembayaran, dan konfirmasi sesi.
</p>
<Link to="/auth/login">
<Button variant="primary" size="lg">
Login Sekarang
</Button>
</Link>
</div>
);
export default Components;
@@ -3,27 +3,24 @@ 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 { For, Show } from '@imphnen-frontend-service/utils';
import { MentorCard } from './_components/mentor-card';
import { Pagination } from '@imphnen-frontend-service/ui/molecules';
import { useGetMentors } from '@imphnen-frontend-service/service';
import { getCoreRowModel, getPaginationRowModel, PaginationState, useReactTable } from '@tanstack/react-table';
import { motion, useInView, Variants } from 'framer-motion';
const TEMP_DATA = [
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'John Doe' },
{ id: 3, name: 'John Doe' },
{ id: 4, name: 'John Doe' },
]
export const Components: FC = (): ReactElement => {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 3,
pageSize: 8,
});
const [search, setSearch] = useState('');
const { data: mentors, isLoading } = useGetMentors({ search });
const table = useReactTable({
data: TEMP_DATA,
data: mentors ?? [],
columns: [],
state: { pagination },
getCoreRowModel: getCoreRowModel(),
@@ -31,6 +28,8 @@ export const Components: FC = (): ReactElement => {
onPaginationChange: setPagination,
});
const pageMentors = table.getRowModel().rows.map((row) => row.original);
const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 })
@@ -63,7 +62,7 @@ export const Components: FC = (): ReactElement => {
<section className="w-full px-8 md:px-[60px] md:pb-[60px] lg:px-20">
<motion.div
className="max-w-7xl mx-auto bg-white rounded-lg py-6 md:rounded-4xl"
className="max-w-7xl mx-auto bg-white rounded-lg py-6 md:rounded-3xl"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
@@ -91,6 +90,8 @@ export const Components: FC = (): ReactElement => {
<Input
placeholder="Cari berdasarkan nama, posisi/peran"
className="relative min-w-full w-full"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<SearchOutlined className="absolute right-2.5 top-1/2 -translate-y-1/2 text-primary-500 size-2.5 cursor-text md:me-12 lg:me-0" />
</motion.div>
@@ -101,13 +102,31 @@ export const Components: FC = (): ReactElement => {
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
>
<For data={Array.from({ length: 8 })}>
{(_, index) => (
<motion.div key={index} variants={childVariants}>
<MentorCard />
</motion.div>
)}
</For>
<Show
condition={!isLoading}
fallback={
<motion.p variants={childVariants} className="text-center text-neutral-500 col-span-full py-10">
Memuat data mentor...
</motion.p>
}
>
<Show
condition={pageMentors.length > 0}
fallback={
<motion.p variants={childVariants} className="text-center text-neutral-500 col-span-full py-10">
Tidak ada mentor ditemukan
</motion.p>
}
>
<For data={pageMentors}>
{(mentor, index) => (
<motion.div key={mentor.id ?? index} variants={childVariants}>
<MentorCard mentor={mentor} />
</motion.div>
)}
</For>
</Show>
</Show>
</motion.div>
<Pagination table={table} />
+1 -1
View File
@@ -12,7 +12,7 @@ export const Components: FC = (): ReactElement => {
<ForgotStep step={1} />
<h3 className='mt-5 text-3xl font-semibold text-primary-500'>Forgot Password</h3>
<h5 className='mt-2 text-xl font-medium text-primary-500'>Masukkan email-mu, dan biarkan kami memanggil password-mu kembali dari dunia lain!</h5>
<h6 className='text-gray-600 mt-8'>Email</h6>
<h6 className='text-neutral-600 mt-8'>Email</h6>
<Input type='email' size='lg' placeholder='Contoh: yourname@mail.com'></Input>
<Button variant='primary' className='mt-7'>Kirim Kode OTP ^^</Button>
</div>
@@ -13,12 +13,12 @@ export const Components: FC = (): ReactElement => {
<h3 className='mt-5 text-3xl font-semibold text-primary-500'>Forgot Password</h3>
<h5 className='mt-2 text-md font-medium text-primary-500'>Sekarang, buatlah password baru yang lebih kuat, seperti jurus andalanmu!<span role="img" aria-label='emoji'>🔥</span></h5>
<div className='my-4'>
<h6 className='text-gray-700'>Password Baru</h6>
<h6 className='text-neutral-700'>Password Baru</h6>
<Input className='w-full' type="password" placeholder='Buat password sekokoh armor legendary!'/>
<h6 className='text-gray-700 mt-5'>Ulang Password Baru</h6>
<h6 className='text-neutral-700 mt-5'>Ulang Password Baru</h6>
<Input className='w-full' type="password" placeholder='Pastikan Cocok! Jangan sampai ada typo, Senpai~!'/>
</div>
<div className='mb-4 text-gray-700 flex flex-col gap-3 text-sm'>
<div className='mb-4 text-neutral-700 flex flex-col gap-3 text-sm'>
<h6><span role='img' aria-label='emoji'>💡</span> Tips dari kami:</h6>
<h6><span role='img' aria-label='emoji'> </span> Buat kombinasi huruf besar, kecil, angka, dan simbol untuk kekuatan maksimal!</h6>
<h6><span role='img' aria-label='emoji'></span> Pastikan kamu ingat password-mu atau simpan di tempat aman~</h6>
+4 -4
View File
@@ -57,12 +57,12 @@ export const Components: FC = (): ReactElement => {
</a>
</div>
<div className="flex items-center w-full">
<div className="flex-grow border-t border-blue-400 opacity-50"></div>
<span className="px-3 text-blue-400">Or</span>
<div className="flex-grow border-t border-blue-400 opacity-50"></div>
<div className="flex-grow border-t border-primary-400 opacity-50"></div>
<span className="px-3 text-primary-400">Or</span>
<div className="flex-grow border-t border-primary-400 opacity-50"></div>
</div>
<Button
className="w-full my-3 text-gray-500 gap-2"
className="w-full my-3 text-neutral-500 gap-2"
variant="secondary"
>
<p>Log In With Google</p>
@@ -1,11 +1,80 @@
import { FC, ReactElement, useState } from 'react';
import { ControlledInputField, RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
import { Button, Select } from '@imphnen-frontend-service/ui/atoms';
import { RegisterResetBanner } from '@imphnen-frontend-service/ui/organisms';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { ArrowLeftOutlined, ArrowRightOutlined } from '@ant-design/icons';
import { InputField, RegisterMentorStep, SelectField } from '@imphnen-frontend-service/ui/molecules';
import { usePostRegisterMentor, TMentorRegisterRequest } from '@imphnen-frontend-service/service';
import { useNavigate } from 'react-router-dom';
export const Components: FC = (): ReactElement => {
const [ step, setStep ] = useState(1)
const [step, setStep] = useState(1)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState("")
const [form, setForm] = useState({
email: "", password: "", confirmPassword: "",
fullname: "", phone_number: "", gender: "", domicile: "",
legal_name: "", phone_for_verification: "", identity_document_url: "",
last_education: "", bio: "", linkedin_url: "", github_url: "",
cv_url: "", portfolio_url: "", industries: "", expertise: "", languages: "",
current_company: "", current_role: "", years_of_experience: "",
topics_of_interest: "", preferred_mentee_level: "", preferred_mentoring_formats: "",
availability_commitment: "", mentoring_rate_amount: "",
})
const setF = (k: string, v: string) => setForm((f) => ({ ...f, [k]: v }))
const registerMutation = usePostRegisterMentor()
const navigate = useNavigate()
const handleSubmit = async () => {
setError("")
if (form.password !== form.confirmPassword) {
setError("Password dan konfirmasi tidak sama.")
return
}
const payload: TMentorRegisterRequest = {
email: form.email,
password: form.password,
fullname: form.fullname,
phone_number: form.phone_number || null,
identity_and_verification: {
legal_name: form.legal_name || form.fullname,
gender: form.gender || null,
domicile: form.domicile || null,
identity_document_url: form.identity_document_url || "https://example.com/identity.jpg",
phone_for_verification: form.phone_for_verification || null,
},
professional_profile: {
bio: form.bio,
last_education: form.last_education || null,
linkedin_url: form.linkedin_url || null,
github_url: form.github_url || null,
cv_url: form.cv_url || null,
portfolio_url: form.portfolio_url || null,
industries: form.industries.split(",").map((s) => s.trim()).filter(Boolean),
expertise: form.expertise.split(",").map((s) => s.trim()).filter(Boolean),
languages: form.languages.split(",").map((s) => s.trim()).filter(Boolean),
current_company: form.current_company,
current_role: form.current_role,
years_of_experience: Number(form.years_of_experience) || 0,
},
mentoring_logistics: {
topics_of_interest: form.topics_of_interest.split(",").map((s) => s.trim()).filter(Boolean),
preferred_mentee_level: [form.preferred_mentee_level].filter(Boolean),
preferred_mentoring_formats: form.preferred_mentoring_formats.split(",").map((s) => s.trim()).filter(Boolean),
availability_commitment: form.availability_commitment,
mentoring_rate_amount: Number(form.mentoring_rate_amount) || 0,
},
}
setSubmitting(true)
try {
await registerMutation.mutateAsync(payload)
navigate('/auth/register-mentor/pending')
} catch (e: unknown) {
const msg = (e as { response?: { data?: { message?: string } } })?.response?.data?.message
setError(msg || "Gagal mendaftar sebagai mentor. Coba lagi.")
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col justify-center items-center min-h-screen py-[60px] px-[80px]">
@@ -13,13 +82,6 @@ export const Components: FC = (): ReactElement => {
<RegisterResetBanner />
<div className="xl:border-2 xl:border-primary-500/50 xl:w-[726px] rounded-lg py-[32px] px-[48px] flex justify-center">
<div className="xl:w-[714px]">
<Button
className='xl:hidden gap-3'
variant='secondary'
>
<ArrowLeftOutlined />
Login
</Button>
<RegisterMentorStep step={step} />
<h3 className="mt-5 text-3xl font-semibold text-primary-500">
Register
@@ -27,60 +89,44 @@ export const Components: FC = (): ReactElement => {
<h5 className="text-primary-500 font-medium">
Welcome to the team, Mentor - powered by IMPHNEN
</h5>
<form>
<form onSubmit={(e) => e.preventDefault()}>
<div className={`${step !== 1 ? 'hidden' : ''}`}>
<div className='mt-7 mb-2'>
<InputField
label="Nama Lengkap"
size="lg"
className="w-full"
placeholder="Nama Lengkap"
name={'fullname'}
label="Nama Lengkap" size="lg" className="w-full"
placeholder="Nama Lengkap" name="fullname"
value={form.fullname} onChange={(e) => setF('fullname', e.target.value)}
/>
</div>
<SelectField
label="Jenis Kelamin"
defaultValue=""
size="lg"
error=""
label="Jenis Kelamin" defaultValue="" size="lg" error=""
value={form.gender} onChange={(e) => setF('gender', e.target.value)}
>
<option value="" disabled hidden>
Pilih Jenis Kelamin
</option>
<option value="laki-laki">Laki - Laki</option>
<option value="perempuan">Perempuan</option>
<option value="" disabled hidden>Pilih Jenis Kelamin</option>
<option value="Laki-laki">Laki - Laki</option>
<option value="Perempuan">Perempuan</option>
</SelectField>
<div className='my-2'>
<InputField
label="No Telp/Whatsapp Only"
size="lg"
className="w-full"
placeholder="Nama Lengkap"
name={'fullname'}
label="No Telp/Whatsapp Only" size="lg" className="w-full"
placeholder="Nomor WhatsApp" name="phone_number"
value={form.phone_number} onChange={(e) => setF('phone_number', e.target.value)}
/>
</div>
<SelectField
label="Domisili"
defaultValue=""
size="lg"
error=""
label="Domisili" defaultValue="" size="lg" error=""
value={form.domicile} onChange={(e) => setF('domicile', e.target.value)}
>
<option value="" disabled hidden>
Pilih Domisili
</option>
<option value="aceh">Aceh</option>
<option value="jawa-selatan">Jawa Selatan</option>
<option value="" disabled hidden>Pilih Domisili</option>
<option value="Aceh">Aceh</option>
<option value="Jawa Barat">Jawa Barat</option>
</SelectField>
<div className='mt-2'>
<SelectField
label="Pendidikan Terakhir"
defaultValue=""
size="lg"
error=""
label="Pendidikan Terakhir" defaultValue="" size="lg" error=""
value={form.last_education} onChange={(e) => setF('last_education', e.target.value)}
>
<option value="" disabled hidden>
Apa pendidikan terakhir senpai
</option>
<option value="" disabled hidden>Apa pendidikan terakhir senpai</option>
<option value="SMA">SMA</option>
<option value="S1">S1</option>
<option value="S2">S2</option>
@@ -90,145 +136,85 @@ export const Components: FC = (): ReactElement => {
</div>
<div className={`${step !== 2 ? 'hidden' : ''}`}>
<div className='mt-7 mb-2 gap-2 flex flex-col'>
<InputField
label="Perusahaan Saat Ini"
size="lg"
className="w-full"
placeholder="Masukkan Perusahaan tempat kerja, Senpai~! ✨ (Pastikan tidak typo, ya~ 😆)"
name={'fullname'}
/>
<InputField
label="Masukan jabatan senpai di perusahaan"
size="lg"
className="w-full"
placeholder="Masukan jabatan senpai di perusahaan"
name={'fullname'}
/>
<SelectField
label="Topik Keahlian Yang Ingin Diajarkan"
defaultValue=""
size="lg"
error=""
>
<option value="" disabled hidden>
Apa yang ingin anda ajarkan senpaii
</option>
<option value="Frontend Developer">Frontend Developer</option>
<option value="Backend Developer">Backend Developer</option>
<option value="Other">Other</option>
</SelectField>
<InputField
label="Jika Mengisi “Other”, Tulis Topik yang Kamu Kuasai"
size="lg"
className="w-full"
placeholder="Tolong isi jika tidak ada di pilihan senpai"
name={'fullname'}
/>
<InputField
label="Skill Utama yang Kamu Miliki (boleh lebih dari satu)"
size="lg"
className="w-full"
placeholder="Isi Skill nya dong senpai"
name={'fullname'}
/>
<InputField label="Perusahaan Saat Ini" size="lg" className="w-full"
placeholder="Masukkan Perusahaan tempat kerja, Senpai~! ✨" name="current_company"
value={form.current_company} onChange={(e) => setF('current_company', e.target.value)} />
<InputField label="Masukan jabatan senpai di perusahaan" size="lg" className="w-full"
placeholder="Jabatan" name="current_role"
value={form.current_role} onChange={(e) => setF('current_role', e.target.value)} />
<InputField label="Topik Keahlian Yang Ingin Diajarkan (pisahkan koma)" size="lg" className="w-full"
placeholder="mis. Backend Developer, Rust" name="expertise"
value={form.expertise} onChange={(e) => setF('expertise', e.target.value)} />
<InputField label="Industri (pisahkan koma)" size="lg" className="w-full"
placeholder="mis. Software, Education" name="industries"
value={form.industries} onChange={(e) => setF('industries', e.target.value)} />
<InputField label="Skill Utama / Bahasa (pisahkan koma)" size="lg" className="w-full"
placeholder="mis. Rust, TypeScript" name="languages"
value={form.languages} onChange={(e) => setF('languages', e.target.value)} />
<InputField label="Tahun Pengalaman" type="number" size="lg" className="w-full"
placeholder="mis. 3" name="years_of_experience"
value={form.years_of_experience} onChange={(e) => setF('years_of_experience', e.target.value)} />
<InputField label="Bio Singkat" size="lg" className="w-full"
placeholder="Ceritakan pengalaman Anda" name="bio"
value={form.bio} onChange={(e) => setF('bio', e.target.value)} />
</div>
</div>
<div className={`${step !== 3 ? 'hidden' : ''}`}>
<div className='mt-7 mb-2 gap-2 flex flex-col'>
<SelectField
label="Durasi Sesi Mentoring"
defaultValue=""
size="lg"
error=""
>
<option value="" disabled hidden>
Placeholder
</option>
<InputField label="Topik Mentoring (pisahkan koma)" size="lg" className="w-full"
placeholder="mis. Rust Programming, Backend" name="topics_of_interest"
value={form.topics_of_interest} onChange={(e) => setF('topics_of_interest', e.target.value)} />
<SelectField label="Tingkat Mentee" defaultValue="" size="lg" error=""
value={form.preferred_mentee_level} onChange={(e) => setF('preferred_mentee_level', e.target.value)}>
<option value="" disabled hidden>Pilih tingkat mentee</option>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
</SelectField>
<SelectField
label="Format Pengajaran yang Anda Sediakan"
defaultValue=""
size="lg"
error=""
>
<option value="" disabled hidden>
Placeholder
</option>
</SelectField>
<InputField
type="file"
label="Unggah Curiculum Vitae"
size="lg"
className="w-full"
/>
<InputField
label="Unggah Portofolio"
size="lg"
className="w-full"
placeholder="Google Drive, Behance, GitHub, Notion, dan lainnya"
/>
<InputField
type='number'
label="Honorarium yang Diharapkan per Sesi (Online) Durasi rata-rata sesi: 2,53 jam"
size="lg"
className="w-full"
placeholder="Isi Nominalnya dong senpaiii!!!"
/>
<InputField label="Format Pengajaran (pisahkan koma, mis. online,offline)" size="lg" className="w-full"
placeholder="online,offline" name="preferred_mentoring_formats"
value={form.preferred_mentoring_formats} onChange={(e) => setF('preferred_mentoring_formats', e.target.value)} />
<InputField label="Komitmen Ketersediaan" size="lg" className="w-full"
placeholder="mis. 2 jam per minggu" name="availability_commitment"
value={form.availability_commitment} onChange={(e) => setF('availability_commitment', e.target.value)} />
<InputField label="Honorarium yang Diharapkan per Sesi" type="number" size="lg" className="w-full"
placeholder="mis. 500000" name="mentoring_rate_amount"
value={form.mentoring_rate_amount} onChange={(e) => setF('mentoring_rate_amount', e.target.value)} />
<InputField label="URL LinkedIn" size="lg" className="w-full"
placeholder="https://linkedin.com/in/..." name="linkedin_url"
value={form.linkedin_url} onChange={(e) => setF('linkedin_url', e.target.value)} />
<InputField label="URL Portofolio" size="lg" className="w-full"
placeholder="Google Drive, GitHub, Behance" name="portfolio_url"
value={form.portfolio_url} onChange={(e) => setF('portfolio_url', e.target.value)} />
<h6 className='text-xs'>Silahkan isi dalam angka (Contoh:500.000)</h6>
</div>
</div>
<div className={`${step !== 4 ? 'hidden' : ''}`}>
<div className='mt-7 mb-2 gap-2 flex flex-col'>
<InputField
type='email'
label="Email"
size="lg"
className="w-full"
placeholder="isi emailnya dong senpai!!!"
/>
<InputField
type='password'
label="Buat Password"
size="lg"
className="w-full"
placeholder="Buat Password"
/>
<InputField
type='password'
label="Buat Password"
size="lg"
className="w-full"
placeholder="Buat Password"
/>
<InputField type='email' label="Email" size="lg" className="w-full"
placeholder="isi emailnya dong senpai!!!" name="email"
value={form.email} onChange={(e) => setF('email', e.target.value)} />
<InputField type='password' label="Buat Password" size="lg" className="w-full"
placeholder="Buat Password" name="password"
value={form.password} onChange={(e) => setF('password', e.target.value)} />
<InputField type='password' label="Konfirmasi Password" size="lg" className="w-full"
placeholder="Ulangi Password" name="confirmPassword"
value={form.confirmPassword} onChange={(e) => setF('confirmPassword', e.target.value)} />
</div>
</div>
{error && <p className="text-danger-600 text-xs mt-3 font-medium">{error}</p>}
<div className='flex justify-end mt-4 gap-2'>
<Button
variant='secondary'
onClick={() => setStep(step - 1)}
type='button'
className={`${step === 1 ? 'hidden' : ''}`}
>
<ArrowLeftOutlined />
Kembali
<Button variant='secondary' onClick={() => setStep(step - 1)} type='button'
className={`${step === 1 ? 'hidden' : ''}`}>
<ArrowLeftOutlined /> Kembali
</Button>
<Button
variant='primary'
onClick={() => setStep(step + 1)}
type='button'
className={`${step === 4 ? 'hidden' : ''}`}
>
Lanjutkan
<ArrowRightOutlined />
<Button variant='primary' onClick={() => setStep(step + 1)} type='button'
className={`${step === 4 ? 'hidden' : ''}`}>
Lanjutkan <ArrowRightOutlined />
</Button>
<Button
variant='primary'
onClick={() => setStep(step + 1)}
type='button'
className={`${step === 4 ? '' : 'hidden'}`}
>
Selesaikan
<ArrowRightOutlined />
<Button variant='primary' onClick={handleSubmit} type='button'
className={`${step === 4 ? '' : 'hidden'}`} disabled={submitting}>
{submitting ? "Mengirim..." : "Selesaikan"} <ArrowRightOutlined />
</Button>
</div>
</form>
@@ -239,4 +225,4 @@ export const Components: FC = (): ReactElement => {
);
};
export default Components;
export default Components;
@@ -17,10 +17,10 @@ export const Components: FC = (): ReactElement => {
Data kamu telah kami terima dan sedang dalam proses verifikasi.
</h4>
<svg className="mt-10" width="148" height="148" viewBox="0 0 148 148" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="74" cy="74" r="73.6348" fill="#42CDF8"/>
<rect x="23.5078" y="24.8463" width="99.646" height="99.646" rx="49.823" fill="#0185D0"/>
<circle cx="74" cy="74" r="73.6348" className="fill-info-400"/>
<rect x="23.5078" y="24.8463" width="99.646" height="99.646" rx="49.823" className="fill-info-600"/>
<circle cx="73.3284" cy="74.6689" r="33.11" transform="rotate(150 73.3284 74.6689)" stroke="#F0F8FF" stroke-width="1.1825"/>
<path d="M44.6436 91.2314C42.1053 92.6969 41.2086 95.9711 43.0096 98.2836C46.1026 102.255 49.9569 105.594 54.3651 108.095C60.3178 111.473 67.0644 113.199 73.9077 113.096C80.7511 112.993 87.4427 111.065 93.2912 107.51C97.6222 104.878 101.374 101.424 104.347 97.3614C106.077 94.9958 105.083 91.75 102.501 90.3615C99.9201 88.9729 96.7369 89.9826 94.884 92.2537C92.8925 94.6949 90.4923 96.7901 87.7782 98.4398C83.545 101.013 78.7015 102.409 73.7482 102.483C68.7949 102.558 63.9116 101.308 59.6029 98.8634C56.8404 97.2961 54.3784 95.2739 52.3144 92.8938C50.3941 90.6794 47.182 89.7658 44.6436 91.2314Z" fill="#F0F8FF"/>
<path d="M44.6436 91.2314C42.1053 92.6969 41.2086 95.9711 43.0096 98.2836C46.1026 102.255 49.9569 105.594 54.3651 108.095C60.3178 111.473 67.0644 113.199 73.9077 113.096C80.7511 112.993 87.4427 111.065 93.2912 107.51C97.6222 104.878 101.374 101.424 104.347 97.3614C106.077 94.9958 105.083 91.75 102.501 90.3615C99.9201 88.9729 96.7369 89.9826 94.884 92.2537C92.8925 94.6949 90.4923 96.7901 87.7782 98.4398C83.545 101.013 78.7015 102.409 73.7482 102.483C68.7949 102.558 63.9116 101.308 59.6029 98.8634C56.8404 97.2961 54.3784 95.2739 52.3144 92.8938C50.3941 90.6794 47.182 89.7658 44.6436 91.2314Z" className="fill-primary-50"/>
</svg>
<h4 className="mt-10 text-primary-500 font-medium text-xl text-center">
Mohon tunggu maksimal 2 hari kerja untuk proses aktivasi akun. Kami akan menghubungi kamu jika proses telah selesai.
@@ -24,18 +24,18 @@ export const Components: FC = (): ReactElement => {
xmlns="http://www.w3.org/2000/svg"
className="mt-10"
>
<circle cx="99" cy="99" r="99" fill="#BCF8B0" />
<circle cx="99" cy="99" r="99" className="fill-success-200" />
<rect
x="31.1152"
y="32.9141"
width="133.971"
height="133.971"
rx="66.9857"
fill="#35BA43"
className="fill-success-500"
/>
<path
d="M127.935 75.8846H122.722C121.991 75.8846 121.297 76.2202 120.85 76.7945L90.0997 115.748L75.355 97.0657C75.1319 96.7825 74.8476 96.5535 74.5234 96.3959C74.1992 96.2384 73.8435 96.1563 73.483 96.1558H68.2697C67.77 96.1558 67.4941 96.7301 67.7999 97.1179L88.2277 122.998C89.1824 124.206 91.0171 124.206 91.9792 122.998L128.405 76.8392C128.711 76.4589 128.435 75.8846 127.935 75.8846Z"
fill="#E0FBD8"
className="fill-success-100"
/>
</svg>
<h4 className="mt-10 text-primary-500 font-medium text-xl text-center">
@@ -65,27 +65,27 @@ export const Components: FC = (): ReactElement => {
control={form.control}
name={'password'}
/>
<h6 className="text-sm text-gray-500 mt-1">
<h6 className="text-sm text-neutral-500 mt-1">
"Senpai~! Pastikan password-mu sekuat pertahanan kastil!"
</h6>
<h6 className="text-sm text-gray-500 mt-2">
<h6 className="text-sm text-neutral-500 mt-2">
Tips membuat password yang OP:
</h6>
<h6 className="text-sm text-gray-500">
<h6 className="text-sm text-neutral-500">
- Minimal 8 karakter (semakin panjang, semakin power-up!{' '}
<span role="img" aria-label="emoji">
</span>
)
</h6>
<h6 className="text-sm text-gray-500">
<h6 className="text-sm text-neutral-500">
- Campur huruf besar, kecil, angka, dan simbol untuk kombinasi
ultimate!
<span role="img" aria-label="emoji">
🔥
</span>
</h6>
<h6 className="text-sm text-gray-500 mb-2">
<h6 className="text-sm text-neutral-500 mb-2">
- Jangan pakai password yang gampang ditebak, nanti ketahuan
musuh!
<span role="img" aria-label="emoji">
@@ -30,18 +30,18 @@ export const Components: FC = (): ReactElement => {
xmlns="http://www.w3.org/2000/svg"
className="mt-10"
>
<circle cx="99" cy="99" r="99" fill="#BCF8B0" />
<circle cx="99" cy="99" r="99" className="fill-success-200" />
<rect
x="31.1152"
y="32.9141"
width="133.971"
height="133.971"
rx="66.9857"
fill="#35BA43"
className="fill-success-500"
/>
<path
d="M127.935 75.8846H122.722C121.991 75.8846 121.297 76.2202 120.85 76.7945L90.0997 115.748L75.355 97.0657C75.1319 96.7825 74.8476 96.5535 74.5234 96.3959C74.1992 96.2384 73.8435 96.1563 73.483 96.1558H68.2697C67.77 96.1558 67.4941 96.7301 67.7999 97.1179L88.2277 122.998C89.1824 124.206 91.0171 124.206 91.9792 122.998L128.405 76.8392C128.711 76.4589 128.435 75.8846 127.935 75.8846Z"
fill="#E0FBD8"
className="fill-success-100"
/>
</svg>
<h4 className="mt-10 text-primary-500 font-medium text-xl">
+64 -64
View File
@@ -5,69 +5,67 @@
@theme {
--color-primary-50: #f0f8ff;
--color-primary-100: #e1f0fd;
--color-primary-200: #b8e1f8;
--color-primary-300: #a1d4f0;
--color-primary-400: #4aa4da;
--color-primary-500: #1a8ce6;
--color-primary-600: #1673c7;
--color-primary-700: #0f5ca5;
--color-primary-800: #0a4780;
--color-primary-900: #04305d;
--color-primary-200: #bce1fb;
--color-primary-300: #81cbf8;
--color-primary-400: #3eb0f2;
--color-primary-500: #23a1eb;
--color-primary-600: #0877c1;
--color-primary-700: #085f9c;
--color-primary-800: #0b5181;
--color-primary-900: #0f446b;
--color-primary-950: #0a2b47;
--color-neutral-50: #f7f7f7;
--color-neutral-50: #f6f6f6;
--color-neutral-100: #e7e7e7;
--color-neutral-200: #d0d0d0;
--color-neutral-300: #b7b7b7;
--color-neutral-400: #9f9f9f;
--color-neutral-500: #7e7e7e;
--color-neutral-200: #d1d1d1;
--color-neutral-300: #b0b0b0;
--color-neutral-400: #888888;
--color-neutral-500: #6d6d6d;
--color-neutral-600: #5d5d5d;
--color-neutral-700: #4a4a4a;
--color-neutral-800: #3a3a3a;
--color-neutral-900: #2f2f2f;
--color-neutral-700: #4f4f4f;
--color-neutral-800: #454545;
--color-neutral-900: #3d3d3d;
--color-neutral-950: #2b2b2b;
--color-success-50: #e7f2ee;
--color-success-100: #cde6dd;
--color-success-200: #9bccbc;
--color-success-300: #78bdab;
--color-success-400: #4ca88f;
--color-success-500: #349078;
--color-success-600: #2f7c66;
--color-success-700: #26624f;
--color-success-800: #1e4a3b;
--color-success-900: #1b513b;
--color-success-100: #e0fbd8;
--color-success-200: #bcf8b0;
--color-success-300: #8eea85;
--color-success-400: #63d564;
--color-success-500: #35ba43;
--color-success-600: #269f3e;
--color-success-700: #1a8439;
--color-success-800: #106b32;
--color-success-900: #0b592f;
--color-info-50: #e7f4f5;
--color-info-100: #cce9ea;
--color-info-200: #99d3d5;
--color-info-300: #78c4c7;
--color-info-400: #3aa9ad;
--color-info-500: #279599;
--color-info-600: #207d82;
--color-info-700: #1a666b;
--color-info-800: #124c52;
--color-info-900: #093d43;
--color-info-100: #ccfcfe;
--color-info-200: #9bf3fd;
--color-info-300: #67e3fb;
--color-info-400: #42cdf8;
--color-info-500: #04acf3;
--color-info-600: #0185d0;
--color-info-700: #0264af;
--color-info-800: #01478d;
--color-info-900: #003375;
--color-warning-50: #fffce6;
--color-warning-100: #fff8cc;
--color-warning-200: #fef39b;
--color-warning-300: #fde967;
--color-warning-400: #fbd93d;
--color-warning-500: #f3c215;
--color-warning-600: #d7a20f;
--color-warning-700: #aa7e0c;
--color-warning-800: #805c07;
--color-warning-900: #665c00;
--color-warning-100: #fffcd3;
--color-warning-200: #fffaa9;
--color-warning-300: #fff67d;
--color-warning-400: #fff25d;
--color-warning-500: #ffed27;
--color-warning-600: #dbc91d;
--color-warning-700: #b7a714;
--color-warning-800: #93850b;
--color-warning-900: #7a6d07;
--color-danger-50: #ffe7e7;
--color-danger-100: #ffcece;
--color-danger-200: #ff9f9f;
--color-danger-300: #ff6e6e;
--color-danger-400: #fa4646;
--color-danger-500: #ef1f1f;
--color-danger-600: #d11515;
--color-danger-700: #a51111;
--color-danger-800: #7f0c0c;
--color-danger-900: #5d2d2d;
--color-danger-100: #ffe8da;
--color-danger-200: #ffcbb3;
--color-danger-300: #ffaa8d;
--color-danger-400: #ff8870;
--color-danger-500: #ff5242;
--color-danger-600: #da3030;
--color-danger-700: #b7212d;
--color-danger-800: #93152a;
--color-danger-900: #7a0c27;
--radius-none: 0px;
--radius-sm: 2px;
@@ -80,13 +78,15 @@
--font-bai-jamjuree: 'Bai Jamjuree', sans-serif;
--text-h1: 3rem; /* ~48px */
--text-h2: 2.4rem; /* ~38.4px */
--text-h3: 1.92rem; /* ~30.72px */
--text-p1: 1.54rem; /* ~24.58px */
--text-p2: 1.23rem; /* ~19.68px */
--text-label1: 0.98rem; /* ~15.74px */
--text-label2: 0.78rem; /* ~12.59px */
--text-h1: 3.833rem; /* ~46px */
--text-h2: 3.083rem; /* ~37px */
--text-h3: 2.417rem; /* ~29px */
--text-p1: 1.917rem; /* ~23px */
--text-p2: 1.583rem; /* ~19px */
--text-p3: 1.25rem; /* 15px */
--text-label1: 1rem; /* 12px */
--text-label2: 0.833rem; /* ~10px */
--text-label3: 0.677rem; /* ~8px */
}
@layer base {
@@ -124,4 +124,4 @@
.scrollbar-hide::-webkit-scrollbar {
@apply hidden;
}
}
}
+25 -13
View File
@@ -17,7 +17,9 @@ const mappingPublicRoutes = [
'/auth/register-mentor',
'/auth/register-mentor/pending',
'/auth/register-mentor/success',
'/mentoring',
'/resources',
'/articles',
];
const mappingRoutePermissions = [
@@ -80,6 +82,13 @@ const mappingPublicPrefixRoutes = [
'/articles',
]
const mappingProtectedPrefixRoutes = [
'/dashboard',
'/users',
'/roles',
'/permissions',
]
//TODO : Fix this later
// const redirectToFirstAccessibleRoute = (userPermissions: string[]) => {
// const fallback = mappingRoutePermissions.find((route) =>
@@ -109,21 +118,24 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
return null;
}
if (!session) return redirect('/auth/login');
const matchedRoute = mappingRoutePermissions.find(
(route) => route.path === pathname
);
if (matchedRoute) {
const hasPermission =
!matchedRoute.permissions ||
matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
if (!hasPermission) {
return '/dashboard';
// Route yang butuh autentikasi: prefix admin backoffice
if (mappingProtectedPrefixRoutes.some((p) => pathname.startsWith(p))) {
if (!session) return redirect('/auth/login');
const matchedRoute = mappingRoutePermissions.find(
(route) => route.path === pathname
);
if (matchedRoute) {
const hasPermission =
!matchedRoute.permissions ||
matchedRoute.permissions.some((perm) =>
userPermissions.includes(perm)
);
if (!hasPermission) return '/dashboard';
}
return null;
}
// Route publik/halaman FE (materi, ai-agent, my-sessions, detail mentor,
// 404 tak dikenal) tidak redirect — biarkan halaman-route menanganinya.
return null;
};
+265
View File
@@ -0,0 +1,265 @@
import { api } from '../';
import {
TArticleDetail,
TArticleListItem,
TArticleListParams,
TChatRequest,
TChatResponse,
TMaterialDetail,
TMaterialItem,
TMaterialListResponse,
TBookSessionRequest,
TCreatePaymentRequest,
TMentorAvailability,
TMentorDetail,
TMentorListParams,
TMentorRegisterRequest,
TMentorStats,
TPayment,
TSessionFeedbackRequest,
TSessionListResponse,
} from '../../types/dimentorin';
import { TResponseMessage } from '../../types/common';
export const getMentors = async (
params?: TMentorListParams
): Promise<TMentorDetail[]> => {
const { data } = await api({
method: 'GET',
url: '/dimentorin/mentors/public',
params,
});
return data.data;
};
export const getMentorDetail = async (id: string): Promise<TMentorDetail> => {
const { data } = await api({
method: 'GET',
url: `/dimentorin/mentors/public/${id}`,
});
return data.data;
};
export const getMentorMe = async (): Promise<TMentorDetail> => {
const { data } = await api({
method: 'GET',
url: '/dimentorin/mentors/me',
});
return data.data;
};
export const getMentorMeStatus = async (): Promise<{ status: string }> => {
const { data } = await api({
method: 'GET',
url: '/dimentorin/mentors/me/status',
});
return data.data;
};
export const getMentorAvailability = async (
id: string
): Promise<TMentorAvailability> => {
const { data } = await api({
method: 'GET',
url: `/dimentorin/mentors/${id}/availability`,
});
return data.data;
};
export const getMentorSessions = async (
id: string
): Promise<TSessionListResponse> => {
const { data } = await api({
method: 'GET',
url: `/dimentorin/mentors/${id}/sessions`,
});
return data.data;
};
export const getMySessions = async (): Promise<TSessionListResponse> => {
const { data } = await api({
method: 'GET',
url: '/dimentorin/sessions/me',
});
return data.data;
};
export const postBookSession = async (
mentorId: string,
payload: TBookSessionRequest
): Promise<TResponseMessage> => {
const { data } = await api({
method: 'POST',
url: `/dimentorin/mentors/${mentorId}/sessions/create`,
data: payload,
});
return data;
};
export const postSessionFeedback = async (
sessionId: string,
payload: TSessionFeedbackRequest
): Promise<TResponseMessage> => {
const { data } = await api({
method: 'POST',
url: `/dimentorin/sessions/${sessionId}/feedback/create`,
data: payload,
});
return data;
};
export const getArticles = async (
params?: TArticleListParams
): Promise<TArticleListItem[]> => {
const { data } = await api({
method: 'GET',
url: '/dimentorin/articles',
params,
});
return data.data;
};
export const getArticleCategories = async (): Promise<string[]> => {
const { data } = await api({
method: 'GET',
url: '/dimentorin/articles/categories',
});
return data.data;
};
export const getArticleBySlug = async (slug: string): Promise<TArticleDetail> => {
const { data } = await api({
method: 'GET',
url: `/dimentorin/articles/slug/${slug}`,
});
return data.data;
};
export const getArticleById = async (id: string): Promise<TArticleDetail> => {
const { data } = await api({
method: 'GET',
url: `/dimentorin/articles/${id}`,
});
return data.data;
};
export const getMentorStats = async (mentorId: string): Promise<TMentorStats> => {
const { data } = await api({
method: 'GET',
url: `/dimentorin/mentors/${mentorId}/stats`,
});
return data.data;
};
export const postRegisterMentor = async (
payload: TMentorRegisterRequest
): Promise<TResponseMessage> => {
const { data } = await api({
method: 'POST',
url: '/dimentorin/mentors/create',
data: payload,
});
return data;
};
export const postCreatePayment = async (
sessionId: string,
payload: TCreatePaymentRequest
): Promise<TPayment> => {
const { data } = await api({
method: 'POST',
url: `/dimentorin/payments/sessions/${sessionId}/create`,
data: payload,
});
return data.data;
};
export const getMyPayments = async (): Promise<TPayment[]> => {
const { data } = await api({
method: 'GET',
url: '/dimentorin/payments/me',
});
return data.data;
};
export const getPaymentById = async (id: string): Promise<TPayment> => {
const { data } = await api({
method: 'GET',
url: `/dimentorin/payments/${id}`,
});
return data.data;
};
export const getSessionPayments = async (
sessionId: string
): Promise<TPayment[]> => {
const { data } = await api({
method: 'GET',
url: `/dimentorin/payments/session/${sessionId}`,
});
return data.data;
};
export const postConfirmPayment = async (
id: string
): Promise<TResponseMessage> => {
const { data } = await api({
method: 'POST',
url: `/dimentorin/payments/${id}/confirm`,
});
return data;
};
export const postRefreshPayment = async (
id: string
): Promise<TPayment> => {
const { data } = await api({
method: 'POST',
url: `/dimentorin/payments/${id}/refresh`,
});
return data.data;
};
// ---- Materials ----
export const getMaterialsList = async (): Promise<TMaterialListResponse> => {
const { data } = await api({ method: 'GET', url: '/dimentorin/materials' });
return data.data;
};
export const getMaterialBySlug = async (
slug: string
): Promise<TMaterialDetail> => {
const { data } = await api({
method: 'GET',
url: `/dimentorin/materials/slug/${slug}`,
});
return data.data;
};
export const getMaterialById = async (
id: string
): Promise<TMaterialDetail> => {
const { data } = await api({ method: 'GET', url: `/dimentorin/materials/${id}` });
return data.data;
};
export const getMaterialCategories = async (): Promise<string[]> => {
const { data } = await api({
method: 'GET',
url: '/dimentorin/materials/categories',
});
return data.data;
};
// ---- AI Agent (RAG) ----
export const postChat = async (
request: TChatRequest
): Promise<TChatResponse> => {
const { data } = await api({
method: 'POST',
url: '/dimentorin/ai/chat',
data: request,
});
return data.data;
};
+24 -1
View File
@@ -1,6 +1,8 @@
import axios, { AxiosRequestConfig } from 'axios';
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
import Cookies from 'js-cookie';
export * from './auth';
export * from './dimentorin';
export * from './gacha';
export * from './users';
@@ -9,3 +11,24 @@ const config: AxiosRequestConfig = {
};
export const api = axios.create(config);
api.interceptors.request.use((config) => {
const raw = Cookies.get('token');
if (raw) {
try {
const parsed = JSON.parse(raw);
const token = parsed?.token?.access_token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
} catch {
// ignore malformed token
}
}
return config;
});
api.interceptors.response.use(
(response) => response,
(error: AxiosError) => Promise.reject(error)
);
+309
View File
@@ -0,0 +1,309 @@
import { useMutation, useQuery, useQueryClient, UseQueryResult } from '@tanstack/react-query';
import {
getArticleById,
getArticleBySlug,
getArticleCategories,
getArticles,
getMentorAvailability,
getMentorDetail,
getMentorMe,
getMentorMeStatus,
getMentorSessions,
getMentorStats,
getMentors,
getMyPayments,
getMaterialById,
getMaterialBySlug,
getMaterialCategories,
getMaterialsList,
getMySessions,
postChat,
getPaymentById,
getSessionPayments,
postBookSession,
postConfirmPayment,
postCreatePayment,
postRefreshPayment,
postRegisterMentor,
postSessionFeedback,
} from '../../api/dimentorin';
import {
TArticleDetail,
TArticleListItem,
TArticleListParams,
TBookSessionRequest,
TChatRequest,
TChatResponse,
TMaterialDetail,
TMaterialItem,
TMaterialListResponse,
TCreatePaymentRequest,
TMentorAvailability,
TMentorDetail,
TMentorListParams,
TMentorRegisterRequest,
TMentorStats,
TPayment,
TSessionFeedbackRequest,
TSessionListResponse,
} from '../../types/dimentorin';
import { TResponseError, TResponseMessage } from '../../types/common';
export const useGetMentors = (
params?: TMentorListParams
): UseQueryResult<TMentorDetail[], TResponseError> => {
return useQuery({
queryKey: ['get-mentors', params],
queryFn: async () => await getMentors(params),
});
};
export const useGetMentorDetail = (
id: string
): UseQueryResult<TMentorDetail, TResponseError> => {
return useQuery({
queryKey: ['get-mentor-detail', id],
queryFn: async () => await getMentorDetail(id),
enabled: !!id,
});
};
export const useGetMentorMe = (): UseQueryResult<
TMentorDetail,
TResponseError
> => {
return useQuery({
queryKey: ['get-mentor-me'],
queryFn: async () => await getMentorMe(),
});
};
export const useGetMentorMeStatus = (): UseQueryResult<
{ status: string },
TResponseError
> => {
return useQuery({
queryKey: ['get-mentor-me-status'],
queryFn: async () => await getMentorMeStatus(),
});
};
export const useGetMentorAvailability = (
mentorId: string
): UseQueryResult<TMentorAvailability, TResponseError> => {
return useQuery({
queryKey: ['get-mentor-availability', mentorId],
queryFn: async () => await getMentorAvailability(mentorId),
enabled: !!mentorId,
});
};
export const useGetMentorStats = (
mentorId: string
): UseQueryResult<TMentorStats, TResponseError> => {
return useQuery({
queryKey: ['get-mentor-stats', mentorId],
queryFn: async () => await getMentorStats(mentorId),
enabled: !!mentorId,
});
};
export const useGetMentorSessions = (
id: string
): UseQueryResult<TSessionListResponse, TResponseError> => {
return useQuery({
queryKey: ['get-mentor-sessions', id],
queryFn: async () => await getMentorSessions(id),
enabled: !!id,
});
};
export const useGetMySessions = (): UseQueryResult<
TSessionListResponse,
TResponseError
> => {
return useQuery({
queryKey: ['get-my-sessions'],
queryFn: async () => await getMySessions(),
});
};
export const usePostBookSession = (mentorId: string) => {
return useMutation({
mutationKey: ['post-book-session', mentorId],
mutationFn: async (payload: TBookSessionRequest) =>
await postBookSession(mentorId, payload),
});
};
export const usePostSessionFeedback = (sessionId: string) => {
return useMutation({
mutationKey: ['post-session-feedback', sessionId],
mutationFn: async (payload: TSessionFeedbackRequest) =>
await postSessionFeedback(sessionId, payload),
});
};
export const usePostRegisterMentor = () => {
return useMutation({
mutationKey: ['post-register-mentor'],
mutationFn: async (payload: TMentorRegisterRequest) =>
await postRegisterMentor(payload),
});
};
export const usePostCreatePayment = (sessionId: string) => {
return useMutation({
mutationKey: ['post-create-payment', sessionId],
mutationFn: async (payload: TCreatePaymentRequest) =>
await postCreatePayment(sessionId, payload),
});
};
export const useGetMyPayments = (): UseQueryResult<
TPayment[],
TResponseError
> => {
return useQuery({
queryKey: ['get-my-payments'],
queryFn: async () => await getMyPayments(),
});
};
export const useGetPaymentById = (
id: string
): UseQueryResult<TPayment, TResponseError> => {
return useQuery({
queryKey: ['get-payment-by-id', id],
queryFn: async () => await getPaymentById(id),
enabled: !!id,
});
};
export const useGetSessionPayments = (
sessionId: string
): UseQueryResult<TPayment[], TResponseError> => {
return useQuery({
queryKey: ['get-session-payments', sessionId],
queryFn: async () => await getSessionPayments(sessionId),
enabled: !!sessionId,
});
};
export const usePostConfirmPayment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ['post-confirm-payment'],
mutationFn: async (id: string) => await postConfirmPayment(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['get-my-payments'] });
queryClient.invalidateQueries({ queryKey: ['get-my-sessions'] });
queryClient.invalidateQueries({ queryKey: ['get-mentor-sessions'] });
},
});
};
export const usePostRefreshPayment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ['post-refresh-payment'],
mutationFn: async (id: string) => await postRefreshPayment(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['get-my-payments'] });
queryClient.invalidateQueries({ queryKey: ['get-my-sessions'] });
queryClient.invalidateQueries({ queryKey: ['get-mentor-sessions'] });
},
});
};
export const useGetArticles = (
params?: TArticleListParams
): UseQueryResult<TArticleListItem[], TResponseError> => {
return useQuery({
queryKey: ['get-articles', params],
queryFn: async () => await getArticles(params),
});
};
export const useGetArticleCategories = (): UseQueryResult<
string[],
TResponseError
> => {
return useQuery({
queryKey: ['get-article-categories'],
queryFn: async () => await getArticleCategories(),
});
};
export const useGetArticleBySlug = (
slug: string
): UseQueryResult<TArticleDetail, TResponseError> => {
return useQuery({
queryKey: ['get-article-by-slug', slug],
queryFn: async () => await getArticleBySlug(slug),
enabled: !!slug,
});
};
export const useGetArticleById = (
id: string
): UseQueryResult<TArticleDetail, TResponseError> => {
return useQuery({
queryKey: ['get-article-by-id', id],
queryFn: async () => await getArticleById(id),
enabled: !!id,
});
};
// ---- Materials ----
export const useGetMaterialsList = (): UseQueryResult<
TMaterialListResponse,
TResponseError
> => {
return useQuery({
queryKey: ['get-materials-list'],
queryFn: async () => await getMaterialsList(),
});
};
export const useGetMaterialBySlug = (
slug: string
): UseQueryResult<TMaterialDetail, TResponseError> => {
return useQuery({
queryKey: ['get-material-by-slug', slug],
queryFn: async () => await getMaterialBySlug(slug),
enabled: !!slug,
});
};
export const useGetMaterialById = (
id: string
): UseQueryResult<TMaterialDetail, TResponseError> => {
return useQuery({
queryKey: ['get-material-by-id', id],
queryFn: async () => await getMaterialById(id),
enabled: !!id,
});
};
export const useGetMaterialCategories = (): UseQueryResult<
string[],
TResponseError
> => {
return useQuery({
queryKey: ['get-material-categories'],
queryFn: async () => await getMaterialCategories(),
});
};
// ---- AI Agent ----
export const usePostChat = () => {
return useMutation({
mutationKey: ['post-chat'],
mutationFn: async (request: TChatRequest) => await postChat(request),
});
};
export type { TResponseMessage };
+1
View File
@@ -1,3 +1,4 @@
export * from './auth';
export * from './dimentorin';
export * from './gacha';
export * from './users';
+231
View File
@@ -0,0 +1,231 @@
import { TResponseList } from '../common';
export type TMentorListItem = {
id: string;
user_id: string;
fullname: string | null;
email: string | null;
status: string;
created_at: string;
updated_at: string;
};
export type TMentorDetail = {
id: string;
user_id: string;
fullname: string | null;
email: string | null;
legal_name: string | null;
gender: string | null;
domicile: string | null;
bio: string | null;
last_education: string | null;
linkedin_url: string | null;
github_url: string | null;
cv_url: string | null;
portfolio_url: string | null;
phone_for_verification: string | null;
industries: string[];
expertise: string[];
languages: string[];
current_company: string;
current_role: string;
years_of_experience: number;
topics_of_interest: string[];
preferred_mentee_level: string[];
preferred_mentoring_formats: string[];
availability_commitment: string;
mentoring_rate: number;
status: string;
created_at: string;
updated_at: string;
};
export type TMentorListParams = {
page?: number;
per_page?: number;
search?: string;
sort_by?: string;
order?: string;
};
export type TMentorListResponse = TMentorListItem[] | TResponseList<TMentorListItem>;
export type TAvailabilitySlot = {
date: string;
time: string;
available: boolean;
};
export type TMentorAvailability = {
mentor_id: string;
availability_commitment: string;
preferred_formats: string[];
slots: TAvailabilitySlot[];
booked_dates: string[];
};
export type TSessionListItem = {
id: string;
mentor_id: string;
mentee_id: string;
topic: string;
scheduled_at: string;
duration_minutes: number;
session_type: string;
status: string;
rating: number | null;
created_at: string;
mentee_email: string | null;
mentee_fullname: string | null;
};
export type TSessionListResponse = {
sessions: TSessionListItem[];
total: number;
};
export type TBookSessionRequest = {
topic: string;
scheduled_at: string;
description?: string | null;
duration_minutes?: number | null;
session_type?: string | null;
};
export type TSessionFeedbackRequest = {
feedback: string;
rating: number;
};
export type TMentorStats = {
mentor_id: string;
total_sessions: number;
unique_mentees: number;
avg_rating: number;
};
export type TArticleListItem = {
id: string;
title: string;
slug: string;
category: string;
excerpt: string;
cover_url: string | null;
author_name: string | null;
created_at: string;
};
export type TArticleDetail = TArticleListItem & {
content: string;
is_published: boolean;
updated_at: string;
};
export type TArticleListParams = {
page?: number;
per_page?: number;
category?: string;
};
export type TMentorRegisterRequest = {
email: string;
password: string;
fullname: string;
phone_number?: string | null;
identity_and_verification: {
legal_name: string;
gender?: string | null;
domicile?: string | null;
identity_document_url: string;
phone_for_verification?: string | null;
};
professional_profile: {
bio: string;
last_education?: string | null;
linkedin_url?: string | null;
github_url?: string | null;
cv_url?: string | null;
portfolio_url?: string | null;
industries: string[];
expertise: string[];
languages: string[];
current_company: string;
current_role: string;
years_of_experience: number;
};
mentoring_logistics: {
topics_of_interest: string[];
preferred_mentee_level: string[];
preferred_mentoring_formats: string[];
availability_commitment: string;
mentoring_rate_amount: number;
};
};
export type TPayment = {
id: string;
sessionId: string;
mentorId: string;
amount: number;
serviceFee: number;
total: number;
method: string;
provider: string;
status: string;
externalRef?: string | null;
providerOrderId?: string | null;
expiresAt: string;
createdAt: string;
};
export type TCreatePaymentRequest = {
method: string; // 'va' | 'qris' | 'manual'
};
export type TMaterialItem = {
id: string;
mentorId: string;
title: string;
slug: string;
category: string;
description: string;
coverUrl?: string | null;
isPublished: boolean;
createdAt: string;
updatedAt: string;
};
export type TMaterialDetail = TMaterialItem & {
content: string;
};
export type TMaterialListResponse = {
data: TMaterialItem[];
meta: {
page: number;
per_page: number;
total?: number;
total_pages?: number;
has_next: boolean;
has_prev: boolean;
};
};
export type TChatRequest = {
question: string;
materialId?: string;
};
export type TChatSource = {
materialId: string;
title: string;
score: number;
snippet: string;
};
export type TChatResponse = {
answer: string;
sources: TChatSource[];
};
+1
View File
@@ -1,4 +1,5 @@
export * from './auth';
export * from './dimentorin';
export * from './gacha';
export * from './users';
export * from './roles';
+3 -3
View File
@@ -26,10 +26,10 @@ type TButtonProps = DetailedHTMLProps<
const variantClasses: Record<TButtonVariant, string> = {
primary: 'bg-primary-500 hover:bg-primary-600 text-white shadow-md',
secondary:
'bg-white hover:text-primary-600 hover:bg-gray-50 text-primary-500 shadow-md',
text: 'bg-transparent hover:text-primary-600 hover:bg-gray-50 text-primary-500',
'bg-white hover:text-primary-600 hover:bg-neutral-50 text-primary-500 shadow-md',
text: 'bg-transparent hover:text-primary-600 hover:bg-neutral-50 text-primary-500',
bordered:
'border border-primary-500 hover:border-primary-600 bg-transparent hover:text-primary-600 hover:bg-gray-50 text-primary-500',
'border border-primary-500 hover:border-primary-600 bg-transparent hover:text-primary-600 hover:bg-neutral-50 text-primary-500',
success: 'bg-success-500 hover:bg-success-600 text-white shadow-md',
danger: 'bg-danger-100 hover:bg-danger-200 text-danger-500 shadow-md',
};
+1 -2
View File
@@ -1,5 +1,4 @@
export * from './button';
export * from './input';
export * from './textarea';
export * from './select'
export * from './toggle';
export * from './select'
+1 -1
View File
@@ -49,7 +49,7 @@ export const Input: FC<TInputProps> = ({
};
const mergedClassName = cn(
`px-[12px] py-[8px] text-neutral-800 bg-white placeholder:text-neutral-300 border border-neutral-200 hover:border-blue-300 focus:outline-1 focus:outline-blue-500 rounded-md font-bai-jamjuree ${widthform === "standard" ? "min-w-70" : ""}`,
`px-[12px] py-[8px] text-neutral-800 bg-white placeholder:text-neutral-300 border border-neutral-200 hover:border-primary-300 focus:outline-1 focus:outline-primary-500 rounded-md font-bai-jamjuree ${widthform === "standard" ? "min-w-70" : ""}`,
sizeClasses[size].textSize,
disabled && disabledClass,
className
+1 -1
View File
@@ -35,7 +35,7 @@ export const Select: FC<TSelectProps> = ({
...rest
}): ReactElement => {
const mergedClassName = cn(
`appearance-none px-[12px] py-[8px] text-neutral-800 bg-white placeholder:text-neutral-300 border border-neutral-200 hover:border-blue-300 focus:outline-1 focus:outline-blue-500 rounded-md`,
`appearance-none px-[12px] py-[8px] text-neutral-800 bg-white placeholder:text-neutral-300 border border-neutral-200 hover:border-primary-300 focus:outline-1 focus:outline-primary-500 rounded-md`,
sizeClasses[size],
widthform === 'standard' && 'min-w-70',
disabled && disabledClass,
+1 -1
View File
@@ -38,7 +38,7 @@ export const Textarea: FC<TTextareaProps> = ({
...rest
}): ReactElement => {
const mergedClassName = cn(
'rounded-md border border-neutral-200 hover:border-blue-300 focus:outline-1 focus:outline-blue-500 px-[12px] py-[8px] invalid:border-danger-500 invalid:text-danger-500',
'rounded-md border border-neutral-200 hover:border-primary-300 focus:outline-1 focus:outline-primary-500 px-[12px] py-[8px] invalid:border-danger-500 invalid:text-danger-500',
sizeClasses[size],
disabled && disabledClass,
error && errorClass,
-1
View File
@@ -1 +0,0 @@
export * from './toggle'
-27
View File
@@ -1,27 +0,0 @@
import { cn } from "@imphnen-frontend-service/utils";
import { DetailedHTMLProps, FC, HTMLAttributes } from "react";
export type ToggleInputProps = DetailedHTMLProps<
HTMLAttributes<HTMLInputElement>,
HTMLInputElement
> & {
label?: string
labelClassName?: string
}
export const ToggleInput: FC<ToggleInputProps> = ({ label, className, labelClassName, ...rest }) => {
return (
<label className={cn("flex items-center gap-5 cursor-pointer mb-8", className)}>
<span className={cn("text-p3 text-gray-800 font-medium", labelClassName)}>{label}</span>
<input type="checkbox" className="sr-only peer" {...rest} />
<div
className={cn(
"w-14 h-7 bg-gray-300 rounded-3xl relative transition-colors",
"peer-checked:bg-blue-600 peer-focus:outline peer-focus:outline-blue-500 peer-checked:[&>div]:translate-x-6.5",
)}
>
<div className="absolute left-0.5 top-0.5 h-6 w-6 bg-white rounded-full shadow transition-transform peer-checked:translate-x-6.5" />
</div>
</label>
);
}
@@ -15,15 +15,15 @@ export const ForgotStep: FC<TForgotStepProps> = ({
<div className="w-full grid grid-cols-3 gap-3">
<div className="flex flex-col items-center justify-center gap-2">
<div className={`${step === 1 ? "bg-primary-500" : "bg-primary-200"} px-5 py-2 rounded-md w-full`}></div>
<h4 className={`${step === 1 ? "text-primary-500" : "text-gray-500"} text-xs`}>Masukkan Email</h4>
<h4 className={`${step === 1 ? "text-primary-500" : "text-neutral-500"} text-xs`}>Masukkan Email</h4>
</div>
<div className="flex flex-col items-center justify-center gap-2">
<div className={`${step === 2 ? "bg-primary-500" : "bg-primary-200"} px-5 py-2 rounded-md w-full`}></div>
<h4 className={`${step === 2 ? "text-primary-500" : "text-gray-500"} text-xs`}>Verifikasi OTP</h4>
<h4 className={`${step === 2 ? "text-primary-500" : "text-neutral-500"} text-xs`}>Verifikasi OTP</h4>
</div>
<div className="flex flex-col items-center justify-center gap-2">
<div className={`${step === 3 ? "bg-primary-500" : "bg-primary-200"} px-5 py-2 rounded-md w-full`}></div>
<h4 className={`${step === 3 ? "text-primary-500" : "text-gray-500"} text-xs`}>Summon Password ^^</h4>
<h4 className={`${step === 3 ? "text-primary-500" : "text-neutral-500"} text-xs`}>Summon Password ^^</h4>
</div>
</div>
)
+3 -3
View File
@@ -66,7 +66,7 @@ const Modal = ({
<div className="fixed inset-0 z-50">
<div
className={cn(
'fixed inset-0 bg-gray-900/80 transition-opacity duration-200',
'fixed inset-0 bg-neutral-900/80 transition-opacity duration-200',
isOpen ? 'opacity-100' : 'opacity-0',
overlayClassName
)}
@@ -90,7 +90,7 @@ const Modal = ({
<button
onClick={onClose}
className={cn(
'absolute right-4 top-4 rounded-sm p-1 text-gray-500 transition-colors hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-gray-950 focus:ring-offset-2',
'absolute right-4 top-4 rounded-sm p-1 text-neutral-500 transition-colors hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2',
closeButtonClassName
)}
aria-label="Close modal"
@@ -168,7 +168,7 @@ const ModalDescription = ({
children,
id,
}: ModalDescriptionProps) => (
<p id={id} className={cn('text-sm text-gray-500', className)}>
<p id={id} className={cn('text-sm text-neutral-500', className)}>
{children}
</p>
);
@@ -40,7 +40,7 @@ export const Default: Story = {
<button className="px-4 py-2 bg-blue-500 text-white rounded">
Confirm
</button>
<button className="px-4 py-2 bg-gray-300 rounded">Cancel</button>
<button className="px-4 py-2 bg-neutral-300 rounded">Cancel</button>
</Modal.Footer>
</>
),
+4 -4
View File
@@ -66,7 +66,7 @@ const Modal = ({
<div className="fixed inset-0 z-50">
<div
className={cn(
'fixed inset-0 bg-gray-900/80 transition-opacity duration-200',
'fixed inset-0 bg-neutral-900/80 transition-opacity duration-200',
isOpen ? 'opacity-100' : 'opacity-0',
overlayClassName
)}
@@ -75,7 +75,7 @@ const Modal = ({
/>
<div
className={cn(
'fixed left-[50%] top-[50%] z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 bg-[#F0F8FF] rounded-lg p-6 shadow-xl transition-all duration-200',
'fixed left-[50%] top-[50%] z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 bg-primary-50 rounded-lg p-6 shadow-xl transition-all duration-200',
'sm:rounded-lg sm:max-w-md',
isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95',
className
@@ -90,7 +90,7 @@ const Modal = ({
<button
onClick={onClose}
className={cn(
'absolute right-4 top-4 rounded-sm p-1 text-gray-500 transition-colors hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-gray-950 focus:ring-offset-2',
'absolute right-4 top-4 rounded-sm p-1 text-neutral-500 transition-colors hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2',
closeButtonClassName
)}
aria-label="Close modal"
@@ -168,7 +168,7 @@ const ModalDescription = ({
children,
id,
}: ModalDescriptionProps) => (
<p id={id} className={cn('text-sm text-gray-500', className)}>
<p id={id} className={cn('text-sm text-neutral-500', className)}>
{children}
</p>
);
@@ -17,7 +17,7 @@ export const RegisterMentorStep: FC<TRegisterMentorStep> = ({ step }): ReactElem
return (
<div className="relative w-full px-8">
{/* Garis dasar (abu/biru muda) */}
<div className="absolute top-5 left-8 right-8 h-0.5 bg-blue-100 z-0" />
<div className="absolute top-5 left-8 right-8 h-0.5 bg-primary-100 z-0" />
{/* Garis progress biru (dinamis) */}
<div
@@ -37,7 +37,7 @@ export const RegisterMentorStep: FC<TRegisterMentorStep> = ({ step }): ReactElem
className={`w-10 h-10 rounded-full flex items-center justify-center border-2 ${
isActive
? "bg-gradient-to-b from-purple-600 to-blue-500 text-white border-transparent"
: "bg-white text-gray-700 border-blue-400"
: "bg-white text-neutral-700 border-blue-400"
}`}
>
<span className="font-bold">{currentStep}</span>
@@ -1,13 +1,9 @@
import {
AppstoreOutlined,
AuditOutlined,
BookOutlined,
CommentOutlined,
InboxOutlined,
LogoutOutlined,
ReloadOutlined,
ScheduleOutlined,
SettingOutlined,
UsergroupAddOutlined,
UserOutlined,
UserSwitchOutlined,
@@ -15,53 +11,102 @@ import {
import { Button } from '../../atoms';
import { FC, ReactElement } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn, For, useSession } from '@imphnen-frontend-service/utils';
const MENUS = [
{ label: 'Dashboard & Set Gacha', href: '/dashboard', icon: <AppstoreOutlined className="text-[20px]" /> },
{ label: 'Dashboard - Dimentorin', href: '/dashboard-dimentorin', icon: <AppstoreOutlined className="text-[20px]" /> },
{ label: 'Gacha Roll', href: '/gacha-roll', icon: <ReloadOutlined className="text-[20px]" /> },
{ label: 'Permissions', href: '/permissions', icon: <UserSwitchOutlined className="text-[20px]" /> },
{ label: 'Roles', href: '/roles', icon: <UsergroupAddOutlined className="text-[20px]" /> },
{ label: 'Data Akun', href: '/accounts', icon: <UserOutlined className="text-[20px]" /> },
{ label: 'Validasi Transaksi', href: '/transactions', icon: <AuditOutlined className="text-[20px]" /> },
{ label: 'Data Pengiriman Hadiah', href: '/prizes', icon: <InboxOutlined className="text-[20px]" /> },
{ label: 'User - Dimentorin', href: '/users-dimentorin', icon: <UserSwitchOutlined className="text-[20px]" /> },
{ label: 'Session - Dimentorin', href: '/session-dimentorin', icon: <ScheduleOutlined className="text-[20px]" /> },
{ label: 'Content & Roadmap', href: '/roadmap-dimentorin', icon: <BookOutlined className="text-[20px]" /> },
{ label: 'Feedback & Review', href: '/feedback-review-dimentorin', icon: <CommentOutlined className="text-[20px]" /> },
{ label: 'Settings - Dimentorin', href: '/settings-dimentorin', icon: <SettingOutlined className="text-[20px]" /> },
]
import { useSession } from '@imphnen-frontend-service/utils';
export const BackofficeSidebar: FC = (): ReactElement => {
const { signOut } = useSession();
const location = useLocation();
const isActive = (path: string) => {
if (path === '/dashboard' && location.pathname === '/dashboard-dimentorin') return false
return location.pathname.includes(path)
};
const isActive = (path: string) => location.pathname.includes(path);
return (
<aside className="sticky top-0 left-0 w-[280px] bg-white h-svh py-[60px] px-[28px] shadow-xl flex flex-col justify-between">
<aside className="sticky top-0 left-0 w-[280px] bg-white min-h-screen py-[60px] px-[28px] shadow-xl flex flex-col justify-between">
<div className="flex flex-col gap-20 justify-between items-center">
<img src="/logos/simple.svg" alt="IMPHNEN Logo" className="w-[150px]" />
<nav className="flex flex-col gap-4 w-full h-[calc(100svh-20rem)] overflow-y-auto">
<For data={MENUS}>
{({ label, href, icon }) => (
<Link
key={href}
to={href}
className={cn(
"flex items-center justify-items-start gap-3 px-[8px] py-[10px]",
isActive(href) ? "bg-primary-500 text-white rounded-md" : "text-gray-700 hover:bg-gray-100"
)}
>
{icon}
<span className="text-p3 font-medium">{label}</span>
</Link>
)}
</For>
<nav className="flex flex-col gap-4 w-full">
<Link
to="/dashboard"
className={`flex items-center justify-items-start gap-3 px-[8px] py-[10px] ${
isActive('/dashboard')
? 'bg-primary-500 text-white rounded-md'
: 'text-neutral-700 hover:bg-neutral-100'
}`}
>
<AppstoreOutlined className="text-[20px]" />
<span className="text-p3 font-medium">Dashboard & Set Gacha</span>
</Link>
<Link
to="/gacha-roll"
className={`flex items-center justify-items-start gap-3 px-[8px] py-[10px] ${
isActive('/gacha-roll')
? 'bg-primary-500 text-white rounded-md'
: 'text-neutral-700 hover:bg-neutral-100'
}`}
>
<ReloadOutlined className="text-[20px]" />
<span className="text-p3 font-medium">Gacha Roll</span>
</Link>
<Link
to="/permissions"
className={`flex items-center justify-items-start gap-3 px-[8px] py-[10px] ${
isActive('/permissions')
? 'bg-primary-500 text-white rounded-md'
: 'text-neutral-700 hover:bg-neutral-100'
}`}
>
<UserSwitchOutlined className="text-[20px]" />
<span className="text-p3 font-medium">Permissions</span>
</Link>
<Link
to="/roles"
className={`flex items-center justify-items-start gap-3 px-[8px] py-[10px] ${
isActive('/roles')
? 'bg-primary-500 text-white rounded-md'
: 'text-neutral-700 hover:bg-neutral-100'
}`}
>
<UsergroupAddOutlined className="text-[20px]" />
<span className="text-p3 font-medium">Roles</span>
</Link>
<Link
to="/accounts"
className={`flex items-center justify-items-start gap-3 px-[8px] py-[10px] ${
isActive('/accounts')
? 'bg-primary-500 text-white rounded-md'
: 'text-neutral-700 hover:bg-neutral-100'
}`}
>
<UserOutlined className="text-[20px]" />
<span className="text-p3 font-medium">Data Akun</span>
</Link>
<Link
to="/transactions"
className={`flex items-center justify-items-start gap-3 px-[8px] py-[10px] ${
isActive('/transactions')
? 'bg-primary-500 text-white rounded-md'
: 'text-neutral-700 hover:bg-neutral-100'
}`}
>
<AuditOutlined className="text-[20px]" />
<span className="text-p3 font-medium">Validasi Transaksi</span>
</Link>
<Link
to="/prizes"
className={`flex items-center justify-items-start gap-3 px-[8px] py-[10px] ${
isActive('/prizes')
? 'bg-primary-500 text-white rounded-md'
: 'text-neutral-700 hover:bg-neutral-100'
}`}
>
<InboxOutlined className="text-[20px]" />
<span className="text-p3 font-medium">Data Pengiriman Hadiah</span>
</Link>
</nav>
</div>
@@ -70,7 +115,7 @@ export const BackofficeSidebar: FC = (): ReactElement => {
<Button
onClick={signOut}
variant="text"
className="items-start justify-start gap-3 px-[8px] py-[10px] text-gray-700 hover:text-red-500 transition-colors w-full"
className="items-start justify-start gap-3 px-[8px] py-[10px] text-neutral-700 hover:text-danger-500 transition-colors w-full"
>
<LogoutOutlined className="text-[20px]" />
<span className="text-p3 font-medium">Log Out</span>
@@ -1,51 +0,0 @@
import { cn } from "@imphnen-frontend-service/utils"
import { Icon } from '@iconify/react'
import { FC, ReactElement, ReactNode } from "react"
import { Button } from "../../atoms"
export type TBackofficeWrapperProps = {
children: ReactNode
title?: string
className?: string
classHeader?: string
classTitle?: string
}
export const BackofficeWrapper: FC<TBackofficeWrapperProps> = ({
children,
title,
className,
classHeader,
classTitle,
}): ReactElement => {
return (
<main className={cn("w-full px-[48px] py-[40px] flex flex-col gap-8", className)}>
<header className={cn("bg-white py-5 px-7 rounded-md shadow flex items-center justify-between", classHeader)}>
<h1 className={cn("text-[19px] text-primary-500 font-semibold", classTitle)}>{title}</h1>
<div className="flex items-center gap-x-6">
<Button
type="button"
variant="secondary"
className="max-h-full p-3"
>
<Icon icon="mdi:bell-outline" className="size-6" />
</Button>
<div className="flex items-center gap-x-6">
<div className="text-neutral-600 font-medium">
<p className="text-p3">Rizal Syaepulloh</p>
<p className="text-label1">Super Admin</p>
</div>
<div className="size-12 rounded-full overflow-hidden">
<img src="/images/asd687hwq6nds4dfjj2983.webp" alt="Profile" className="size-full object-cover" />
</div>
</div>
</div>
</header>
<section>
{children}
</section>
</main>
)
}
@@ -1 +0,0 @@
export * from './backoffice-wrapper';
@@ -6,12 +6,10 @@ import {
flexRender,
ColumnDef,
Table,
RowData,
} from '@tanstack/react-table';
import { Pagination } from '../../molecules';
import React from 'react';
import { cn } from '@imphnen-frontend-service/utils';
interface DataTableProps<T> {
data: T[];
@@ -20,7 +18,7 @@ interface DataTableProps<T> {
pageSize?: number;
}
export const DataTable = <T extends RowData>({
export const DataTable = <T,>({
data,
columns,
pageSize = 9,
@@ -51,7 +49,7 @@ export const DataTable = <T extends RowData>({
{headerGroup.headers.map((header) => (
<th
key={header.id}
className={cn("py-4 px-5 font-normal first:rounded-l-lg last:rounded-r-lg", header?.column?.columnDef?.meta?.headerClassName)}
className="py-4 px-5 font-normal first:rounded-l-lg last:rounded-r-lg"
>
{header.isPlaceholder
? null
@@ -70,7 +68,7 @@ export const DataTable = <T extends RowData>({
{row.getVisibleCells().map((cell, index) => (
<td
key={cell.id}
className={cn("py-3 px-5 first:rounded-l-lg last:rounded-r-lg", cell?.column?.columnDef?.meta?.cellClassName)}
className="py-3 px-5 first:rounded-l-lg last:rounded-r-lg"
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
-1
View File
@@ -1,2 +1 @@
export * from './datatable';
export * from './type.d';
-8
View File
@@ -1,8 +0,0 @@
import "@tanstack/react-table"
declare module "@tanstack/react-table" {
interface ColumnMeta {
headerClassName?: string
cellClassName?: string
}
}
+2 -2
View File
@@ -30,9 +30,9 @@ const Radio = ({
checked={checked}
onChange={onChange}
disabled={disabled}
className="peer col-start-1 row-start-1 appearance-none shrink-0 size-[10px] bg-primary-100 rounded-full disabled:border-gray-400"
className="peer col-start-1 row-start-1 appearance-none shrink-0 size-[10px] bg-primary-100 rounded-full disabled:border-neutral-400"
/>
<div className="pointer-events-none col-start-1 row-start-1 size-[6px] rounded-full peer-checked:bg-primary-500 peer-checked:peer-disabled:bg-gray-400" />
<div className="pointer-events-none col-start-1 row-start-1 size-[6px] rounded-full peer-checked:bg-primary-500 peer-checked:peer-disabled:bg-neutral-400" />
</div>
<label htmlFor={id} className="text-start text-neutral-400 text-label2">
{label || 'This is the radio label'}
-1
View File
@@ -5,4 +5,3 @@ export * from './backoffice-sidebar';
export * from './datatable';
export * from './filter';
export * from './controlled-field';
export * from './backoffice-wrapper';
@@ -9,7 +9,7 @@ const meta: Meta<typeof Navbar> = {
decorators: [
(Story) => (
<BrowserRouter>
<div className="min-h-screen bg-gray-100 p-4">
<div className="min-h-screen bg-neutral-100 p-4">
<Story />
</div>
</BrowserRouter>
+8 -8
View File
@@ -38,7 +38,7 @@ export const Navbar: FC = (): ReactElement => {
<Button
size="md"
variant="text"
className="lg:text-p2 lg:max-h-[44px] text-gray-600 hover:text-gray-700 transition-colors"
className="lg:text-p2 lg:max-h-[44px] text-neutral-600 hover:text-neutral-700 transition-colors"
>
<Link to="#">Merch Gacha</Link>
</Button>
@@ -56,7 +56,7 @@ export const Navbar: FC = (): ReactElement => {
onClick={signOut}
variant="text"
size="md"
className="lg:text-p2 text-red-500 font-bold hover:text-red-600"
className="lg:text-p2 text-danger-500 font-bold hover:text-danger-600"
>
Logout
</Button>
@@ -69,12 +69,12 @@ export const Navbar: FC = (): ReactElement => {
}`}
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
<MenuOutlined style={{ color: '#1a8ce6' }} />
<MenuOutlined style={{ color: '#23a1eb' }} />
</button>
<div className="relative">
{isDropdownOpen && (
<div className="absolute right-0 top-0 mt-5">
<ul className="mt-2 w-48 bg-white shadow-md border rounded-[16px] border-gray-200 px-5 py-3">
<ul className="mt-2 w-48 bg-white shadow-md border rounded-2xl border-neutral-200 px-5 py-3">
<li>
<Link
to="#"
@@ -86,7 +86,7 @@ export const Navbar: FC = (): ReactElement => {
<li>
<Link
to="#"
className="block text-gray-600 transition-colors px-4 py-2 text-center font-semibold"
className="block text-neutral-600 transition-colors px-4 py-2 text-center font-semibold"
>
Merch Gacha
</Link>
@@ -95,20 +95,20 @@ export const Navbar: FC = (): ReactElement => {
<li>
<Button
onClick={() => setShowModalLogin(true)}
className="block w-full text-gray-100 transition-colors px-4 py-2 text-center font-semibold"
className="block w-full text-neutral-100 transition-colors px-4 py-2 text-center font-semibold"
>
Login
</Button>
</li>
) : (
<>
<li className="px-4 py-2 text-center text-gray-600 font-semibold">
<li className="px-4 py-2 text-center text-neutral-600 font-semibold">
{session?.user?.fullname}
</li>
<li>
<div
onClick={signOut}
className="block text-red-500 font-bold transition-colors px-4 py-2 text-center cursor-pointer"
className="block text-danger-500 font-bold transition-colors px-4 py-2 text-center cursor-pointer"
>
Logout
</div>
+74 -942
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -38,7 +38,6 @@
"axios": "^1.8.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"framer-motion": "^12.9.2",
"graphql": "^16.11.0",
"js-cookie": "^3.0.5",
@@ -51,7 +50,6 @@
"react-hook-form": "^7.56.4",
"react-icons": "^5.5.0",
"react-router-dom": "^7.3.0",
"recharts": "^3.1.2",
"sharp": "^0.34.1",
"sonner": "^2.0.3",
"tailwind-merge": "^3.0.2",
@@ -113,7 +111,7 @@
"h3": "^1.8.2",
"jiti": "2.4.2",
"jsdom": "~22.1.0",
"nx": "20.8.1",
"nx": "^20.8.1",
"openapi-typescript": "^7.8.0",
"postcss": "^8.5.3",
"prettier": "^2.6.2",