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
This commit is contained in:
asepharyana
2026-08-05 09:28:56 +07:00
parent a74fabe5f5
commit 893f7ba03c
7 changed files with 209 additions and 30 deletions
@@ -10,7 +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, usePostBookSession } from "@imphnen-frontend-service/service"
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]
@@ -19,15 +24,24 @@ type Props = {
open: boolean
setOpen: (open: boolean) => void
mentorId?: string
mentor?: { fullname?: string | null; current_role?: string; current_company?: string }
mentor?: {
fullname?: string | null
current_role?: string
current_company?: string
mentoring_rate?: number
}
}
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 = async (action: 'next' | 'prev') => {
setError("")
@@ -37,8 +51,19 @@ export const AppointmentModal: FC<Props> = ({ open, setOpen, mentorId, mentor })
return
}
try {
await bookMutation.mutateAsync({ ...booking, topic: booking.topic || `Mentoring #${selectedTopics[0] ?? 1}` })
setStep('success')
// 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.")
}
@@ -46,6 +71,8 @@ export const AppointmentModal: FC<Props> = ({ open, setOpen, mentorId, mentor })
}
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') {
@@ -135,9 +162,9 @@ export const AppointmentModal: FC<Props> = ({ open, setOpen, mentorId, mentor })
{step === 'topic' && <TopicStep selectedTopics={selectedTopics} setSelectedTopics={setSelectedTopics} />}
{step === 'schedule' && <ScheduleStep onChange={(data) => setBooking(data)} />}
{step === 'profile' && <ProfileStep />}
{step === 'payment' && <PaymentStep selectedTopics={selectedTopics} mentor={mentor} booking={booking} />}
{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>
@@ -1,18 +1,32 @@
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"
type Props = {
selectedTopics: number[]
mentor?: { fullname?: string | null; current_role?: string; current_company?: string } | null
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, mentor, booking }) => {
const rate = 50000
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
@@ -76,15 +90,16 @@ export const PaymentStep: FC<Props> = ({ selectedTopics, mentor, booking }) => {
<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>
@@ -96,11 +111,12 @@ export const PaymentStep: FC<Props> = ({ selectedTopics, mentor, booking }) => {
<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>
@@ -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?.external_ref}>
<p className="text-[10px] text-neutral-400 mt-1 font-medium">
Ref: {payment?.external_ref}
</p>
</Show>
</div>
</div>
@@ -49,7 +62,10 @@ export const QrisPaymentStep = () => {
<div className="bg-primary-50 p-2.5 rounded-lg size-[120px] mx-auto md:size-[142px] md:me-0">
<img src="/image/sample-qrcode.webp" alt="QR Code" className="w-full" />
</div>
<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") : "-"}
</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?.external_ref || "8091239861969812"}
</p>
<p className="text-[8px] text-neutral-400 md:text-[10px]">
A/N Unknown
A/N IMPHNEN
</p>
<Show condition={!!payment?.expires_at}>
<p className="text-[8px] text-neutral-500 mt-2 md:text-[10px]">
Berlaku hingga {new Date(payment!.expires_at).toLocaleString("id-ID")}
</p>
</Show>
</div>
</motion.div>
)
}
}
+41 -1
View File
@@ -3,14 +3,16 @@ import {
TArticleDetail,
TArticleListItem,
TArticleListParams,
TBookSessionRequest,
TCreatePaymentRequest,
TMentorAvailability,
TMentorDetail,
TMentorListParams,
TMentorRegisterRequest,
TMentorStats,
TPayment,
TSessionFeedbackRequest,
TSessionListResponse,
TBookSessionRequest,
} from '../../types/dimentorin';
import { TResponseMessage } from '../../types/common';
@@ -154,3 +156,41 @@ export const postRegisterMentor = async (
});
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 postConfirmPayment = async (
id: string
): Promise<TResponseMessage> => {
const { data } = await api({
method: 'POST',
url: `/dimentorin/payments/${id}/confirm`,
});
return data;
};
@@ -11,8 +11,12 @@ import {
getMentorSessions,
getMentorStats,
getMentors,
getMyPayments,
getMySessions,
getPaymentById,
postBookSession,
postConfirmPayment,
postCreatePayment,
postRegisterMentor,
postSessionFeedback,
} from '../../api/dimentorin';
@@ -21,11 +25,13 @@ import {
TArticleListItem,
TArticleListParams,
TBookSessionRequest,
TCreatePaymentRequest,
TMentorAvailability,
TMentorDetail,
TMentorListParams,
TMentorRegisterRequest,
TMentorStats,
TPayment,
TSessionFeedbackRequest,
TSessionListResponse,
} from '../../types/dimentorin';
@@ -134,6 +140,41 @@ export const usePostRegisterMentor = () => {
});
};
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 usePostConfirmPayment = () => {
return useMutation({
mutationKey: ['post-confirm-payment'],
mutationFn: async (id: string) => await postConfirmPayment(id),
});
};
export const useGetArticles = (
params?: TArticleListParams
): UseQueryResult<TArticleListItem[], TResponseError> => {
@@ -162,3 +162,22 @@ export type TMentorRegisterRequest = {
mentoring_rate_amount: number;
};
};
export type TPayment = {
id: string;
session_id: string;
mentor_id: string;
amount: number;
service_fee: number;
total: number;
method: string;
provider: string;
status: string;
external_ref?: string | null;
expires_at: string;
created_at: string;
};
export type TCreatePaymentRequest = {
method: string; // 'va' | 'qris' | 'manual'
};