import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose, } from "@/components/ui/dialog"; import { BookOpen, Star, Lock, Sparkles, RotateCcw, Plus, Ban, BadgeCheck, GraduationCap, Book, Medal, Check, Clock, CalendarDays, Tag, XIcon, } from "lucide-react"; import * as LucideIcons 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 { getTierColor } from "@/utils/tierColors"; import { getBundleContent, overlapsExistingAccess, isPlanCurrent } from "@/utils/planBundle.util"; import { TablePagination } from "@/components/generic/Table/TablePagination"; // ─── 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 ──────────────────────────────────────────────────────────────── function ChecklistItem({ item, icon: Icon = Star, onClick }) { return (
{item}
); } // Per-bundle-type icon for "Benefits" rows — distinguishes a course // grant from a unit or lesson grant at a glance instead of a flat checkmark. const BUNDLE_ITEM_ICONS = { course: GraduationCap, unit: Book, lesson: Medal }; const PlanCard = ({ plan, myTier, tierMap, onSelect, onRefund, refundSecsLeft }) => { const { fmtCurrency } = useDateFormat(); const [notAvailableOpen, setNotAvailableOpen] = useState(false); const [previewItem, setPreviewItem] = useState(null); const [viewOpen, setViewOpen] = useState(false); const { label: tierLabel, cls: badgeCls, panel, gradient, colorKey } = resolveTierBadge(plan.tier, tierMap); // Details-dialog-only accent — the swatch hex and tier icon aren't part // of resolveTierBadge's return, mirrors the old ViewPlan page's styling. const TierIcon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag; const accentStyle = { color: getTierColor(colorKey).swatch }; // Admin-controlled — set from the "Recommended for you" toggle on the // plan's Edit Plan page, drives the featured frame treatment below. // Color comes from the plan's own tier category's actual gradient (same // one used for its badge), not a hardcoded blue or a pale flat tint. const isRecommended = !!plan.is_recommended; // "Current" means the user actually purchased THIS exact plan (matched by // plan_id) and it's still active — NOT just any plan sharing the same tier // slug. Two different plans can be the same tier (e.g. two Premium // bundles) and stay independently active (Tier Plans v2). const isCurrent = isPlanCurrent(plan, myTier); // Blocked-by-overlap ("already covered") — the plan isn't itself owned, // but at least one of its bundle items is already granted by something // else the user holds. Card stays visible but disabled (Scenario 3). const isBlockedByOverlap = !isCurrent && overlapsExistingAccess(plan, myTier?.my_grants); const duration = formatDuration(plan.duration_days, plan.duration_unit); const bundle = getBundleContent(plan); const features = plan.features ?? []; const bundleCount = bundle?.items?.length ?? 0; const BundleIcon = bundle?.icon ?? BookOpen; const totalSeconds = (bundle?.items ?? []).reduce((sum, c) => sum + (c.duration_seconds ?? 0), 0); const totalDuration = formatCourseDuration(totalSeconds); // Footer button state — always visible when there's an actual action to // take. A not-yet-owned free plan has nothing to do (it's the default), // so it's the one case with no button. Base color follows the recommended // flag (dark = recommended, light = plain), overridden for refund/blocked. let footerLabel = null; let footerAction = null; let footerDisabled = false; let footerBtnCls = ""; if (isCurrent && refundSecsLeft > 0) { footerLabel = <> Refund ({formatCountdown(refundSecsLeft)}); footerAction = () => onRefund(plan); footerBtnCls = "bg-red-600 hover:bg-red-700 text-white"; } else if (!plan.is_active) { footerLabel = <> Not Available; footerAction = () => setNotAvailableOpen(true); footerDisabled = true; footerBtnCls = "bg-gray-100 text-slate-800 dark:bg-primary dark:text-primary-foreground shadow-none"; } else if (isBlockedByOverlap) { footerLabel = <> Already Included; footerDisabled = true; footerBtnCls = "bg-gray-100 text-slate-400 shadow-none"; } else if (isCurrent && plan.tier !== "free") { footerLabel = <> Extend {tierLabel}; footerAction = () => onSelect(plan); } else if (!isCurrent && plan.tier !== "free") { footerLabel = <> Get {tierLabel}; footerAction = () => onSelect(plan); } return ( <> {/* Actual Card */}
Recommended for you

setViewOpen(true)} > {plan.label}

{plan.description || "No information provided"}

{isBlockedByOverlap && ( Already Included )}

{fmtCurrency(plan.price, plan.currency)}

{duration &&

/ {duration}

}

{bundle ? `Bundle (${bundle.noun}s):` : "Bundle:"}

{bundle ? ( bundle.items.map((item) => ( setPreviewItem(item)} /> )) ) : !plan.description ? (

Access to all free course content.

) : null}
{footerLabel && ( )}
{/* ── Not Available Dialog ──────────────────────────────────────── */} Unavailable

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

{/* ── Plan Details Dialog (replaces the old /subscriptions/view/:id page) ── */}
{tierLabel} {plan.label}

{plan.description || `Everything you need with the ${tierLabel} plan.`}

{fmtCurrency(plan.price, plan.currency)} {duration && / {duration}}
{isCurrent ? ( Your Current Plan ) : !plan.is_active ? ( Not Available ) : isBlockedByOverlap ? ( Already Included ) : null}
{features.length > 0 && (

Benefits

{features.map(({ text }, i) => (
{text}
))}
)}
{[ { label: bundle ? `${bundle.noun}s` : "Courses", value: bundleCount, icon: BundleIcon }, { label: "Content", value: totalDuration ?? "—", icon: Clock }, { label: "Access", value: duration ?? "Lifetime", icon: CalendarDays }, ].map(({ label, value, icon: StatIcon }) => (

{value}

{label}

))}
Bundle
{bundleCount}
{bundle ? ( bundle.items.map((item) => (

{item.title}

{item.level && ( {item.level} )} {formatCourseDuration(item.duration_seconds) && ( {formatCourseDuration(item.duration_seconds)} )}
)) ) : (

Content coming soon

We're curating the best content for this plan.

)}
{footerLabel && ( )}
{/* ── Bundle Item Preview ─────────────────────────────────────────── */} !v && setPreviewItem(null)} title={previewItem?.title} description={`Upgrade your plan to access this ${bundle?.noun?.toLowerCase() ?? "item"}.`} >
{tierLabel}
  • Access to {tierLabel} content
  • Certificates & achievements

Upgrade to a {tierLabel} plan to unlock this {bundle?.noun?.toLowerCase() ?? "item"}.

); }; // ─── Page ───────────────────────────────────────────────────────────────────── export default function PlanList() { const navigate = useNavigate(); const { plans, plansLoading, myTier, tierLoading, tierMap, getPlans, getMyTier, getTierCategories, resetMyTier } = useClientTiers(); const { fmtDate, fmtCurrency } = useDateFormat(); const { adLists, listLoading: adLoading, getActiveAdvertisementList, handleAdCtaClick } = useClientAdvertisements(); // Plans are fetched all at once — pagination here just slices the // already-loaded list client-side, it doesn't refetch. const [plansPage, setPlansPage] = useState(1); const [plansLimit, setPlansLimit] = useState(10); const [refundPlan, setRefundPlan] = useState(null); // plan being refunded const [refundLoading, setRefundLoading] = useState(false); // Keyed by plan_id, not tier slug — two different plans can share a tier // (Tier Plans v2) and each has its own independent refund window based on // its own starts_at. const [refundSecsLeftByTier, setRefundSecsLeftByTier] = useState({}); const refundTimerRef = useRef(null); useEffect(() => { getPlans(); getMyTier(); getTierCategories(); getActiveAdvertisementList("tier_plans.banner"); // eslint-disable-next-line react-hooks/exhaustive-deps }, [getPlans, getMyTier]); const bannerAds = adLists["tier_plans.banner"] ?? []; useEffect(() => { clearInterval(refundTimerRef.current); const activeTiers = myTier?.active_tiers ?? []; if (!activeTiers.length) { setRefundSecsLeftByTier({}); return; } const compute = () => { const map = {}; for (const t of activeTiers) { if (!t.starts_at || t.plan_id == null) continue; const elapsed = Math.floor((Date.now() - new Date(t.starts_at).getTime()) / 1000); map[t.plan_id] = Math.max(0, REFUND_WINDOW_SECS - elapsed); } return map; }; setRefundSecsLeftByTier(compute()); refundTimerRef.current = setInterval(() => { setRefundSecsLeftByTier(compute()); }, 1000); return () => clearInterval(refundTimerRef.current); }, [myTier?.active_tiers]); const handleSelectPlan = (plan) => navigate(`/subscriptions/checkout?plan_id=${plan.plan_id}`); const handleRefundClick = (plan) => setRefundPlan(plan); // The plan being refunded may not be the user's "best" tier (myTier), since // more than one can be active at once — resolve its own record by plan_id // (not tier slug, since two plans can share a tier) for its own // expires_at/refund window instead of assuming it matches myTier. const refundPlanTier = (myTier?.active_tiers ?? []).find((t) => t.plan_id != null && String(t.plan_id) === String(refundPlan?.plan_id)) ?? null; const refundPlanSecsLeft = refundSecsLeftByTier[refundPlan?.plan_id] ?? 0; const isOnlyActiveTier = (myTier?.active_tiers ?? []).length <= 1; const plansTotalPages = Math.max(1, Math.ceil(plans.length / plansLimit)); const pagedPlans = plans.slice((plansPage - 1) * plansLimit, plansPage * plansLimit); const plansPagination = { page: plansPage, limit: plansLimit, totalPages: plansTotalPages, totalRecords: plans.length, hasPrevPage: plansPage > 1, hasNextPage: plansPage < plansTotalPages, }; const handlePlansPageSize = (size) => { setPlansLimit(size); setPlansPage(1); }; const handleConfirmRefund = async () => { setRefundLoading(true); try { // plan_id disambiguates which active subscription to refund now that a // user can hold more than one concurrently. const { data } = await api.post("/client/tiers/checkout/refund", { plan_id: refundPlan?.plan_id }); 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 Subscriptions

Choose a subscription that matches your goals.

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

No plans available at the moment.

) : ( <>
{pagedPlans.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) : "—"}
{refundPlanTier?.expires_at && (
Access until {fmtDate(refundPlanTier.expires_at)}
)}
Refund window {refundPlanSecsLeft > 0 ? ( {formatCountdown(refundPlanSecsLeft)} remaining ) : ( Expired )}
{refundPlanSecsLeft > 0 ? (

Your refund will be processed through PayPal.{" "} Access will be revoked immediately {" "} {isOnlyActiveTier ? "and your account will be downgraded to Free." : "Your other active plan(s) will be unaffected."}

) : (

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

)}
); }