fixed FAQ view button in ListFAQ, ongoing: Dashboard routing to BE

This commit is contained in:
Billy Marcello
2025-12-13 15:38:14 +07:00
parent 077483e6c9
commit 964e75a965
6 changed files with 346 additions and 83 deletions
+128 -31
View File
@@ -1,53 +1,150 @@
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import axios from "axios";
import { useLocation } from "react-router-dom";
const faqData = [ type FaqItem = {
{ id: number;
question: "Bagaimana cara magang disini?", pertanyaan: string;
answer: "Pilih Internship, Kemudian Lakukan Proses Administrasi", jawaban: string;
}, created_at?: string;
{ };
question: "Apakah saya bisa melamar lebih dari satu lowongan?",
answer: "Tidak Bisa, Hanya Bisa Melamar 1 Lowongan",
},
{
question: "Bagaimana saya tahu jika lamaran saya diterima atau ditolak?",
answer: "Sistem Akan Mengirimkan Pesan Diterima/Ditolak Ke Email Anda",
},
{
question: "Berapa lama durasi magang di Humic?",
answer: "Cek Instagram Kita Yah, Disana Tertera Durasi Magangnya",
},
{
question: "Apakah data pribadi saya aman?",
answer: "Data Pribadi Dipastikan Aman, Sistem Ini Memiliki Admin Dashboard",
},
];
const Faq = () => { const Faq = () => {
const location = useLocation();
const [openIndex, setOpenIndex] = useState<number | null>(null); const [openIndex, setOpenIndex] = useState<number | null>(null);
const [faqData, setFaqData] = useState<FaqItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const faqIdFromUrl = useMemo(() => {
const params = new URLSearchParams(location.search);
const raw = params.get("faq");
const n = raw ? Number(raw) : NaN;
return Number.isFinite(n) ? n : null;
}, [location.search]);
const toggleItem = (index: number) => { const toggleItem = (index: number) => {
setOpenIndex(openIndex === index ? null : index); setOpenIndex(openIndex === index ? null : index);
}; };
useEffect(() => {
const fetchFaq = async () => {
setLoading(true);
setError(null);
try {
const token = document.cookie
.split("; ")
.find((row) => row.startsWith("token="))
?.split("=")[1];
const response = await axios.get(
`${import.meta.env.VITE_API_BASE_URL}/faq-api/get`,
{
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
}
);
if (response.data?.status) {
const items: FaqItem[] = Array.isArray(response.data.data)
? response.data.data
: [];
setFaqData(items);
} else {
setFaqData([]);
setError(response.data?.message || "Gagal mengambil data FAQ.");
}
} catch (err: any) {
console.error("Gagal mengambil data FAQ:", err);
setFaqData([]);
setError(
err?.response?.data?.message ||
"Terjadi kesalahan saat mengambil data FAQ."
);
} finally {
setLoading(false);
}
};
fetchFaq();
}, []);
// Auto-scroll ke section FAQ kalau ada #faq
useEffect(() => {
if (location.hash === "#faq") {
// wrapper ada di Landing: <div id="faq"><Faq/></div>
// ini memastikan scroll halus
const el = document.getElementById("faq");
el?.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, [location.hash]);
// Auto-open FAQ tertentu kalau ?faq=ID ada dan data sudah ke-load
useEffect(() => {
if (!faqIdFromUrl) return;
if (faqData.length === 0) return;
const idx = faqData.findIndex((f) => f.id === faqIdFromUrl);
if (idx !== -1) {
setOpenIndex(idx);
// Optional: setelah open, pastikan tetap terlihat
setTimeout(() => {
const el = document.getElementById("faq");
el?.scrollIntoView({ behavior: "smooth", block: "start" });
}, 0);
}
}, [faqIdFromUrl, faqData]);
return ( return (
<div className="hero-5-section flex flex-col lg:flex-row items-start lg:items-center justify-between mt-25 px-6 md:px-16 lg:px-[150px] pb-[60px] gap-10"> <div className="hero-5-section flex flex-col lg:flex-row items-start lg:items-center justify-between mt-25 px-6 md:px-16 lg:px-[150px] pb-[60px] gap-10">
{/* UI tetap sama persis */}
<div className="content-of-5 w-full lg:w-[610px]"> <div className="content-of-5 w-full lg:w-[610px]">
<h2 className="font-bold text-2xl md:text-3xl">Frequently Asked Questions</h2> <h2 className="font-bold text-2xl md:text-3xl">
Frequently Asked Questions
</h2>
<p className="font-medium text-base mt-4"> <p className="font-medium text-base mt-4">
Berikut adalah informasi mengenai pertanyaan umum seputar program magang di Humic, termasuk tata cara pendaftaran dan persyaratan yang perlu dipenuhi. Masih ada yang perlu ditanyakan? Silahkan{" "} Berikut adalah informasi mengenai pertanyaan umum seputar program magang
di Humic, termasuk tata cara pendaftaran dan persyaratan yang perlu
dipenuhi. Masih ada yang perlu ditanyakan? Silahkan{" "}
<span className="text-[#C3423F] font-semibold">DM</span> kami <span className="text-[#C3423F] font-semibold">DM</span> kami
</p> </p>
{loading && <p className="mt-4 text-sm text-gray-600">Memuat FAQ...</p>}
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
</div> </div>
<div className="faq-content-list space-y-6 w-full lg:w-[700px]"> <div className="faq-content-list space-y-6 w-full lg:w-[700px]">
{!loading && !error && faqData.length === 0 && (
<div className="bg-[#FAF0EF] shadow-md rounded-sm p-4">
<p className="text-sm text-gray-700">Belum ada FAQ.</p>
</div>
)}
{faqData.map((faq, index) => ( {faqData.map((faq, index) => (
<div key={index} className="bg-[#FAF0EF] shadow-md rounded-sm"> <div
<button onClick={() => toggleItem(index)} className="w-full flex items-center cursor-pointer justify-between p-4 focus:outline-none"> key={faq.id ?? index}
<span className="text-[16px] font-semibold text-left text-[#C3423F]">{faq.question}</span> className="bg-[#FAF0EF] shadow-md rounded-sm"
<span className="text-[20px] font-bold text-[#C3423F]">{openIndex === index ? "-" : "+"}</span> >
<button
onClick={() => toggleItem(index)}
className="w-full flex items-center cursor-pointer justify-between p-4 focus:outline-none"
>
<span className="text-[16px] font-semibold text-left text-[#C3423F]">
{faq.pertanyaan}
</span>
<span className="text-[20px] font-bold text-[#C3423F]">
{openIndex === index ? "-" : "+"}
</span>
</button> </button>
<div className={`px-4 pb-4 text-black transition-all duration-300 ${openIndex === index ? "block" : "hidden"}`}>
<p>{faq.answer}</p> <div
className={`px-4 pb-4 text-black transition-all duration-300 ${
openIndex === index ? "block" : "hidden"
}`}
>
<div dangerouslySetInnerHTML={{ __html: faq.jawaban }} />
</div> </div>
</div> </div>
))} ))}
-2
View File
@@ -26,8 +26,6 @@ const NavbarAdmin = () => {
); );
const result = await response.json(); const result = await response.json();
console.log("Response status:", response.status);
console.log("Response body:", result);
if (response.ok && result.data?.nama_depan) { if (response.ok && result.data?.nama_depan) {
setNamaDepan(result.data.nama_depan); setNamaDepan(result.data.nama_depan);
+1 -1
View File
@@ -11,7 +11,7 @@ const SidebarAdmin = () => {
<div className="list-of-sidebar flex flex-col p-7 gap-4"> <div className="list-of-sidebar flex flex-col p-7 gap-4">
<div <div
className={`text-container flex flex-row gap-3 p-3 items-center cursor-pointer rounded-2xl ${ className={`text-container flex flex-row gap-3 p-3 items-center cursor-pointer rounded-2xl ${
location.pathname === "/product-list" location.pathname === "/dashboard"
? "bg-[#D3DFEA] font-bold" ? "bg-[#D3DFEA] font-bold"
: "hover:bg-[#D3DFEA] " : "hover:bg-[#D3DFEA] "
}`} }`}
+213 -47
View File
@@ -1,6 +1,14 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts"; import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
import { Search, ChevronDown, Users, UserCheck, UserX, Clock } from "lucide-react"; import {
Search,
ChevronDown,
Users,
UserCheck,
UserX,
Clock,
} from "lucide-react";
import axios, { AxiosError } from "axios";
import NavbarAdmin from "../Layout/NavbarAdmin"; import NavbarAdmin from "../Layout/NavbarAdmin";
import SidebarAdmin from "../Layout/SidebarAdmin"; import SidebarAdmin from "../Layout/SidebarAdmin";
@@ -17,104 +25,262 @@ type StatCard = {
iconColor: string; iconColor: string;
}; };
type DashboardApiResponse = { // ====== BE RESPONSE TYPES ======
totalPendaftar: number; type DashboardStatsResponse = {
totalDiterima: number; status: boolean;
totalDitolak: number; data: {
totalDiproses: number; total_pendaftar: number;
universitas: ChartDataItem[]; total_diterima: number;
negara: ChartDataItem[]; total_ditolak: number;
sedang_diproses: number;
acceptance_rate: string;
rejection_rate: string;
pending_rate: string;
};
message: string;
code?: number;
};
type PositionStatsResponse = {
status: boolean;
data: Array<{
posisi: string;
total: number;
diterima: number;
ditolak: number;
}>;
message?: string;
code?: number;
};
type CountryStatsResponse = {
status: boolean;
data: Array<{
negara: string;
total: number;
diterima: number;
ditolak: number;
diproses: number;
}>;
message?: string;
code?: number;
};
type BatchResponse = {
success: boolean;
message: string;
data: Array<{
id: number;
batch_number: number;
is_active: boolean;
created_at: string;
updated_at: string;
}>;
}; };
const UNIVERSITAS_COLORS = ["#001a66", "#334d99", "#6699cc", "#99b3ff"]; const UNIVERSITAS_COLORS = ["#001a66", "#334d99", "#6699cc", "#99b3ff"];
const PENERIMAAN_COLORS = ["#0088ff", "#00cc00", "#ff3333"]; const PENERIMAAN_COLORS = ["#0088ff", "#00cc00", "#ff3333"];
const NEGARA_COLORS = ["#cc3333", "#dd6666", "#ee9999", "#f0b3b3", "#f5cccc"]; const NEGARA_COLORS = ["#cc3333", "#dd6666", "#ee9999", "#f0b3b3", "#f5cccc"];
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL?.trim() ||
"https://internify-ruddy.vercel.app";
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [statCards, setStatCards] = useState<StatCard[]>([]); const [statCards, setStatCards] = useState<StatCard[]>([]);
const [universitasData, setUniversitasData] = useState<ChartDataItem[]>([]); const [universitasData, setUniversitasData] = useState<ChartDataItem[]>([]);
const [penerimaanData, setPenerimaanData] = useState<ChartDataItem[]>([]); const [penerimaanData, setPenerimaanData] = useState<ChartDataItem[]>([]);
const [negaraData, setNegaraData] = useState<ChartDataItem[]>([]); const [negaraData, setNegaraData] = useState<ChartDataItem[]>([]);
const [, setBatches] = useState<BatchResponse["data"]>([]);
const getTokenFromCookie = () => {
return document.cookie
.split("; ")
.find((row) => row.startsWith("token="))
?.split("=")[1];
};
const api = useMemo(() => {
const instance = axios.create({
baseURL: API_BASE_URL,
headers: {
Accept: "application/json",
},
timeout: 15000,
});
instance.interceptors.request.use((config) => {
const token = getTokenFromCookie();
if (token) {
config.headers = config.headers ?? {};
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
return instance;
}, []);
const getAxiosErrorMessage = (err: unknown) => {
if (!axios.isAxiosError(err)) {
return "Terjadi kesalahan saat mengambil data dashboard.";
}
const axErr = err as AxiosError<any>;
const status = axErr.response?.status;
const msgFromBe =
axErr.response?.data?.message ||
axErr.response?.data?.error ||
axErr.message;
if (status === 401) {
return msgFromBe || "Unauthorized (401). Silakan login ulang.";
}
return msgFromBe || `Request gagal (status ${status ?? "unknown"}).`;
};
useEffect(() => { useEffect(() => {
const fetchDashboardData = async () => { const fetchDashboardData = async () => {
try { try {
const response = await fetch( setLoading(true);
`${import.meta.env.VITE_API_BASE_URL}/lowongan-magang-api/get` setErrorMessage("");
);
const result = await response.json();
if (!response.ok) { const token = getTokenFromCookie();
console.error("Gagal mengambil data dashboard:", result.message); if (!token) {
setErrorMessage(result.message || "Gagal mengambil data dashboard."); setErrorMessage(
"Token tidak ditemukan di cookie. Silakan login ulang."
);
setLoading(false); setLoading(false);
return; return;
} }
const data: DashboardApiResponse = result.data; const [statsRes, positionRes, countryRes, batchRes] = await Promise.all(
[
api.get<DashboardStatsResponse>(
"/lamaran-magang-api/statistics/dashboard"
),
api.get<PositionStatsResponse>(
"/lamaran-magang-api/statistics/position"
),
api.get<CountryStatsResponse>(
"/lamaran-magang-api/statistics/country"
),
api.get<BatchResponse>("/api/batch"),
]
);
const statsJson = statsRes.data;
const positionJson = positionRes.data;
const countryJson = countryRes.data;
const batchJson = batchRes.data;
if (!statsJson?.status) {
setErrorMessage(
statsJson?.message ||
"Gagal mengambil data dashboard (statistics/dashboard)."
);
setLoading(false);
return;
}
if (!positionJson?.status) {
setErrorMessage(
positionJson?.message ||
"Gagal mengambil data dashboard (statistics/position)."
);
setLoading(false);
return;
}
if (!countryJson?.status) {
setErrorMessage(
countryJson?.message ||
"Gagal mengambil data dashboard (statistics/country)."
);
setLoading(false);
return;
}
if (batchJson?.success) {
setBatches(batchJson.data || []);
} else {
console.warn("Fetch batch gagal:", batchJson?.message);
}
const stats = statsJson.data;
const positions = positionJson.data || [];
const countries = countryJson.data || [];
const statCardsFromApi: StatCard[] = [ const statCardsFromApi: StatCard[] = [
{ {
title: "Total Pendaftar", title: "Total Pendaftar",
value: data.totalPendaftar, value: stats.total_pendaftar,
subtitle: "+200 dari batch sebelumnya", // sesuaikan bila BE sudah support subtitle: "+200 dari batch sebelumnya",
icon: <Users size={24} />, icon: <Users size={24} />,
iconColor: "#0088ff", iconColor: "#0088ff",
}, },
{ {
title: "Total Diterima", title: "Total Diterima",
value: data.totalDiterima, value: stats.total_diterima,
subtitle: `${( subtitle: `${stats.acceptance_rate} acceptance rate`,
(data.totalDiterima / (data.totalPendaftar || 1)) *
100
).toFixed(1)}% acceptance rate`,
icon: <UserCheck size={24} />, icon: <UserCheck size={24} />,
iconColor: "#00cc00", iconColor: "#00cc00",
}, },
{ {
title: "Total Ditolak", title: "Total Ditolak",
value: data.totalDitolak, value: stats.total_ditolak,
subtitle: `${( subtitle: `${stats.rejection_rate} rejection rate`,
(data.totalDitolak / (data.totalPendaftar || 1)) *
100
).toFixed(1)}% rejection rate`,
icon: <UserX size={24} />, icon: <UserX size={24} />,
iconColor: "#ff3333", iconColor: "#ff3333",
}, },
{ {
title: "Sedang Diproses", title: "Sedang Diproses",
value: data.totalDiproses, value: stats.sedang_diproses,
subtitle: `${( subtitle: `${stats.pending_rate} pending review`,
(data.totalDiproses / (data.totalPendaftar || 1)) *
100
).toFixed(1)}% pending review`,
icon: <Clock size={24} />, icon: <Clock size={24} />,
iconColor: "#ffaa00", iconColor: "#ffaa00",
}, },
]; ];
const penerimaanFromApi: ChartDataItem[] = [ const penerimaanFromApi: ChartDataItem[] = [
{ name: "Total Pendaftar", value: data.totalPendaftar }, { name: "Total Pendaftar", value: stats.total_pendaftar },
{ name: "Total Diterima", value: data.totalDiterima }, { name: "Total Diterima", value: stats.total_diterima },
{ name: "Total Ditolak", value: data.totalDitolak }, { name: "Total Ditolak", value: stats.total_ditolak },
]; ];
const universitasFromApi: ChartDataItem[] = positions.map((p) => ({
name: p.posisi,
value: p.total,
}));
const negaraFromApi: ChartDataItem[] = countries.map((c) => ({
name: c.negara,
value: c.total,
}));
setStatCards(statCardsFromApi); setStatCards(statCardsFromApi);
setUniversitasData(data.universitas || []);
setPenerimaanData(penerimaanFromApi); setPenerimaanData(penerimaanFromApi);
setNegaraData(data.negara || []); setUniversitasData(universitasFromApi);
setNegaraData(negaraFromApi);
setLoading(false); setLoading(false);
} catch (error) { } catch (err) {
console.error("Terjadi kesalahan saat mengambil data dashboard:", error); console.error("Terjadi kesalahan saat mengambil data dashboard:", err);
setErrorMessage("Terjadi kesalahan saat mengambil data dashboard."); setErrorMessage(getAxiosErrorMessage(err));
setLoading(false); setLoading(false);
} }
}; };
console.log("AXIOS BASE URL:", API_BASE_URL);
fetchDashboardData(); fetchDashboardData();
}, []); }, [api]);
return ( return (
<div className="min-h-screen w-full overflow-hidden"> <div className="min-h-screen w-full overflow-hidden">
@@ -149,7 +315,9 @@ const Dashboard: React.FC = () => {
{/* Loading / Error State */} {/* Loading / Error State */}
{loading && ( {loading && (
<p className="text-center text-gray-600">Memuat data dashboard...</p> <p className="text-center text-gray-600">
Memuat data dashboard...
</p>
)} )}
{!loading && errorMessage && ( {!loading && errorMessage && (
@@ -329,9 +497,7 @@ const Dashboard: React.FC = () => {
<Cell <Cell
key={`negara-cell-${index}`} key={`negara-cell-${index}`}
fill={ fill={
NEGARA_COLORS[ NEGARA_COLORS[index % NEGARA_COLORS.length]
index % NEGARA_COLORS.length
]
} }
/> />
))} ))}
+3 -1
View File
@@ -216,7 +216,9 @@ const Landing = () => {
</div> </div>
{/* Hero 5 Section FaQ */} {/* Hero 5 Section FaQ */}
<Faq /> <div id="faq">
<Faq />
</div>
{/* Footer */} {/* Footer */}
<Footer /> <Footer />
+1 -1
View File
@@ -117,7 +117,7 @@ const ListFaq = () => {
<button <button
className="bg-[#3BE21D] cursor-pointer text-white p-2 rounded-xl" className="bg-[#3BE21D] cursor-pointer text-white p-2 rounded-xl"
onClick={() => onClick={() =>
window.open(`/details-product/${faq.id}`, "_blank") window.open(`/?faq=${faq.id}#faq`, "_blank")
} }
> >
<svg <svg