import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import * as LucideIcons from "lucide-react"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { BookOpen, Clock, Check, Tag, RotateCcw, LaptopMinimal, Table as TableIcon, } from "lucide-react"; import { useClientTiers } from "@/contexts/ClientTiersProvider"; import ResponsiveModal from "@/components/generic/ResponsiveModal"; import { toast } from "sonner"; import api from "@/utils/api.util"; import { PageMeta } from "@/contexts/MetadataContext"; import { useDateFormat } from "@/hooks/useDateFormat"; import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext"; import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner"; import { resolveTierBadge } from "@/utils/tierBadge.util"; import PlanComparisonTable from "../components/PlanComparisonTable"; // ─── Helpers ────────────────────────────────────────────────────────────────── const REFUND_WINDOW_SECS = 5 * 60; function formatCountdown(secs) { const m = Math.floor(secs / 60); const s = secs % 60; return `${m}:${String(s).padStart(2, "0")}`; } function formatDuration(days, unit) { if (!days) return null; const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 }; const multiplier = UNIT_TO_DAYS[unit] ?? 1; const value = Math.round((days / multiplier) * 1000) / 1000; const label = unit ?? "day"; return `${value} ${label}${value !== 1 ? "s" : ""}`; } function formatCourseDuration(seconds = 0) { if (!seconds) return null; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); if (h && m) return `${h}h ${m}m`; if (h) return `${h}h`; return `${m}m`; } // ─── Skeleton ────────────────────────────────────────────────────────────────── const PlanSkeleton = () => (
{Array.from({ length: 3 }).map((_, i) => ( ))}
); // ─── Plan Card ──────────────────────────────────────────────────────────────── const PREVIEW_COURSE_LIMIT = 2; const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSecsLeft }) => { const { fmtCurrency } = useDateFormat(); const [coursesOpen, setCoursesOpen] = useState(false); const [notAvailableOpen, setNotAvailableOpen] = useState(false); const { label: tierLabel, cls: badgeCls, rank } = resolveTierBadge(plan.tier, tierMap); const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag; const ring = rank > 0 ? "ring-2 ring-primary/30" : ""; const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active"; const duration = formatDuration(plan.duration_days, plan.duration_unit); const features = plan.features ?? []; const previewCourses = plan.courses?.slice(0, PREVIEW_COURSE_LIMIT) ?? []; const extraCount = (plan.courses?.length ?? 0) - PREVIEW_COURSE_LIMIT; return ( <>
{plan.label}
{isCurrent && ( Current Plan )} {tierLabel}
{fmtCurrency(plan.price, plan.currency)} {duration && ( / {duration} )}
{features.length > 0 && ( )} {plan.courses?.length > 0 ? (

Course{plan.course_count !== 1 ? "s" : ""} Included

    {previewCourses.map((course) => (
  • {course.title}
    {course.level && ( {course.level} )} {formatCourseDuration(course.duration_seconds) && ( {formatCourseDuration(course.duration_seconds)} )}
  • ))}
{extraCount > 0 && ( setCoursesOpen(true)} // DIALOG DISABLED variant="secondary" > +{extraCount} more course{extraCount !== 1 ? "s" : ""} )}
) : !plan.description ? (

Access to all free course content.

) : null}
{isCurrent && refundSecsLeft > 0 ? ( ) : !plan.is_active ? ( ) : !isCurrent ? ( ) : null}
{/* ── Not Available Dialog ──────────────────────────────────────── */} Unavailable

The {plan.label} plan is currently not available for purchase. Please check back later.

{/* ── Plan Detail Dialog ─────────────────────────────────────────── */} {/* Current disabled from line 170 to 172 */}
{plan.label} {tierLabel}
{/* Price + Duration */}
{fmtCurrency(plan.price, plan.currency)} {duration && ( / {duration} )}
{/* Features */} {features.length > 0 && ( )} {/* Description */} {plan.description && (

{plan.description}

)} {/* Courses horizontal scroll */} {plan.courses?.length > 0 && (

{plan.courses.length} Course{plan.courses.length !== 1 ? "s" : ""} Included

{plan.courses.map((course) => (

{course.title}

{course.level && ( {course.level} )} {formatCourseDuration(course.duration_seconds) && ( {formatCourseDuration(course.duration_seconds)} )}
))}
)} {!isCurrent && plan.tier !== "free" && ( )}
); }; // ─── Page ───────────────────────────────────────────────────────────────────── export default function PlanList() { const navigate = useNavigate(); const { plans, plansLoading, myTier, tierLoading, tierMap, getPlans, getMyTier, getTierCategories, resetMyTier } = useClientTiers(); const { fmtDate, fmtCurrency } = useDateFormat(); const { advertisements, loading: adLoading, getActiveAdvertisement, handleAdCtaClick } = useClientAdvertisements(); const [view, setView] = useState("grid"); const [refundPlan, setRefundPlan] = useState(null); // plan being refunded const [refundLoading, setRefundLoading] = useState(false); const [refundSecsLeft, setRefundSecsLeft] = useState(0); const refundTimerRef = useRef(null); useEffect(() => { getPlans(); getMyTier(); getTierCategories(); getActiveAdvertisement("tier_plans.banner"); // eslint-disable-next-line react-hooks/exhaustive-deps }, [getPlans, getMyTier]); const bannerAd = advertisements["tier_plans.banner"] ?? null; useEffect(() => { clearInterval(refundTimerRef.current); if (!myTier?.starts_at) { setRefundSecsLeft(0); return; } const compute = () => { const elapsed = Math.floor((Date.now() - new Date(myTier.starts_at).getTime()) / 1000); return Math.max(0, REFUND_WINDOW_SECS - elapsed); }; setRefundSecsLeft(compute()); refundTimerRef.current = setInterval(() => { const left = compute(); setRefundSecsLeft(left); if (left === 0) clearInterval(refundTimerRef.current); }, 1000); return () => clearInterval(refundTimerRef.current); }, [myTier?.starts_at]); const handleViewPlan = (plan) => navigate(`/plans/view/${plan.plan_id}`); const handleSelectPlan = (plan) => navigate(`/plans/checkout?plan_id=${plan.plan_id}`); const handleRefundClick = (plan) => setRefundPlan(plan); const handleConfirmRefund = async () => { setRefundLoading(true); try { const { data } = await api.post("/client/tiers/checkout/refund"); toast(data.message ?? "Refund processed. Your access has been revoked."); setRefundPlan(null); resetMyTier(); getMyTier(); } catch (err) { toast(err?.response?.data?.message ?? "Refund failed. Please try again."); } finally { setRefundLoading(false); } }; return (
{/* Advertisement Banner */} {adLoading["tier_plans.banner"] ? ( ) : ( )} {/* Section Header */}

Available Plans

Choose a subscription that matches your goals.

{!plansLoading && plans.length > 0 && (
)}
{/* Plan Cards / Comparison Table */} {plansLoading || tierLoading ? (
{Array.from({ length: 3 }).map((_, i) => )}
) : plans.length === 0 ? (

No plans available at the moment.

) : view === "table" ? ( ) : (
{plans.map((plan) => ( ))}
)}
{/* ── Refund Confirmation Modal ──────────────────────────────────── */} !v && setRefundPlan(null)} title="Request Refund" description={`Are you sure you want to refund your ${refundPlan?.label ?? "current"} plan?`} footer={ <> } >
Plan {refundPlan?.tier}
Refund amount {refundPlan ? fmtCurrency(refundPlan.price, refundPlan.currency) : "—"}
{myTier?.expires_at && (
Access until {fmtDate(myTier.expires_at)}
)}
Refund window {refundSecsLeft > 0 ? ( {formatCountdown(refundSecsLeft)} remaining ) : ( Expired )}
{refundSecsLeft > 0 ? (

Your refund will be processed through PayPal.{" "} Access will be revoked immediately {" "} and your account will be downgraded to Free.

) : (

The 5-minute refund window has expired. Refunds are no longer available for this payment.

)}
); }