);
}
// 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 */}
{/* ── Not Available Dialog ──────────────────────────────────────── */}
{/* ── Plan Details Dialog (replaces the old /subscriptions/view/:id page) ── */}
{/* ── 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 (
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.