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:
+34
-7
@@ -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>
|
||||
|
||||
|
||||
+28
-12
@@ -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>
|
||||
|
||||
+20
-4
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
+26
-6
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user