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:
asepharyana
2026-08-05 23:18:51 +07:00
parent e2d5e37bd1
commit 7901fc3f82
2 changed files with 290 additions and 2 deletions
@@ -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 { useQueryClient } from '@tanstack/react-query';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { For, Show, cn, SessionToken } from '@imphnen-frontend-service/utils';
import {
@@ -7,9 +8,10 @@ import {
useGetMyPayments,
usePostRefreshPayment,
} from '@imphnen-frontend-service/service';
import { ReloadOutlined } from '@ant-design/icons';
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',
@@ -46,9 +48,14 @@ const formatRupiah = (n: number) =>
}).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])
@@ -170,6 +177,21 @@ export const Components: FC = (): ReactElement => {
</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>
);
@@ -190,6 +212,16 @@ export const Components: FC = (): ReactElement => {
</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>
);
};