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
This commit is contained in:
asepharyana
2026-08-05 15:58:29 +07:00
parent bb980a7add
commit 49a448fee1
8 changed files with 487 additions and 14 deletions
@@ -8,6 +8,7 @@ import { Link, NavLink, useLocation } from "react-router-dom";
const MENUS: { label: string; href: string }[] = [ const MENUS: { label: string; href: string }[] = [
{ label: "Home", href: "/" }, { label: "Home", href: "/" },
{ label: 'Mentoring', href: '/mentoring' }, { label: 'Mentoring', href: '/mentoring' },
{ label: 'Sesi Saya', href: '/mentoring/my-sessions' },
{ label: 'Resources', href: '/resources' }, { label: 'Resources', href: '/resources' },
{ label: 'Articles', href: '/articles' }, { label: 'Articles', href: '/articles' },
] ]
@@ -47,9 +47,9 @@ export const QrisPaymentStep: FC<Props> = ({ payment }) => {
<p className="text-xs font-semibold text-neutral-700 md:text-[15px]"> <p className="text-xs font-semibold text-neutral-700 md:text-[15px]">
Rp. {(payment?.total ?? 52000).toLocaleString("id-ID")} Rp. {(payment?.total ?? 52000).toLocaleString("id-ID")}
</p> </p>
<Show condition={!!payment?.external_ref && payment.provider !== 'midtrans'}> <Show condition={!!payment?.externalRef && payment.provider !== 'midtrans'}>
<p className="text-[10px] text-neutral-400 mt-1 font-medium"> <p className="text-[10px] text-neutral-400 mt-1 font-medium">
Ref: {payment?.external_ref} Ref: {payment?.externalRef}
</p> </p>
</Show> </Show>
</div> </div>
@@ -60,10 +60,19 @@ export const QrisPaymentStep: FC<Props> = ({ payment }) => {
QR Code Pembayaran QR Code Pembayaran
</p> </p>
<div className="bg-primary-50 p-2.5 rounded-lg size-[120px] mx-auto md:size-[142px] md:me-0"> <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> </div>
<p className="text-[8px] text-neutral-500 text-center mt-2 md:text-[10px]"> <p className="text-[8px] text-neutral-500 text-center mt-2 md:text-[10px]">
Berlaku hingga {payment ? new Date(payment.expires_at).toLocaleString("id-ID") : "-"} Berlaku hingga {payment ? new Date(payment.expiresAt).toLocaleString("id-ID") : "-"}
</p> </p>
</div> </div>
</motion.div> </motion.div>
@@ -59,14 +59,14 @@ export const VAPaymentStep: FC<Props> = ({ payment }) => {
Kode Virtual Account Kode Virtual Account
</p> </p>
<p className="text-xs font-semibold text-neutral-700 mb-1.5 md:text-[15px]"> <p className="text-xs font-semibold text-neutral-700 mb-1.5 md:text-[15px]">
{payment?.external_ref || "8091239861969812"} {payment?.externalRef || "8091239861969812"}
</p> </p>
<p className="text-[8px] text-neutral-400 md:text-[10px]"> <p className="text-[8px] text-neutral-400 md:text-[10px]">
A/N IMPHNEN A/N IMPHNEN
</p> </p>
<Show condition={!!payment?.expires_at}> <Show condition={!!payment?.expiresAt}>
<p className="text-[8px] text-neutral-500 mt-2 md:text-[10px]"> <p className="text-[8px] text-neutral-500 mt-2 md:text-[10px]">
Berlaku hingga {new Date(payment!.expires_at).toLocaleString("id-ID")} Berlaku hingga {new Date(payment!.expiresAt).toLocaleString("id-ID")}
</p> </p>
</Show> </Show>
</div> </div>
@@ -0,0 +1,220 @@
import { FC, ReactElement } from 'react';
import { Link } from 'react-router-dom';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { For, Show, cn } from '@imphnen-frontend-service/utils';
import {
useGetMentorMe,
useGetMentorSessions,
useGetSessionPayments,
usePostConfirmPayment,
usePostRefreshPayment,
} from '@imphnen-frontend-service/service';
import { ReloadOutlined, CheckOutlined } from '@ant-design/icons';
import { toast } from 'sonner';
const STATUS_STYLE: Record<string, string> = {
pending: 'bg-amber-100 text-amber-700',
confirmed: 'bg-emerald-100 text-emerald-700',
completed: 'bg-primary-100 text-primary-700',
cancelled: 'bg-red-100 text-red-600',
};
const PAYMENT_STYLE: Record<string, string> = {
pending: 'bg-amber-100 text-amber-700',
paid: 'bg-emerald-100 text-emerald-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 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: sessions, isLoading } = useGetMentorSessions(mentorId);
return (
<main className="w-full px-8 py-12 md:px-[60px] lg:px-20">
<div className="max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-neutral-800 md:text-3xl">
Dashboard Mentor
</h1>
<p className="text-sm text-neutral-500 mt-1">
Permintaan sesi yang masuk ke kamu beserta status pembayarannya.
</p>
</div>
<Show
condition={!loadingMentor && !mentor}
fallback={
<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">
Belum ada permintaan sesi mentoring.
</p>
</div>
}
>
<div className="grid gap-4 md:grid-cols-2">
<For data={sessions?.sessions ?? []}>
{(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-[11px] 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>
</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 className="bg-white rounded-lg p-12 text-center">
<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>
</Show>
</div>
</main>
);
};
export default Components;
@@ -0,0 +1,191 @@
import { FC, ReactElement } from 'react';
import { Link } from 'react-router-dom';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { For, Show, cn } from '@imphnen-frontend-service/utils';
import {
useGetMySessions,
useGetMyPayments,
usePostRefreshPayment,
} from '@imphnen-frontend-service/service';
import { ReloadOutlined } from '@ant-design/icons';
import { toast } from 'sonner';
const STATUS_STYLE: Record<string, string> = {
pending: 'bg-amber-100 text-amber-700',
confirmed: 'bg-emerald-100 text-emerald-700',
completed: 'bg-primary-100 text-primary-700',
cancelled: 'bg-red-100 text-red-600',
};
const PAYMENT_STYLE: Record<string, string> = {
pending: 'bg-amber-100 text-amber-700',
paid: 'bg-emerald-100 text-emerald-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 { data: sessions, isLoading } = useGetMySessions();
const { data: payments } = useGetMyPayments();
const refreshPayment = usePostRefreshPayment();
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 (
<main className="w-full px-8 py-12 md:px-[60px] lg:px-20">
<div className="max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-neutral-800 md:text-3xl">
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={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-[11px] 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>
</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>
</div>
</main>
);
};
export default Components;
+20
View File
@@ -185,6 +185,16 @@ export const getPaymentById = async (id: string): Promise<TPayment> => {
return data.data; 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 ( export const postConfirmPayment = async (
id: string id: string
): Promise<TResponseMessage> => { ): Promise<TResponseMessage> => {
@@ -194,3 +204,13 @@ export const postConfirmPayment = async (
}); });
return data; return data;
}; };
export const postRefreshPayment = async (
id: string
): Promise<TPayment> => {
const { data } = await api({
method: 'POST',
url: `/dimentorin/payments/${id}/refresh`,
});
return data.data;
};
+32 -1
View File
@@ -1,4 +1,4 @@
import { useMutation, useQuery, UseQueryResult } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient, UseQueryResult } from '@tanstack/react-query';
import { import {
getArticleById, getArticleById,
getArticleBySlug, getArticleBySlug,
@@ -14,9 +14,11 @@ import {
getMyPayments, getMyPayments,
getMySessions, getMySessions,
getPaymentById, getPaymentById,
getSessionPayments,
postBookSession, postBookSession,
postConfirmPayment, postConfirmPayment,
postCreatePayment, postCreatePayment,
postRefreshPayment,
postRegisterMentor, postRegisterMentor,
postSessionFeedback, postSessionFeedback,
} from '../../api/dimentorin'; } from '../../api/dimentorin';
@@ -168,10 +170,39 @@ export const useGetPaymentById = (
}); });
}; };
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 = () => { export const usePostConfirmPayment = () => {
const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationKey: ['post-confirm-payment'], mutationKey: ['post-confirm-payment'],
mutationFn: async (id: string) => await postConfirmPayment(id), 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'] });
},
}); });
}; };
+7 -6
View File
@@ -165,17 +165,18 @@ export type TMentorRegisterRequest = {
export type TPayment = { export type TPayment = {
id: string; id: string;
session_id: string; sessionId: string;
mentor_id: string; mentorId: string;
amount: number; amount: number;
service_fee: number; serviceFee: number;
total: number; total: number;
method: string; method: string;
provider: string; provider: string;
status: string; status: string;
external_ref?: string | null; externalRef?: string | null;
expires_at: string; providerOrderId?: string | null;
created_at: string; expiresAt: string;
createdAt: string;
}; };
export type TCreatePaymentRequest = { export type TCreatePaymentRequest = {