feat(dimentorin): articles page integrated with API (list, filter, detail)

This commit is contained in:
asepharyana
2026-08-04 19:31:29 +07:00
parent ce0579de5d
commit 583b6b224e
11 changed files with 412 additions and 186 deletions
@@ -1,9 +1,19 @@
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { Button } from "@imphnen-frontend-service/ui/atoms"; import { Button } from "@imphnen-frontend-service/ui/atoms";
import { cn } from "@imphnen-frontend-service/utils"; import { cn, For, Show } from "@imphnen-frontend-service/utils";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useParams } from "react-router-dom";
import { useGetArticleBySlug } from "@imphnen-frontend-service/service";
export default function DetailArticle() { export default function DetailArticle() {
const { slug } = useParams()
const { data: article, isLoading, isError } = useGetArticleBySlug(slug ?? "")
const paragraphs = article?.content.split("\n").filter((p) => p.trim()) ?? []
const date = article?.created_at
? new Date(article.created_at).toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" })
: ""
return ( return (
<main> <main>
<motion.section <motion.section
@@ -12,76 +22,83 @@ export default function DetailArticle() {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ duration: 0.5, ease: "easeInOut" }} transition={{ duration: 0.5, ease: "easeInOut" }}
> >
<div className="w-full max-w-[900px] mx-auto mb-8 md:mb-[60px]"> <Show
<div className="mb-10 flex items-center justify-between"> condition={!isLoading && !isError && !!article}
<Button fallback={
type="button" <div className="w-full max-w-[900px] mx-auto text-center text-neutral-500 py-20">
variant="text" {isError ? "Artikel tidak ditemukan." : "Memuat artikel..."}
className="flex gap-x-2 text-[10px] px-2.5 py-2 md:text-[15px]" </div>
> }
<Icon icon="material-symbols:arrow-back-rounded" /> >
<span>Back</span> <div className="w-full max-w-[900px] mx-auto mb-8 md:mb-[60px]">
</Button> <div className="mb-10 flex items-center justify-between">
<Button <Button
type="button" type="button"
variant="text" variant="text"
className="flex gap-x-2 text-[10px] px-2.5 py-2 md:text-[15px]" className="flex gap-x-2 text-[10px] px-2.5 py-2 md:text-[15px]"
> onClick={() => window.history.back()}
<span>Read Next</span> >
<Icon icon="material-symbols:arrow-forward-rounded" /> <Icon icon="material-symbols:arrow-back-rounded" />
</Button> <span>Back</span>
</div> </Button>
<Button
type="button"
variant="text"
className="flex gap-x-2 text-[10px] px-2.5 py-2 md:text-[15px]"
>
<span>Read Next</span>
<Icon icon="material-symbols:arrow-forward-rounded" />
</Button>
</div>
<div className="text-center leading-tight"> <div className="text-center leading-tight">
<h1 <h1
className="text-[23px] text-neutral-800 font-semibold mb-6 md:text-[37px] md:mb-10 xl:text-[46px]" className="text-[23px] text-neutral-800 font-semibold mb-6 md:text-[37px] md:mb-10 xl:text-[46px]"
> >
Lorem ipsum dolor sit amet, consectetur adipiscing elit. {article?.title}
</h1> </h1>
<p <p
className="text-[15px] font-semibold text-neutral-800 mb-2 md:text-[19px] md:mb-1 xl:text-[23px] xl:mb-2" className="text-[15px] font-semibold text-neutral-800 mb-2 md:text-[19px] md:mb-1 xl:text-[23px] xl:mb-2"
> >
Writer&rsquo;s Name {article?.author_name || "IMPHNEN Editorial"}
</p> </p>
<p className="text-xs text-neutral-600 mb-6 md:text-[15px] xl:text-[19px]"> <p className="text-xs text-neutral-600 mb-6 md:text-[15px] xl:text-[19px]">
14 March 2025 {date}
</p> </p>
<div <div
className="text-[10px] text-primary-600 font-medium bg-primary-200 px-3 py-1 rounded-4xl w-max mx-auto md:text-xs xl:text-[15px]" className="text-[10px] text-primary-600 font-medium bg-primary-200 px-3 py-1 rounded-4xl w-max mx-auto md:text-xs xl:text-[15px]"
> >
5 min read {article?.category}
</div>
</div> </div>
</div> </div>
</div>
<div className="w-full max-w-[900px] mx-auto"> <div className="w-full max-w-[900px] mx-auto">
<div className="mb-8 w-full h-auto aspect-16/10 overflow-hidden md:aspect-31/16 md:mb-[60px] xl:aspect-45/26"> <div className="mb-8 w-full h-auto aspect-16/10 overflow-hidden md:aspect-31/16 md:mb-[60px] xl:aspect-45/26">
<img src="/image/59de8a682dfbe42455613ea07477ff3e30532c1f.webp" alt="Cover Article" className="size-full object-cover" /> <img
src={article?.cover_url ?? "/image/59de8a682dfbe42455613ea07477ff3e30532c1f.webp"}
alt="Cover Article"
className="size-full object-cover"
/>
</div>
<div
className={cn(
"text-balance text-[15px] font-medium text-neutral-600 space-y-8",
"md:space-y-12 md:text-[19px] xl:text-[23px] xl:max-w-[780px] xl:mx-auto",
)}
>
<p className="leading-tight">{article?.excerpt}</p>
<For data={paragraphs}>
{(paragraph) => (
<p key={paragraph.slice(0, 40)} className="leading-tight">
{paragraph}
</p>
)}
</For>
</div>
</div> </div>
</Show>
<div
className={cn(
"text-balance text-[15px] font-medium text-neutral-600 space-y-8",
"md:space-y-12 md:text-[19px] xl:text-[23px] xl:max-w-[780px] xl:mx-auto",
)}
>
<h2 className="text-[19px] text-neutral-800 font-semibold leading-tight md:text-[23px] xl:text-[29px]">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus.
</h2>
<p className="leading-tight">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
</p>
<img src="/image/code-article.webp" alt="Code" className="w-full h-auto mb-12" />
<p className="leading-tight">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
</p>
<p className="leading-tight">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
</p>
</div>
</div>
</motion.section> </motion.section>
</main> </main>
) )
@@ -2,12 +2,26 @@ import { Button } from "@imphnen-frontend-service/ui/atoms"
import { cn, Show } from "@imphnen-frontend-service/utils" import { cn, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react" import { FC } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { TArticleListItem } from "@imphnen-frontend-service/service"
export type ArticleCardProps = { export type ArticleCardProps = {
variant?: "default" | "recent" | "featured" variant?: "default" | "recent" | "featured"
article?: TArticleListItem
} }
export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default' }) => { function formatDate(iso?: string) {
if (!iso) return ""
return new Date(iso).toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" })
}
export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default', article }) => {
const title = article?.title ?? "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
const excerpt = article?.excerpt ?? "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
const author = article?.author_name ?? "IMPHNEN Editorial"
const date = formatDate(article?.created_at)
const slug = article?.slug ?? ""
const cover = article?.cover_url
return ( return (
<div <div
className={cn( className={cn(
@@ -22,25 +36,25 @@ export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default' }) => {
)} )}
> >
<img <img
src="/image/59de8a682dfbe42455613ea07477ff3e30532c1f.webp" src={cover ?? "/image/59de8a682dfbe42455613ea07477ff3e30532c1f.webp"}
alt="Cover's Article" alt={title}
className={cn("w-full object-cover", variant === "recent" && "h-full")} className={cn("w-full object-cover", variant === "recent" && "h-full")}
/> />
</div> </div>
<div className={cn("leading-tight", variant === "recent" && "xl:flex-1")}> <div className={cn("leading-tight", variant === "recent" && "xl:flex-1")}>
<div className="w-max bg-primary-200 text-primary-600 px-3 py-1 text-xs font-medium rounded-4xl mb-4 xl:mb-[15px]"> <div className="w-max bg-primary-200 text-primary-600 px-3 py-1 text-xs font-medium rounded-4xl mb-4 xl:mb-[15px]">
5 min read {article?.category ?? "Artikel"}
</div> </div>
<Link <Link
to="/articles/detail" to={slug ? `/articles/${slug}` : "/articles"}
className={cn( className={cn(
"text-[15px] text-neutral-800 font-semibold mb-4 line-clamp-2 md:text-[19px] xl:mb-[23px]", "text-[15px] text-neutral-800 font-semibold mb-4 line-clamp-2 md:text-[19px] xl:mb-[23px]",
variant === "featured" && "text-[19px] md:text-[23px]", variant === "featured" && "text-[19px] md:text-[23px]",
variant === "recent" && "mb-7 xl:text-[27px]" variant === "recent" && "mb-7 xl:text-[27px]"
)} )}
> >
Lorem ipsum dolor sit amet, consectetur adipiscing elit. {title}
</Link> </Link>
<Show condition={variant !== "recent"}> <Show condition={variant !== "recent"}>
<p <p
@@ -49,7 +63,7 @@ export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default' }) => {
variant === "featured" && "text-[15px] md:text-[19px] md:mb-6", variant === "featured" && "text-[15px] md:text-[19px] md:mb-6",
)} )}
> >
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. {excerpt}
</p> </p>
</Show> </Show>
<div <div
@@ -59,14 +73,16 @@ export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default' }) => {
variant === "featured" && "text-[15px] md:text-[19px] xl:text-base", variant === "featured" && "text-[15px] md:text-[19px] xl:text-base",
)} )}
> >
<p>Writers Name</p> <p>{author}</p>
<p>14 March 2025</p> <p>{date}</p>
</div> </div>
<Show condition={variant === "default"}> <Show condition={variant === "default"}>
<Button type="button" size="sm" className="px-2.5 py-2"> <Link to={slug ? `/articles/${slug}` : "/articles"}>
Baca Artikel <Button type="button" size="sm" className="px-2.5 py-2">
</Button> Baca Artikel
</Button>
</Link>
</Show> </Show>
</div> </div>
</div> </div>
@@ -1,14 +1,22 @@
import { ReactElement, useRef, useState } from "react"; import { ReactElement, useRef, useState } from "react";
import { ArticleCard } from "./_components/card/article"; import { ArticleCard } from "./_components/card/article";
import { cn, For } from "@imphnen-frontend-service/utils"; import { cn, For, Show } from "@imphnen-frontend-service/utils";
import { Button } from "@imphnen-frontend-service/ui/atoms"; import { Button } from "@imphnen-frontend-service/ui/atoms";
import { motion, useInView, Variants } from "framer-motion"; import { motion, useInView, Variants } from "framer-motion";
import { useGetArticleCategories, useGetArticles } from "@imphnen-frontend-service/service";
const CATEGORIES = ['UI/UX Design', 'Software/Web Dev', 'Data & AI', 'Cloud & DevOps', 'Cybersecurity', 'IT & Network', 'Project Management', 'QA & Testing'] as const
type Category = typeof CATEGORIES[number]
export default function Components(): ReactElement { export default function Components(): ReactElement {
const [activeTab, setActiveTab] = useState<Category>('UI/UX Design') const [activeTab, setActiveTab] = useState<string>("Semua")
const { data: articles = [], isLoading } = useGetArticles({ per_page: 20 })
const { data: categories = [] } = useGetArticleCategories()
const tabs = ["Semua", ...categories]
const featured = articles[0]
const recent = articles.slice(1, 4)
const filtered = activeTab === "Semua"
? articles
: articles.filter((a) => a.category === activeTab)
const ref = useRef(null) const ref = useRef(null)
const isInView = useInView(ref, { once: true, amount: 0.2 }) const isInView = useInView(ref, { once: true, amount: 0.2 })
@@ -52,29 +60,34 @@ export default function Components(): ReactElement {
Featuring Articles Featuring Articles
</motion.h1> </motion.h1>
<div className="grid gap-12 xl:grid-cols-2"> <Show
<motion.div variants={childVariants}> condition={!isLoading}
<ArticleCard variant="featured" /> fallback={<p className="text-neutral-500">Memuat artikel...</p>}
</motion.div> >
<div className="grid gap-12 xl:grid-cols-2">
<motion.div variants={childVariants}>
<ArticleCard variant="featured" article={featured} />
</motion.div>
<div> <div>
<motion.h1 <motion.h1
className="text-[19px] text-neutral-800 font-semibold mb-10 md:text-[29px] xl:text-[37px] xl:mb-12" className="text-[19px] text-neutral-800 font-semibold mb-10 md:text-[29px] xl:text-[37px] xl:mb-12"
variants={childVariants} variants={childVariants}
> >
Recent Articles Recent Articles
</motion.h1> </motion.h1>
<div className="grid gap-10"> <div className="grid gap-10">
<For data={Array.from({ length: 3 })}> <For data={recent}>
{(_, index) => ( {(article, index) => (
<motion.div key={index} variants={childVariants}> <motion.div key={article.id ?? index} variants={childVariants}>
<ArticleCard variant="recent" /> <ArticleCard variant="recent" article={article} />
</motion.div> </motion.div>
)} )}
</For> </For>
</div>
</div> </div>
</div> </div>
</div> </Show>
</div> </div>
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
@@ -86,7 +99,7 @@ export default function Components(): ReactElement {
</motion.h1> </motion.h1>
<div className="scrollbar-hide mb-8 w-full overflow-auto mx-auto"> <div className="scrollbar-hide mb-8 w-full overflow-auto mx-auto">
<motion.div className="min-w-max w-auto flex gap-x-3" variants={childVariants}> <motion.div className="min-w-max w-auto flex gap-x-3" variants={childVariants}>
<For data={CATEGORIES}> <For data={tabs}>
{(category) => ( {(category) => (
<Button <Button
key={category} key={category}
@@ -106,15 +119,20 @@ export default function Components(): ReactElement {
</motion.div> </motion.div>
</div> </div>
<div className="grid gap-8 xl:grid-cols-3"> <Show
<For data={Array.from({ length: 3 })}> condition={filtered.length > 0}
{(_, index) => ( fallback={<p className="text-neutral-500 text-center py-10">Belum ada artikel di kategori ini.</p>}
<motion.div key={index} variants={childVariants}> >
<ArticleCard /> <div className="grid gap-8 xl:grid-cols-3">
</motion.div> <For data={filtered}>
)} {(article, index) => (
</For> <motion.div key={article.id ?? index} variants={childVariants}>
</div> <ArticleCard article={article} />
</motion.div>
)}
</For>
</div>
</Show>
</div> </div>
</motion.section> </motion.section>
</main> </main>
@@ -10,6 +10,7 @@ import { QrisPaymentStep } from "./steps/qris-payement"
import { VAPaymentStep } from "./steps/va-payment" import { VAPaymentStep } from "./steps/va-payment"
import { SuccessStep } from "./steps/success" import { SuccessStep } from "./steps/success"
import { PaymentStep } from "./steps/payment" import { PaymentStep } from "./steps/payment"
import { TBookSessionRequest, usePostBookSession } from "@imphnen-frontend-service/service"
const STEPS = ['topic', 'schedule', 'profile', 'payment', 'qr-payment', 'va-payment', 'success'] as const const STEPS = ['topic', 'schedule', 'profile', 'payment', 'qr-payment', 'va-payment', 'success'] as const
type Step = typeof STEPS[number] type Step = typeof STEPS[number]
@@ -17,13 +18,32 @@ type Step = typeof STEPS[number]
type Props = { type Props = {
open: boolean open: boolean
setOpen: (open: boolean) => void setOpen: (open: boolean) => void
mentorId?: string
mentor?: { fullname?: string | null; current_role?: string; current_company?: string }
} }
export const AppointmentModal: FC<Props> = ({ open, setOpen }) => { export const AppointmentModal: FC<Props> = ({ open, setOpen, mentorId, mentor }) => {
const [step, setStep] = useState<Step>('topic') const [step, setStep] = useState<Step>('topic')
const [selectedTopics, setSelectedTopics] = useState<number[]>([]) const [selectedTopics, setSelectedTopics] = useState<number[]>([])
const [booking, setBooking] = useState<TBookSessionRequest | null>(null)
const [error, setError] = useState("")
const bookMutation = usePostBookSession(mentorId ?? "")
const handleStep = (action: 'next' | 'prev') => { const handleStep = async (action: 'next' | 'prev') => {
setError("")
if (step === 'payment' && action === 'next') {
if (!mentorId || !booking) {
setError("Data booking tidak lengkap.")
return
}
try {
await bookMutation.mutateAsync({ ...booking, topic: booking.topic || `Mentoring #${selectedTopics[0] ?? 1}` })
setStep('success')
} catch {
setError("Gagal membuat sesi mentoring. Coba lagi.")
}
return
}
if (action === 'next' && step === 'success') { if (action === 'next' && step === 'success') {
setOpen(false) setOpen(false)
} else if (action === 'next') { } else if (action === 'next') {
@@ -113,13 +133,17 @@ export const AppointmentModal: FC<Props> = ({ open, setOpen }) => {
<AnimatePresence> <AnimatePresence>
{step === 'topic' && <TopicStep selectedTopics={selectedTopics} setSelectedTopics={setSelectedTopics} />} {step === 'topic' && <TopicStep selectedTopics={selectedTopics} setSelectedTopics={setSelectedTopics} />}
{step === 'schedule' && <ScheduleStep />} {step === 'schedule' && <ScheduleStep onChange={(data) => setBooking(data)} />}
{step === 'profile' && <ProfileStep />} {step === 'profile' && <ProfileStep />}
{step === 'payment' && <PaymentStep selectedTopics={selectedTopics} />} {step === 'payment' && <PaymentStep selectedTopics={selectedTopics} mentor={mentor} booking={booking} />}
{step === 'qr-payment' && <QrisPaymentStep />} {step === 'qr-payment' && <QrisPaymentStep />}
{step === 'va-payment' && <VAPaymentStep />} {step === 'va-payment' && <VAPaymentStep />}
{step === 'success' && <SuccessStep />} {step === 'success' && <SuccessStep />}
</AnimatePresence> </AnimatePresence>
{error && (
<p className="text-danger-600 text-xs mt-4 font-medium">{error}</p>
)}
</div> </div>
<Show condition={step !== 'success'}> <Show condition={step !== 'success'}>
@@ -148,7 +172,7 @@ export const AppointmentModal: FC<Props> = ({ open, setOpen }) => {
condition={step !== 'success'} condition={step !== 'success'}
fallback="Halman Booking" fallback="Halman Booking"
> >
<Show condition={step !== 'payment'} fallback="Bayar Sekarang"> <Show condition={step !== 'payment'} fallback={bookMutation.isPending ? "Memproses..." : "Bayar Sekarang"}>
Selanjutnya Selanjutnya
</Show> </Show>
</Show> </Show>
@@ -2,12 +2,18 @@ import { For, Show } from "@imphnen-frontend-service/utils"
import { FC } from "react" import { FC } from "react"
import { motion } from "framer-motion" import { motion } from "framer-motion"
import { TOPICS } from "../../../sections/topics" import { TOPICS } from "../../../sections/topics"
import { TBookSessionRequest } from "@imphnen-frontend-service/service"
type Props = { type Props = {
selectedTopics: number[] selectedTopics: number[]
mentor?: { fullname?: string | null; current_role?: string; current_company?: string } | null
booking?: TBookSessionRequest | null
} }
export const PaymentStep: FC<Props> = ({ selectedTopics }) => { export const PaymentStep: FC<Props> = ({ selectedTopics, mentor, booking }) => {
const rate = 50000
const serviceFee = 2000
return ( return (
<motion.div <motion.div
className="bg-white px-6 py-5 rounded-md grid gap-6 md:grid-cols-2 md:gap-8 xl:grid-cols-9" className="bg-white px-6 py-5 rounded-md grid gap-6 md:grid-cols-2 md:gap-8 xl:grid-cols-9"
@@ -26,11 +32,16 @@ export const PaymentStep: FC<Props> = ({ selectedTopics }) => {
</div> </div>
<div className="w-44 mx-auto text-center md:ms-0 md:text-left md:mb-2 md:text-[15px] md:w-auto"> <div className="w-44 mx-auto text-center md:ms-0 md:text-left md:mb-2 md:text-[15px] md:w-auto">
<h3 className="text-xs font-semibold mb-1 xl:text-[15px]"> <h3 className="text-xs font-semibold mb-1 xl:text-[15px]">
Muhammad Firdaus Oi Oi Oi, S.H., M.H. {mentor?.fullname || "Mentor"}
</h3> </h3>
<p className="text-[10px] text-neutral-600 md:text-xs"> <p className="text-[10px] text-neutral-600 md:text-xs">
UI Designer at Oray orayan Studios {mentor?.current_role || "Mentor"} at {mentor?.current_company || "IMPHNEN"}
</p> </p>
{booking?.scheduled_at && (
<p className="text-[10px] text-neutral-500 md:text-xs mt-1">
{new Date(booking.scheduled_at).toLocaleString("id-ID", { dateStyle: "full", timeStyle: "short" })}
</p>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -104,14 +115,14 @@ export const PaymentStep: FC<Props> = ({ selectedTopics }) => {
</p> </p>
<div className="grid grid-cols-2 space-y-2.5 text-[10px] text-neutral-400 font-medium md:text-xs md:space-y-3"> <div className="grid grid-cols-2 space-y-2.5 text-[10px] text-neutral-400 font-medium md:text-xs md:space-y-3">
<p className="font-normal">Subtotal :</p> <p className="font-normal">Subtotal :</p>
<p className="text-right">Rp. 50.000</p> <p className="text-right">Rp. {rate.toLocaleString("id-ID")}</p>
<p className="font-normal">Service Fee :</p> <p className="font-normal">Service Fee :</p>
<p className="text-right">Rp. 2.000</p> <p className="text-right">Rp. {serviceFee.toLocaleString("id-ID")}</p>
<hr className="border-neutral-200 col-span-full mt-1" /> <hr className="border-neutral-200 col-span-full mt-1" />
<p className="text-neutral-700">Total :</p> <p className="text-neutral-700">Total :</p>
<p className="text-right text-neutral-700">Rp. 52.000</p> <p className="text-right text-neutral-700">Rp. {(rate + serviceFee).toLocaleString("id-ID")}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -1,6 +1,8 @@
import { Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms" import { Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
import { cn } from "@imphnen-frontend-service/utils" import { cn } from "@imphnen-frontend-service/utils"
import { motion } from "framer-motion" import { motion } from "framer-motion"
import { useEffect, useState } from "react"
import { TBookSessionRequest } from "@imphnen-frontend-service/service"
const placeholder = `Hi [Nama Mentor], Saya [Nama Kamu] & saya berharap dapat memiliki sesi mentoring dengan Anda. const placeholder = `Hi [Nama Mentor], Saya [Nama Kamu] & saya berharap dapat memiliki sesi mentoring dengan Anda.
@@ -13,7 +15,27 @@ Saya ingin tahu secara khusus tentang ___.
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]') const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
export const ScheduleStep = () => { type Props = {
onChange: (data: TBookSessionRequest) => void
}
export const ScheduleStep = ({ onChange }: Props) => {
const [date, setDate] = useState("")
const [time, setTime] = useState("")
const [sessionType, setSessionType] = useState("online")
const [description, setDescription] = useState("")
useEffect(() => {
if (!date || !time) return
onChange({
topic: "Mentoring Session",
scheduled_at: `${date}T${time}:00`,
description,
duration_minutes: 60,
session_type: sessionType,
})
}, [date, time, sessionType, description])
return ( return (
<motion.div <motion.div
className="bg-white px-6 py-5 rounded-md md:px-6" className="bg-white px-6 py-5 rounded-md md:px-6"
@@ -31,15 +53,15 @@ export const ScheduleStep = () => {
<div className="grid gap-2.5 md:grid-cols-2 md:gap-5"> <div className="grid gap-2.5 md:grid-cols-2 md:gap-5">
<div> <div>
<label className={labelClass}>Tanggal</label> <label className={labelClass}>Tanggal</label>
<Input type="date" className="min-w-full w-full" /> <Input type="date" className="min-w-full w-full" value={date} onChange={(e) => setDate(e.target.value)} />
</div> </div>
<div> <div>
<label className={labelClass}>Waktu</label> <label className={labelClass}>Waktu</label>
<Input type="time" className="min-w-full w-full" /> <Input type="time" className="min-w-full w-full" value={time} onChange={(e) => setTime(e.target.value)} />
</div> </div>
<div className="relative md:col-span-full"> <div className="relative md:col-span-full">
<label className={labelClass}>Lokasi</label> <label className={labelClass}>Lokasi</label>
<Select className="min-w-full w-full"> <Select className="min-w-full w-full" value={sessionType} onChange={(e) => setSessionType(e.target.value)}>
<option value="online">Online</option> <option value="online">Online</option>
<option value="offline">Offline</option> <option value="offline">Offline</option>
</Select> </Select>
@@ -48,7 +70,9 @@ export const ScheduleStep = () => {
<label className={labelClass}>Pertanyaan Untuk Senpai</label> <label className={labelClass}>Pertanyaan Untuk Senpai</label>
<Textarea <Textarea
className="min-w-full w-full h-40" className="min-w-full w-full h-40"
placeholder={placeholder} /> placeholder={placeholder}
value={description}
onChange={(e) => setDescription(e.target.value)} />
</div> </div>
</div> </div>
</motion.div> </motion.div>
@@ -1,68 +1,77 @@
import { Button } from "@imphnen-frontend-service/ui/atoms" import { Button } from "@imphnen-frontend-service/ui/atoms"
import { cn, For } from "@imphnen-frontend-service/utils" import { For, Show } from "@imphnen-frontend-service/utils"
import { useGetMentorAvailability } from "@imphnen-frontend-service/service"
import { FC } from "react" import { FC } from "react"
const SCHEDULES = [
{
date: "Kamis, 20 Maret 2025",
availableCount: 10,
schedule: [
{ time: "19:00 WIB", available: true },
{ time: "20:00 WIB", available: true },
{ time: "21:00 WIB", available: true },
{ time: "22:00 WIB", available: true },
]
},
{
date: "Jumat, 21 Maret 2025",
availableCount: 1,
schedule: [
{ time: "19:00 WIB", available: true },
{ time: "20:00 WIB", available: true },
{ time: "21:00 WIB", available: false },
{ time: "22:00 WIB", available: true },
]
}
]
type Props = { type Props = {
onBook: () => void onBook: () => void
mentorId?: string
} }
export const SenpaiScheduleSection: FC<Props> = ({ onBook }) => { const DAY_NAMES = ["Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"]
function formatDate(dateStr: string) {
const d = new Date(dateStr + "T00:00:00")
return `${DAY_NAMES[d.getDay()]}, ${d.getDate()} ${d.toLocaleString("id-ID", { month: "long" })} ${d.getFullYear()}`
}
function formatTime(timeStr: string) {
const [h] = timeStr.split(":").map(Number)
return `${String(h).padStart(2, "0")}:00 WIB`
}
export const SenpaiScheduleSection: FC<Props> = ({ onBook, mentorId }) => {
const { data: availability, isLoading } = useGetMentorAvailability(mentorId ?? "")
const slots = availability?.slots ?? []
const byDate = new Map<string, typeof slots>()
for (const s of slots) {
if (!byDate.has(s.date)) byDate.set(s.date, [])
byDate.get(s.date)!.push(s)
}
const days = [...byDate.entries()]
return ( return (
<div> <div>
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Senpai Schedule</h2> <h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Senpai Schedule</h2>
<div className="p-4 bg-primary-50 border border-primary-100 rounded-md space-y-5"> <div className="p-4 bg-primary-50 border border-primary-100 rounded-md space-y-5">
<For data={SCHEDULES}> <Show
{(item) => ( condition={!isLoading && days.length > 0}
<div key={item.date}> fallback={
<div className="flex justify-between items-center mb-4"> <p className="text-xs text-neutral-500">
<p className="text-[15px] font-medium">{item.date}</p> {isLoading ? "Memuat jadwal senpai..." : "Belum ada jadwal tersedia."}
<p </p>
className={cn( }
"rounded-4xl py-1 px-2.5 text-xs font-medium", >
item.availableCount > 5 ? "bg-primary-200 text-primary-500" : "bg-danger-100 text-danger-600" <For data={days}>
)} {([date, daySlots]) => (
> <div key={date}>
{item.availableCount} slot tersisa <div className="flex justify-between items-center mb-4">
</p> <p className="text-[15px] font-medium">{formatDate(date)}</p>
<p className="rounded-4xl py-1 px-2.5 text-xs font-medium bg-primary-200 text-primary-500">
{daySlots.length} slot tersisa
</p>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-2">
<For data={daySlots}>
{(schedule) => (
<div
key={`${schedule.date}-${schedule.time}`}
className={`px-3 py-2 text-xs rounded-md font-semibold ${
schedule.available
? "bg-primary-300 text-primary-600"
: "bg-neutral-200 text-neutral-400 line-through"
}`}
>
{formatTime(schedule.time)}
</div>
)}
</For>
</div>
</div> </div>
<div className="flex gap-x-3"> )}
<For data={item.schedule}> </For>
{(schedule, index) => ( </Show>
<div
key={index}
className="bg-primary-300 text-primary-600 px-3 py-2 text-xs rounded-md font-semibold"
>
{schedule.time}
</div>
)}
</For>
</div>
</div>
)}
</For>
<hr className="border-primary-200" /> <hr className="border-primary-200" />
@@ -55,13 +55,13 @@ export const Components: FC = () => {
</div> </div>
<div className="hidden xl:block xl:w-[400px]"> <div className="hidden xl:block xl:w-[400px]">
<SenpaiScheduleSection onBook={() => setOpen(true)} /> <SenpaiScheduleSection onBook={() => setOpen(true)} mentorId={mentor?.id} />
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<AppointmentModal open={open} setOpen={setOpen} /> <AppointmentModal open={open} setOpen={setOpen} mentorId={mentor?.id} mentor={mentor} />
</Show> </Show>
</main> </main>
) )
+38
View File
@@ -1,5 +1,8 @@
import { api } from '../'; import { api } from '../';
import { import {
TArticleDetail,
TArticleListItem,
TArticleListParams,
TMentorAvailability, TMentorAvailability,
TMentorDetail, TMentorDetail,
TMentorListParams, TMentorListParams,
@@ -95,3 +98,38 @@ export const postSessionFeedback = async (
}); });
return data; return data;
}; };
export const getArticles = async (
params?: TArticleListParams
): Promise<TArticleListItem[]> => {
const { data } = await api({
method: 'GET',
url: '/dimentorin/articles',
params,
});
return data.data;
};
export const getArticleCategories = async (): Promise<string[]> => {
const { data } = await api({
method: 'GET',
url: '/dimentorin/articles/categories',
});
return data.data;
};
export const getArticleBySlug = async (slug: string): Promise<TArticleDetail> => {
const { data } = await api({
method: 'GET',
url: `/dimentorin/articles/slug/${slug}`,
});
return data.data;
};
export const getArticleById = async (id: string): Promise<TArticleDetail> => {
const { data } = await api({
method: 'GET',
url: `/dimentorin/articles/${id}`,
});
return data.data;
};
@@ -1,5 +1,9 @@
import { useMutation, useQuery, UseQueryResult } from '@tanstack/react-query'; import { useMutation, useQuery, UseQueryResult } from '@tanstack/react-query';
import { import {
getArticleById,
getArticleBySlug,
getArticleCategories,
getArticles,
getMentorAvailability, getMentorAvailability,
getMentorDetail, getMentorDetail,
getMentorMe, getMentorMe,
@@ -11,6 +15,9 @@ import {
postSessionFeedback, postSessionFeedback,
} from '../../api/dimentorin'; } from '../../api/dimentorin';
import { import {
TArticleDetail,
TArticleListItem,
TArticleListParams,
TBookSessionRequest, TBookSessionRequest,
TMentorAvailability, TMentorAvailability,
TMentorDetail, TMentorDetail,
@@ -105,4 +112,43 @@ export const usePostSessionFeedback = (sessionId: string) => {
}); });
}; };
export const useGetArticles = (
params?: TArticleListParams
): UseQueryResult<TArticleListItem[], TResponseError> => {
return useQuery({
queryKey: ['get-articles', params],
queryFn: async () => await getArticles(params),
});
};
export const useGetArticleCategories = (): UseQueryResult<
string[],
TResponseError
> => {
return useQuery({
queryKey: ['get-article-categories'],
queryFn: async () => await getArticleCategories(),
});
};
export const useGetArticleBySlug = (
slug: string
): UseQueryResult<TArticleDetail, TResponseError> => {
return useQuery({
queryKey: ['get-article-by-slug', slug],
queryFn: async () => await getArticleBySlug(slug),
enabled: !!slug,
});
};
export const useGetArticleById = (
id: string
): UseQueryResult<TArticleDetail, TResponseError> => {
return useQuery({
queryKey: ['get-article-by-id', id],
queryFn: async () => await getArticleById(id),
enabled: !!id,
});
};
export type { TResponseMessage }; export type { TResponseMessage };
@@ -97,3 +97,26 @@ export type TSessionFeedbackRequest = {
feedback: string; feedback: string;
rating: number; rating: number;
}; };
export type TArticleListItem = {
id: string;
title: string;
slug: string;
category: string;
excerpt: string;
cover_url: string | null;
author_name: string | null;
created_at: string;
};
export type TArticleDetail = TArticleListItem & {
content: string;
is_published: boolean;
updated_at: string;
};
export type TArticleListParams = {
page?: number;
per_page?: number;
category?: string;
};