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)
This commit is contained in:
@@ -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;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FC, ReactElement } from 'react';
|
import { FC, ReactElement, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { For, Show, cn, SessionToken } from '@imphnen-frontend-service/utils';
|
import { For, Show, cn, SessionToken } from '@imphnen-frontend-service/utils';
|
||||||
import {
|
import {
|
||||||
@@ -7,9 +8,10 @@ import {
|
|||||||
useGetMyPayments,
|
useGetMyPayments,
|
||||||
usePostRefreshPayment,
|
usePostRefreshPayment,
|
||||||
} from '@imphnen-frontend-service/service';
|
} from '@imphnen-frontend-service/service';
|
||||||
import { ReloadOutlined } from '@ant-design/icons';
|
import { ReloadOutlined, StarOutlined } from '@ant-design/icons';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { DashboardLayout } from '../_components/dashboard-layout';
|
import { DashboardLayout } from '../_components/dashboard-layout';
|
||||||
|
import { FeedbackModal } from '../_components/feedback-modal';
|
||||||
|
|
||||||
const STATUS_STYLE: Record<string, string> = {
|
const STATUS_STYLE: Record<string, string> = {
|
||||||
pending: 'bg-warning-100 text-warning-800',
|
pending: 'bg-warning-100 text-warning-800',
|
||||||
@@ -46,9 +48,14 @@ const formatRupiah = (n: number) =>
|
|||||||
}).format(n);
|
}).format(n);
|
||||||
|
|
||||||
export const Components: FC = (): ReactElement => {
|
export const Components: FC = (): ReactElement => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { data: sessions, isLoading } = useGetMySessions();
|
const { data: sessions, isLoading } = useGetMySessions();
|
||||||
const { data: payments } = useGetMyPayments();
|
const { data: payments } = useGetMyPayments();
|
||||||
const refreshPayment = usePostRefreshPayment();
|
const refreshPayment = usePostRefreshPayment();
|
||||||
|
const [feedbackTarget, setFeedbackTarget] = useState<{
|
||||||
|
sessionId: string;
|
||||||
|
mentorName: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const paymentBySession = new Map(
|
const paymentBySession = new Map(
|
||||||
(payments ?? []).map((p) => [p.sessionId, p])
|
(payments ?? []).map((p) => [p.sessionId, p])
|
||||||
@@ -170,6 +177,21 @@ export const Components: FC = (): ReactElement => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</Show>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -190,6 +212,16 @@ export const Components: FC = (): ReactElement => {
|
|||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user