@@ -74,7 +76,9 @@ export default function PlanComparisonTable({ plans, myTier, tierMap, fmtCurrenc
{plans.map((plan) => {
- const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
+ // A user can hold more than one active tier concurrently — check
+ // membership in the full active set, not just the best one.
+ const isCurrent = (myTier?.active_tiers ?? []).some((t) => t.tier === plan.tier);
return (
+
+
+ Your active subscriptions
+
+ You currently hold more than one active plan at the same time.
+
+
+
+
+ {tiers.map((t) => {
+ const badge = resolveTierBadge(t.tier, tierMap)
+ const TierIcon = LucideIcons[tierMap[t.tier]?.badge_icon] ?? null
+ return (
+
+
+ {TierIcon && }
+ {badge.label}
+
+
+ {t.expires_at ? `Until ${fmtDate(t.expires_at)}` : "No expiry"}
+
+
+ )
+ })}
+
+
+
+ )
+}
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getInitials(name = "") {
@@ -148,6 +190,7 @@ function ClientNav() {
const { myTier, tierMap, tierCategories, getMyTier, getTierCategories } = useClientTiers()
const [referOpen, setReferOpen] = useState(false)
+ const [extraTiersOpen, setExtraTiersOpen] = useState(false)
const [badgeImgUrl, setBadgeImgUrl] = useState(null)
// Caches the resolved stream URL per asset_id — avoids re-hitting /media/token
// for the same asset on re-renders. Token TTL is 4h so safe within a session.
@@ -181,6 +224,11 @@ function ClientNav() {
const tierBadge = resolveTierBadge(tierSlug, tierMap)
const TierIcon = LucideIcons[tierMap[tierSlug]?.badge_icon] ?? null
+ // A user can hold more than one active tier concurrently (e.g. premium +
+ // exclusive bought separately) — the pill above only ever shows the best
+ // one, so surface the rest as a "+N" trigger next to it.
+ const extraActiveTiers = (myTier?.active_tiers ?? []).filter((t) => t.tier !== tierSlug)
+
// Derive primitive deps — effect only fires when the actual asset changes,
// not on every tierMap/tierSlug reference churn.
const badgeAsset = tierMap[tierSlug]?.badgeAsset ?? null
@@ -261,12 +309,23 @@ function ClientNav() {
Loading
) : tierBadge && (
-
- {badgeImgUrl
- ?
- : TierIcon &&
- }
- {tierBadge.label}
+
+
+ {badgeImgUrl
+ ?
+ : TierIcon &&
+ }
+ {tierBadge.label}
+
+ {extraActiveTiers.length > 0 && (
+
setExtraTiersOpen(true)}
+ className="inline-flex items-center justify-center px-1.5 py-0.5 rounded-full text-xs font-semibold bg-muted text-muted-foreground hover:bg-muted-foreground/20 transition-colors"
+ >
+ +{extraActiveTiers.length}
+
+ )}
)}
@@ -344,6 +403,12 @@ function ClientNav() {
+
>
)
}
diff --git a/src/modules/client/pages/Checkout.jsx b/src/modules/client/pages/Checkout.jsx
index 5224868..0e60835 100644
--- a/src/modules/client/pages/Checkout.jsx
+++ b/src/modules/client/pages/Checkout.jsx
@@ -132,7 +132,9 @@ const Checkout = () => {
const email = user?.email ?? "";
const isLoading = plansLoading || tierLoading;
- const isCurrent = plan && myTier?.tier === plan.tier && myTier?.status === "active";
+ // A user can hold more than one active tier concurrently — check membership
+ // in the full active set, not just the best one.
+ const isCurrent = !!plan && (myTier?.active_tiers ?? []).some((t) => t.tier === plan.tier);
const style = TIER_STYLES[plan?.tier] ?? TIER_STYLES.free;
const Icon = style.icon;
const effectivePrice = plan ? Number(plan.price) : 0;
diff --git a/src/modules/client/pages/PlanList.jsx b/src/modules/client/pages/PlanList.jsx
index b4cf78f..528694a 100644
--- a/src/modules/client/pages/PlanList.jsx
+++ b/src/modules/client/pages/PlanList.jsx
@@ -89,7 +89,9 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
const { label: tierLabel, cls: badgeCls, rank } = resolveTierBadge(plan.tier, tierMap);
const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag;
const ring = rank > 0 ? "ring-2 ring-primary/30" : "";
- const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
+ // A user can hold more than one active tier concurrently (e.g. premium +
+ // exclusive) — check membership in the full active set, not just the best one.
+ const isCurrent = (myTier?.active_tiers ?? []).some((t) => t.tier === plan.tier);
const duration = formatDuration(plan.duration_days, plan.duration_unit);
const features = plan.features ?? [];
const previewCourses = plan.courses?.slice(0, PREVIEW_COURSE_LIMIT) ?? [];
@@ -349,7 +351,9 @@ export default function PlanList() {
const [view, setView] = useState("grid");
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
const [refundLoading, setRefundLoading] = useState(false);
- const [refundSecsLeft, setRefundSecsLeft] = useState(0);
+ // Keyed by tier slug — a user can hold more than one active tier concurrently,
+ // and each one has its own independent refund window based on its own starts_at.
+ const [refundSecsLeftByTier, setRefundSecsLeftByTier] = useState({});
const refundTimerRef = useRef(null);
useEffect(() => {
@@ -364,19 +368,25 @@ export default function PlanList() {
useEffect(() => {
clearInterval(refundTimerRef.current);
- if (!myTier?.starts_at) { setRefundSecsLeft(0); return; }
+ const activeTiers = myTier?.active_tiers ?? [];
+ if (!activeTiers.length) { setRefundSecsLeftByTier({}); return; }
+
const compute = () => {
- const elapsed = Math.floor((Date.now() - new Date(myTier.starts_at).getTime()) / 1000);
- return Math.max(0, REFUND_WINDOW_SECS - elapsed);
+ const map = {};
+ for (const t of activeTiers) {
+ if (!t.starts_at) continue;
+ const elapsed = Math.floor((Date.now() - new Date(t.starts_at).getTime()) / 1000);
+ map[t.tier] = Math.max(0, REFUND_WINDOW_SECS - elapsed);
+ }
+ return map;
};
- setRefundSecsLeft(compute());
+
+ setRefundSecsLeftByTier(compute());
refundTimerRef.current = setInterval(() => {
- const left = compute();
- setRefundSecsLeft(left);
- if (left === 0) clearInterval(refundTimerRef.current);
+ setRefundSecsLeftByTier(compute());
}, 1000);
return () => clearInterval(refundTimerRef.current);
- }, [myTier?.starts_at]);
+ }, [myTier?.active_tiers]);
const handleViewPlan = (plan) => navigate(`/plans/view/${plan.plan_id}`);
@@ -384,10 +394,19 @@ export default function PlanList() {
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 for its own
+ // expires_at/refund window instead of assuming it matches myTier.
+ const refundPlanTier = (myTier?.active_tiers ?? []).find((t) => t.tier === refundPlan?.tier) ?? null;
+ const refundPlanSecsLeft = refundSecsLeftByTier[refundPlan?.tier] ?? 0;
+ const isOnlyActiveTier = (myTier?.active_tiers ?? []).length <= 1;
+
const handleConfirmRefund = async () => {
setRefundLoading(true);
try {
- const { data } = await api.post("/client/tiers/checkout/refund");
+ // 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();
@@ -461,7 +480,7 @@ export default function PlanList() {
onSelect={handleSelectPlan}
onView={handleViewPlan}
onRefund={handleRefundClick}
- refundSecsLeft={refundSecsLeft}
+ refundSecsLeft={refundSecsLeftByTier[plan.tier] ?? 0}
/>
))}
@@ -490,7 +509,7 @@ export default function PlanList() {
{refundLoading ? "Processing..." : "Confirm Refund"}
@@ -510,32 +529,34 @@ export default function PlanList() {
{refundPlan ? fmtCurrency(refundPlan.price, refundPlan.currency) : "—"}
- {myTier?.expires_at && (
+ {refundPlanTier?.expires_at && (
Access until
- {fmtDate(myTier.expires_at)}
+ {fmtDate(refundPlanTier.expires_at)}
)}
Refund window
- {refundSecsLeft > 0 ? (
+ {refundPlanSecsLeft > 0 ? (
- {formatCountdown(refundSecsLeft)} remaining
+ {formatCountdown(refundPlanSecsLeft)} remaining
) : (
Expired
)}
- {refundSecsLeft > 0 ? (
+ {refundPlanSecsLeft > 0 ? (
Your refund will be processed through PayPal.{" "}
Access will be revoked immediately
{" "}
- and your account will be downgraded to Free.
+ {isOnlyActiveTier
+ ? "and your account will be downgraded to Free."
+ : "Your other active plan(s) will be unaffected."}
) : (
diff --git a/src/modules/client/pages/ViewPlan.jsx b/src/modules/client/pages/ViewPlan.jsx
index 8e787d1..0c2a54a 100644
--- a/src/modules/client/pages/ViewPlan.jsx
+++ b/src/modules/client/pages/ViewPlan.jsx
@@ -80,7 +80,9 @@ const ViewPlan = () => {
const accentBorder = colors.panel.border;
const features = plan.features ?? [];
const duration = formatDuration(plan.duration_days, plan.duration_unit);
- const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
+ // A user can hold more than one active tier concurrently — check membership
+ // in the full active set, not just the best one.
+ const isCurrent = (myTier?.active_tiers ?? []).some((t) => t.tier === plan.tier);
const totalSeconds = (plan.courses ?? []).reduce((sum, c) => sum + (c.duration_seconds ?? 0), 0);
const totalDuration = formatCourseDuration(totalSeconds);