From 7413a296fbb74df243a0a446a62053d2e2c4c40a Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Mon, 31 Aug 2026 17:14:13 +0800 Subject: [PATCH] replace view plan to dialog - client side Signed-off-by: Kenneth Obsequio --- .../generic/notificationDisplay.jsx | 6 +- .../web/src/modules/client/pages/PlanList.jsx | 182 ++++++++++- .../web/src/modules/client/pages/ViewPlan.jsx | 287 ------------------ .../modules/client/routes/ClientRoutes.jsx | 2 - apps/web/src/utils/link.util.js | 1 - 5 files changed, 177 insertions(+), 301 deletions(-) delete mode 100644 apps/web/src/modules/client/pages/ViewPlan.jsx 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) ── */} + + +
+
+
+
+
+ + + +
+ + {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 ─────────────────────────────────────────── */} 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 */} -
-
-
-
-
- -
- - - - {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) => ( -
-
- -
- {text} -
- ))} -
-
- )} - - {/* ── 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 }) => ( -
- -

{value}

-

{label}

-
- ))} -
- - {/* ── 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 && ( - - )} -
-
-
- )} - -
-
- ); -}; - -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",