diff --git a/apps/web/src/components/generic/notificationDisplay.jsx b/apps/web/src/components/generic/notificationDisplay.jsx
index a5b30a4..b5b661a 100644
--- a/apps/web/src/components/generic/notificationDisplay.jsx
+++ b/apps/web/src/components/generic/notificationDisplay.jsx
@@ -123,9 +123,7 @@ export function resolveNotificationLink(type, data) {
: null;
case "tier_expired":
- return data.planId
- ? { label: "Renew plan", go: (navigate) => navigate(`/subscriptions/view/${data.planId}`) }
- : { label: "View plans", go: (navigate) => navigate("/subscriptions") };
+ return { label: "View plans", go: (navigate) => navigate("/subscriptions") };
case "announcement":
if (data.groupId && data.groupCode) {
@@ -135,7 +133,7 @@ export function resolveNotificationLink(type, data) {
return { label: "Go to course", go: (navigate) => goToCourse(navigate, data.targetId) };
}
if (data.targetType === "tier_plan" && data.targetId) {
- return { label: "View plan", go: (navigate) => navigate(`/subscriptions/view/${data.targetId}`) };
+ return { label: "View plan", go: (navigate) => navigate("/subscriptions") };
}
if (data.targetType === "task_list" && data.groupId && data.targetId) {
return { label: "View task list", go: (navigate) => navigate(`/group/${data.groupId}/view/${data.targetId}`) };
diff --git a/apps/web/src/modules/client/pages/PlanList.jsx b/apps/web/src/modules/client/pages/PlanList.jsx
index ab2dd7a..e2b837b 100644
--- a/apps/web/src/modules/client/pages/PlanList.jsx
+++ b/apps/web/src/modules/client/pages/PlanList.jsx
@@ -8,13 +8,15 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
- Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
+ 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";
@@ -24,6 +26,7 @@ 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";
@@ -46,6 +49,15 @@ function formatDuration(days, unit) {
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 ──────────────────────────────────────────────────────────────────
@@ -87,11 +99,16 @@ function ChecklistItem({ item, icon: Icon = Star, onClick }) {
// 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, onView, onRefund, refundSecsLeft }) => {
+const PlanCard = ({ plan, myTier, tierMap, onSelect, onRefund, refundSecsLeft }) => {
const { fmtCurrency } = useDateFormat();
const [notAvailableOpen, setNotAvailableOpen] = useState(false);
const [previewItem, setPreviewItem] = useState(null);
- const { label: tierLabel, cls: badgeCls, panel, gradient } = resolveTierBadge(plan.tier, tierMap);
+ 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
@@ -108,6 +125,11 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
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
@@ -157,7 +179,7 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
onView(plan)}
+ onClick={() => setViewOpen(true)}
>
{plan.label}
@@ -240,6 +262,155 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
+ {/* ── Plan Details Dialog (replaces the old /subscriptions/view/:id page) ── */}
+
+
+
+
+
+
+
+ Close
+
+
+
+
+ {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) => (
+
+ ))}
+
+
+ )}
+
+
+ {[
+ { 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 }) => (
+
+ ))}
+
+
+
+
+
+
+ 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 && (
+
+ { setViewOpen(false); footerAction?.(); }}
+ >
+ {footerLabel}
+
+
+ )}
+
+
+
{/* ── Bundle Item Preview ─────────────────────────────────────────── */}
clearInterval(refundTimerRef.current);
}, [myTier?.active_tiers]);
- const handleViewPlan = (plan) => navigate(`/subscriptions/view/${plan.plan_id}`);
-
const handleSelectPlan = (plan) => navigate(`/subscriptions/checkout?plan_id=${plan.plan_id}`);
const handleRefundClick = (plan) => setRefundPlan(plan);
@@ -412,7 +581,6 @@ export default function PlanList() {
myTier={myTier}
tierMap={tierMap}
onSelect={handleSelectPlan}
- onView={handleViewPlan}
onRefund={handleRefundClick}
refundSecsLeft={refundSecsLeftByTier[plan.plan_id] ?? 0}
/>
diff --git a/apps/web/src/modules/client/pages/ViewPlan.jsx b/apps/web/src/modules/client/pages/ViewPlan.jsx
deleted file mode 100644
index 6bdbc91..0000000
--- a/apps/web/src/modules/client/pages/ViewPlan.jsx
+++ /dev/null
@@ -1,287 +0,0 @@
-import { useEffect } from "react";
-import { useNavigate, useParams } from "react-router-dom";
-import * as LucideIcons from "lucide-react";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import { Skeleton } from "@/components/ui/skeleton";
-import {
- ArrowLeft, BookOpen, Clock, Check, Lock,
- Tag, CalendarDays,
-} from "lucide-react";
-import { useClientTiers } from "@/contexts/ClientTiersProvider";
-import { useProfile } from "@/contexts/ProfileProvider";
-import { PageMeta } from "@/contexts/MetadataContext";
-import { useDateFormat } from "@/hooks/useDateFormat";
-import { resolveTierBadge } from "@/utils/tierBadge.util";
-import { getTierColor } from "@/utils/tierColors";
-import { getBundleContent, overlapsExistingAccess, isPlanCurrent } from "@/utils/planBundle.util";
-
-// ─── Helpers ──────────────────────────────────────────────────────────────────
-
-const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
-
-function formatDuration(days, unit) {
- if (!days) return null;
- 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 ViewPlanSkeleton = () => (
-
-);
-
-// ─── Component ────────────────────────────────────────────────────────────────
-
-const ViewPlan = () => {
- const { id } = useParams();
- const navigate = useNavigate();
- const { myTier, getMyTier, plans, plansLoading, getPlans, tierMap, getTierCategories } = useClientTiers();
- const { getProfile } = useProfile();
- const { fmtCurrency } = useDateFormat();
-
- useEffect(() => {
- getProfile();
- getMyTier();
- getTierCategories();
- if (!plans.length) getPlans();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [id]);
-
- const plan = plans.find((p) => String(p.plan_id) === String(id)) ?? null;
- const loading = plansLoading;
-
- if (loading) return ;
- if (!plan) return null;
-
- const { label: tierLabel, cls: badgeCls } = resolveTierBadge(plan.tier, tierMap);
- const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag;
- const colors = getTierColor(tierMap[plan.tier]?.color ?? "purple");
- const accentSwatch = colors.swatch;
- const accentStyle = { color: accentSwatch };
- const accentBg = colors.panel.bg;
- const accentBorder = colors.panel.border;
- const features = plan.features ?? [];
- const duration = formatDuration(plan.duration_days, plan.duration_unit);
- // "Current" means the user actually purchased THIS exact plan (matched by
- // plan_id), not just any plan sharing the same tier slug — two different
- // plans can share a tier and stay independently active (Tier Plans v2).
- const isCurrent = isPlanCurrent(plan, myTier);
- // Blocked-by-overlap ("already covered") — plan isn't owned, but at least
- // one of its bundle items is already granted by something else the user
- // holds (Scenario 3): shown, not hidden, but purchase is disabled.
- const isBlockedByOverlap = !isCurrent && overlapsExistingAccess(plan, myTier?.my_grants);
-
- const bundle = getBundleContent(plan);
- 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);
-
- return (
-
-
-
- {/* ── Hero Banner ───────────────────────────────────────────── */}
-
- {/* Decorative blobs */}
-
-
-
-
navigate("/subscriptions")}
- className="flex items-center gap-1.5 text-white/70 hover:text-white text-sm mb-8 transition-colors"
- >
- Back to Subscriptions
-
-
-
- {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 at the Moment
-
- ) : isBlockedByOverlap ? (
-
- Already Included in Your Plan
-
- ) : null}
-
-
-
-
-
-
- {/* ── Benefits ───────────────────────────────────── */}
- {features.length > 0 && (
-
-
- Benefits
-
-
- {features.map(({ text }, i) => (
-
- ))}
-
-
- )}
-
- {/* ── Stats row ─────────────────────────────────────────── */}
-
- {[
- { 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 }) => (
-
- ))}
-
-
- {/* ── Included Items ─────────────────────────────────────── */}
-
-
-
-
- Bundle
-
-
{bundleCount}
-
-
-
- {bundle ? (
- bundle.items.map((item) => (
-
-
-
-
-
-
{item.title}
-
- {item.level && (
-
- {item.level}
-
- )}
- {formatCourseDuration(item.duration_seconds) && (
-
-
- {formatCourseDuration(item.duration_seconds)}
-
- )}
- {item.course_code && (
- {item.course_code}
- )}
-
-
-
-
-
-
- ))
- ) : (
-
-
-
-
-
Content coming soon
-
- We're curating the best content for this plan. Subscribe now and get instant access the moment they go live.
-
-
- )}
-
-
-
- {/* ── Bottom CTA ────────────────────────────────────────── */}
- {!isCurrent && (
-
-
-
-
- {isBlockedByOverlap ? "Already Included" : plan.is_active ? "Ready to get started?" : "Coming Soon"}
-
-
- {isBlockedByOverlap
- ? "Your existing access already covers item(s) in this bundle, so it can't be purchased separately."
- : plan.is_active
- ? (plan.description || `Everything you need with the ${tierLabel} plan.`)
- : "This plan is not available for purchase at the moment. Check back later."}
-
-
- {plan.is_active && !isBlockedByOverlap && (
- navigate(`/subscriptions/checkout?plan_id=${plan.plan_id}`)}
- >
- Get {tierLabel} Plan — {fmtCurrency(plan.price, plan.currency)}
-
- )}
-
-
-
- )}
-
-
-
- );
-};
-
-export default ViewPlan;
diff --git a/apps/web/src/modules/client/routes/ClientRoutes.jsx b/apps/web/src/modules/client/routes/ClientRoutes.jsx
index 3d4c0c0..ed0f6f4 100644
--- a/apps/web/src/modules/client/routes/ClientRoutes.jsx
+++ b/apps/web/src/modules/client/routes/ClientRoutes.jsx
@@ -18,7 +18,6 @@ import ProfilePage from '../pages/Profile'
import Checkout from '../pages/Checkout'
import EditProfile from '../pages/EditProfile'
import PlanList from '../pages/PlanList'
-import ViewPlan from '../pages/ViewPlan'
import ViewTask from '../pages/ViewTask'
import CourseCheckout from '../pages/CourseCheckout'
import MyCertificates from '../pages/MyCertificates'
@@ -75,7 +74,6 @@ export const ClientRoutes = {
children: [
// { index: true, element: },
{ index: true, element: },
- { path: 'view/:id', element: },
{ path: 'checkout', element: },
],
},
diff --git a/apps/web/src/utils/link.util.js b/apps/web/src/utils/link.util.js
index 5ee2a42..25a6fc5 100644
--- a/apps/web/src/utils/link.util.js
+++ b/apps/web/src/utils/link.util.js
@@ -18,7 +18,6 @@ export const CLIENT_ROUTE_PATHS = [
"notifications",
"ads/:uuid",
"subscriptions",
- "subscriptions/view/:id",
"subscriptions/checkout",
"course",
"course/:id",