From 81749873825e712d27c34f191be5a1be6fa02e9b Mon Sep 17 00:00:00 2001 From: rgrgogu Date: Fri, 14 Aug 2026 02:21:27 +0800 Subject: [PATCH] commit subscriptions Signed-off-by: rgrgogu --- src/modules/admin/pages/tiers/AddPlan.jsx | 4 +- src/modules/admin/pages/tiers/EditPlan.jsx | 15 +- src/modules/admin/pages/tiers/ViewPlan.jsx | 5 + src/modules/client/pages/PlanList.jsx | 342 ++++++++++++--------- src/modules/client/pages/ViewPlan.jsx | 4 +- src/utils/table.util.jsx | 1 + src/utils/tierBadge.util.js | 6 +- 7 files changed, 233 insertions(+), 144 deletions(-) diff --git a/src/modules/admin/pages/tiers/AddPlan.jsx b/src/modules/admin/pages/tiers/AddPlan.jsx index c33f872..99d0fb8 100644 --- a/src/modules/admin/pages/tiers/AddPlan.jsx +++ b/src/modules/admin/pages/tiers/AddPlan.jsx @@ -465,7 +465,7 @@ export default function AddPlan() {
- +

Bullet points shown on the plans page and the comparison table.

@@ -593,7 +593,7 @@ export default function AddPlan() { {featureFields.length > 0 && (
- +
    {watch("features")?.map((f, i) => (
  • {f.text}
  • diff --git a/src/modules/admin/pages/tiers/EditPlan.jsx b/src/modules/admin/pages/tiers/EditPlan.jsx index 5f2de28..eed4e0c 100644 --- a/src/modules/admin/pages/tiers/EditPlan.jsx +++ b/src/modules/admin/pages/tiers/EditPlan.jsx @@ -62,6 +62,7 @@ const schema = z.object({ price: z.coerce.number().min(0.01, "Price must be greater than 0."), currency: z.string().length(3), is_active: z.boolean().default(true), + is_recommended: z.boolean().default(false), status: z.enum(["draft", "published"]).default("draft"), }).superRefine(({ duration_value, duration_unit }, ctx) => { const rule = DURATION_UNIT_LIMITS[duration_unit]; @@ -150,6 +151,7 @@ export default function EditPlan() { price: plan.price, currency: plan.currency, is_active: plan.is_active, + is_recommended: plan.is_recommended ?? false, status: plan.status ?? "draft", }); } @@ -317,7 +319,7 @@ export default function EditPlan() {
- +

Bullet points shown on the plans page and the comparison table.

@@ -427,6 +429,17 @@ export default function EditPlan() { onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })} />
+ +
+
+

Recommended for you

+

Highlights this plan with a "Recommended for you" banner on the client Plans page.

+
+ setValue("is_recommended", v, { shouldDirty: true })} + /> +
{plan?.tier && ( diff --git a/src/modules/admin/pages/tiers/ViewPlan.jsx b/src/modules/admin/pages/tiers/ViewPlan.jsx index c9d5a58..ae15164 100644 --- a/src/modules/admin/pages/tiers/ViewPlan.jsx +++ b/src/modules/admin/pages/tiers/ViewPlan.jsx @@ -111,6 +111,11 @@ function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoadin {plan.status ?? "draft"} + + + {plan.is_recommended ? "Yes" : "No"} + + {plan.is_active ? "Active" : "Inactive"} diff --git a/src/modules/client/pages/PlanList.jsx b/src/modules/client/pages/PlanList.jsx index b9b6803..78d3bb7 100644 --- a/src/modules/client/pages/PlanList.jsx +++ b/src/modules/client/pages/PlanList.jsx @@ -1,21 +1,19 @@ 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, Lock, - Tag, RotateCcw, Plus, + BookOpen, Star, Lock, Sparkles, + RotateCcw, Plus, Ban, BadgeCheck, + GraduationCap, Book, Medal, } from "lucide-react"; import { useClientTiers } from "@/contexts/ClientTiersProvider"; import ResponsiveModal from "@/components/generic/ResponsiveModal"; @@ -27,6 +25,7 @@ import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext"; import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner"; import { resolveTierBadge } from "@/utils/tierBadge.util"; import { getBundleContent, overlapsExistingAccess, isPlanCurrent } from "@/utils/planBundle.util"; +import { TablePagination } from "@/components/generic/Table/TablePagination"; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -47,15 +46,6 @@ 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 ────────────────────────────────────────────────────────────────── @@ -81,14 +71,32 @@ const PlanSkeleton = () => ( // ─── Plan Card ──────────────────────────────────────────────────────────────── -const PREVIEW_COURSE_LIMIT = 2; +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, onView, onRefund, refundSecsLeft }) => { const { fmtCurrency } = useDateFormat(); - const [coursesOpen, setCoursesOpen] = useState(false); const [notAvailableOpen, setNotAvailableOpen] = useState(false); - const { label: tierLabel, cls: badgeCls } = resolveTierBadge(plan.tier, tierMap); - const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag; + const [previewItem, setPreviewItem] = useState(null); + const { label: tierLabel, cls: badgeCls, panel, gradient } = resolveTierBadge(plan.tier, tierMap); + // 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 @@ -99,18 +107,121 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec // 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 features = plan.features ?? []; const bundle = getBundleContent(plan); - const previewItems = bundle?.items?.slice(0, PREVIEW_COURSE_LIMIT) ?? []; - const extraCount = (bundle?.items?.length ?? 0) - PREVIEW_COURSE_LIMIT; - const hasFooterAction = (isCurrent && refundSecsLeft > 0) || !plan.is_active || isBlockedByOverlap || (isCurrent && plan.tier !== "free"); + // 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 ( <> -
- askodasodkas + {/* Actual Card */} +
+
+ Recommended for you +
+ +
+ +
+

onView(plan)} + > + {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 ──────────────────────────────────────── */} @@ -129,104 +240,30 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec - {/* ── Plan Detail Dialog ─────────────────────────────────────────── */} - {/* Current disabled from line 170 to 172 */} - - - -
- {plan.label} - - - {tierLabel} + {/* ── Bundle Item Preview ─────────────────────────────────────────── */} + !v && setPreviewItem(null)} + title={previewItem?.title} + description={`Upgrade your plan to access this ${bundle?.noun?.toLowerCase() ?? "item"}.`} + > +
+
+
+ + {tierLabel}
- - - {/* Price + Duration */} -
- - {fmtCurrency(plan.price, plan.currency)} - - {duration && ( - / {duration} - )} -
- - {/* Features */} - {features.length > 0 && ( -
    - {features.map((f, i) => ( -
  • - - {f.text} -
  • - ))} +
      +
    • Access to {tierLabel} content
    • +
    • Certificates & achievements
    - )} - - {/* Description */} - {plan.description && ( -

    - {plan.description} +

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

    - )} - - - - {/* Bundle items horizontal scroll */} - {bundle && ( -
    -

    - - Bundle -

    - -
    - {bundle.items.map((item) => ( -
    -
    - -

    - {item.title} -

    -
    -
    - {item.level && ( - - {item.level} - - )} - {formatCourseDuration(item.duration_seconds) && ( - - - {formatCourseDuration(item.duration_seconds)} - - )} -
    -
    - ))} -
    - -
    -
    - )} - - {!isCurrent && !isBlockedByOverlap && plan.tier !== "free" && ( - - - - )} - -
+
+
+ ); }; @@ -239,6 +276,11 @@ export default function PlanList() { 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 @@ -293,6 +335,21 @@ export default function PlanList() { 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 { @@ -317,18 +374,16 @@ export default function PlanList() {
{/* Advertisement Banner */} - {adLoading["tier_plans.banner"] ? ( + {/* {adLoading["tier_plans.banner"] ? ( ) : ( - )} + )} */} + + {/* Section Header */}
- - - -

Available Plans

@@ -348,20 +403,31 @@ export default function PlanList() {

No plans available at the moment.

) : ( -
- {plans.map((plan) => ( - - ))} -
+ <> +
+ {pagedPlans.map((plan) => ( + + ))} +
+ + + )}
diff --git a/src/modules/client/pages/ViewPlan.jsx b/src/modules/client/pages/ViewPlan.jsx index 60ce37c..3a3ffc0 100644 --- a/src/modules/client/pages/ViewPlan.jsx +++ b/src/modules/client/pages/ViewPlan.jsx @@ -157,11 +157,11 @@ const ViewPlan = () => {
- {/* ── What's included ───────────────────────────────────── */} + {/* ── Benefits ───────────────────────────────────── */} {features.length > 0 && (

- What's included + Benefits

{features.map(({ text }, i) => ( diff --git a/src/utils/table.util.jsx b/src/utils/table.util.jsx index 1527dac..95b8f63 100644 --- a/src/utils/table.util.jsx +++ b/src/utils/table.util.jsx @@ -119,6 +119,7 @@ export const BOOLEAN_FIELD_LABELS = { is_banned: ["Not Banned", "Banned"], is_required: ["No", "Yes"], accepts_submissions: ["No", "Yes"], + is_recommended: ["No", "Yes"], }; function renderCell(attr, value) { diff --git a/src/utils/tierBadge.util.js b/src/utils/tierBadge.util.js index ff64d70..ba99225 100644 --- a/src/utils/tierBadge.util.js +++ b/src/utils/tierBadge.util.js @@ -10,7 +10,11 @@ export function resolveTierBadge(slug, tierMap = {}) { const colors = getTierColor(colorKey); const rank = info?.rank ?? 0; const label = info?.name ?? (slug ? slug.charAt(0).toUpperCase() + slug.slice(1) : 'Free'); - return { rank, label, cls: colors.badge, panel: colors.panel, colorKey }; + // Just the "bg-gradient-to-r from-X to-Y" portion of the badge class — + // for surfaces that need the tier's actual gradient (not the pale panel + // tint) but supply their own text/border handling. + const gradient = colors.badge.replace(/\s*text-white\s*border-0\s*$/, ''); + return { rank, label, cls: colors.badge, panel: colors.panel, gradient, colorKey }; } /** Returns badge Tailwind class string for a stored color key. */