future tier plans to payment

This commit is contained in:
rgrgogu
2026-07-31 02:27:17 +08:00
parent cf6e6fc54c
commit 44d1fd2931
5 changed files with 123 additions and 29 deletions
@@ -23,7 +23,9 @@ export default function PlanComparisonTable({ plans, myTier, tierMap, fmtCurrenc
{plans.map((plan) => { {plans.map((plan) => {
const { label, cls } = resolveTierBadge(plan.tier, tierMap); const { label, cls } = resolveTierBadge(plan.tier, tierMap);
const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag; const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag;
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 ( return (
<th key={plan.plan_id} className="p-4 text-center align-bottom min-w-[160px]"> <th key={plan.plan_id} className="p-4 text-center align-bottom min-w-[160px]">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
@@ -74,7 +76,9 @@ export default function PlanComparisonTable({ plans, myTier, tierMap, fmtCurrenc
<tr> <tr>
<td className="p-4" /> <td className="p-4" />
{plans.map((plan) => { {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 ( return (
<td key={plan.plan_id} className="p-4 text-center"> <td key={plan.plan_id} className="p-4 text-center">
<Button <Button
+66 -1
View File
@@ -14,6 +14,7 @@ import {
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogDescription,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { import {
User, Settings, LogOut, SquareArrowOutUpRight, TableOfContents, User, Settings, LogOut, SquareArrowOutUpRight, TableOfContents,
@@ -31,6 +32,7 @@ import { useClientTiers } from "@/contexts/ClientTiersProvider"
import { resolveTierBadge } from "@/utils/tierBadge.util" import { resolveTierBadge } from "@/utils/tierBadge.util"
import { useGroup } from "@/contexts/ClientGroupContext" import { useGroup } from "@/contexts/ClientGroupContext"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useDateFormat } from "@/hooks/useDateFormat"
import { AVATAR_COLORS } from "@/data/profile.data" import { AVATAR_COLORS } from "@/data/profile.data"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import ClientNotificationBell from "@/components/generic/ClientNotificationBell" import ClientNotificationBell from "@/components/generic/ClientNotificationBell"
@@ -131,6 +133,46 @@ function ReferDialog({ open, onOpenChange }) {
) )
} }
// ─── Extra active tiers dialog (stacked-tier "+N" badge) ──────────────────────
function ExtraTiersDialog({ open, onOpenChange, tiers, tierMap }) {
const { fmtDate } = useDateFormat()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>Your active subscriptions</DialogTitle>
<DialogDescription>
You currently hold more than one active plan at the same time.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2 py-2">
{tiers.map((t) => {
const badge = resolveTierBadge(t.tier, tierMap)
const TierIcon = LucideIcons[tierMap[t.tier]?.badge_icon] ?? null
return (
<div
key={t.tier_id ?? t.tier}
className="flex items-center justify-between rounded-lg border bg-muted/40 px-3 py-2.5"
>
<div className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold ${badge.cls}`}>
{TierIcon && <TierIcon className="size-3" />}
{badge.label}
</div>
<span className="text-xs text-muted-foreground">
{t.expires_at ? `Until ${fmtDate(t.expires_at)}` : "No expiry"}
</span>
</div>
)
})}
</div>
</DialogContent>
</Dialog>
)
}
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function getInitials(name = "") { function getInitials(name = "") {
@@ -148,6 +190,7 @@ function ClientNav() {
const { myTier, tierMap, tierCategories, getMyTier, getTierCategories } = useClientTiers() const { myTier, tierMap, tierCategories, getMyTier, getTierCategories } = useClientTiers()
const [referOpen, setReferOpen] = useState(false) const [referOpen, setReferOpen] = useState(false)
const [extraTiersOpen, setExtraTiersOpen] = useState(false)
const [badgeImgUrl, setBadgeImgUrl] = useState(null) const [badgeImgUrl, setBadgeImgUrl] = useState(null)
// Caches the resolved stream URL per asset_id — avoids re-hitting /media/token // 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. // 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 tierBadge = resolveTierBadge(tierSlug, tierMap)
const TierIcon = LucideIcons[tierMap[tierSlug]?.badge_icon] ?? null 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, // Derive primitive deps — effect only fires when the actual asset changes,
// not on every tierMap/tierSlug reference churn. // not on every tierMap/tierSlug reference churn.
const badgeAsset = tierMap[tierSlug]?.badgeAsset ?? null const badgeAsset = tierMap[tierSlug]?.badgeAsset ?? null
@@ -261,13 +309,24 @@ function ClientNav() {
<span className="text-transparent select-none">Loading</span> <span className="text-transparent select-none">Loading</span>
</div> </div>
) : tierBadge && ( ) : tierBadge && (
<div className={`xs:hidden md:inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold ${tierBadge.cls}`}> <div className="xs:hidden md:inline-flex items-center gap-1.5">
<div className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold ${tierBadge.cls}`}>
{badgeImgUrl {badgeImgUrl
? <img src={badgeImgUrl} className="size-3.5 rounded-full object-cover" /> ? <img src={badgeImgUrl} className="size-3.5 rounded-full object-cover" />
: TierIcon && <TierIcon className="size-3" /> : TierIcon && <TierIcon className="size-3" />
} }
{tierBadge.label} {tierBadge.label}
</div> </div>
{extraActiveTiers.length > 0 && (
<button
type="button"
onClick={() => 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}
</button>
)}
</div>
)} )}
</div> </div>
</div> </div>
@@ -344,6 +403,12 @@ function ClientNav() {
</nav> </nav>
<ReferDialog open={referOpen} onOpenChange={setReferOpen} /> <ReferDialog open={referOpen} onOpenChange={setReferOpen} />
<ExtraTiersDialog
open={extraTiersOpen}
onOpenChange={setExtraTiersOpen}
tiers={extraActiveTiers}
tierMap={tierMap}
/>
</> </>
) )
} }
+3 -1
View File
@@ -132,7 +132,9 @@ const Checkout = () => {
const email = user?.email ?? ""; const email = user?.email ?? "";
const isLoading = plansLoading || tierLoading; 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 style = TIER_STYLES[plan?.tier] ?? TIER_STYLES.free;
const Icon = style.icon; const Icon = style.icon;
const effectivePrice = plan ? Number(plan.price) : 0; const effectivePrice = plan ? Number(plan.price) : 0;
+40 -19
View File
@@ -89,7 +89,9 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
const { label: tierLabel, cls: badgeCls, rank } = resolveTierBadge(plan.tier, tierMap); const { label: tierLabel, cls: badgeCls, rank } = resolveTierBadge(plan.tier, tierMap);
const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag; const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag;
const ring = rank > 0 ? "ring-2 ring-primary/30" : ""; 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 duration = formatDuration(plan.duration_days, plan.duration_unit);
const features = plan.features ?? []; const features = plan.features ?? [];
const previewCourses = plan.courses?.slice(0, PREVIEW_COURSE_LIMIT) ?? []; const previewCourses = plan.courses?.slice(0, PREVIEW_COURSE_LIMIT) ?? [];
@@ -349,7 +351,9 @@ export default function PlanList() {
const [view, setView] = useState("grid"); const [view, setView] = useState("grid");
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
const [refundLoading, setRefundLoading] = useState(false); 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); const refundTimerRef = useRef(null);
useEffect(() => { useEffect(() => {
@@ -364,19 +368,25 @@ export default function PlanList() {
useEffect(() => { useEffect(() => {
clearInterval(refundTimerRef.current); clearInterval(refundTimerRef.current);
if (!myTier?.starts_at) { setRefundSecsLeft(0); return; } const activeTiers = myTier?.active_tiers ?? [];
if (!activeTiers.length) { setRefundSecsLeftByTier({}); return; }
const compute = () => { const compute = () => {
const elapsed = Math.floor((Date.now() - new Date(myTier.starts_at).getTime()) / 1000); const map = {};
return Math.max(0, REFUND_WINDOW_SECS - elapsed); 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(() => { refundTimerRef.current = setInterval(() => {
const left = compute(); setRefundSecsLeftByTier(compute());
setRefundSecsLeft(left);
if (left === 0) clearInterval(refundTimerRef.current);
}, 1000); }, 1000);
return () => clearInterval(refundTimerRef.current); return () => clearInterval(refundTimerRef.current);
}, [myTier?.starts_at]); }, [myTier?.active_tiers]);
const handleViewPlan = (plan) => navigate(`/plans/view/${plan.plan_id}`); const handleViewPlan = (plan) => navigate(`/plans/view/${plan.plan_id}`);
@@ -384,10 +394,19 @@ export default function PlanList() {
const handleRefundClick = (plan) => setRefundPlan(plan); 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 () => { const handleConfirmRefund = async () => {
setRefundLoading(true); setRefundLoading(true);
try { 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."); toast(data.message ?? "Refund processed. Your access has been revoked.");
setRefundPlan(null); setRefundPlan(null);
resetMyTier(); resetMyTier();
@@ -461,7 +480,7 @@ export default function PlanList() {
onSelect={handleSelectPlan} onSelect={handleSelectPlan}
onView={handleViewPlan} onView={handleViewPlan}
onRefund={handleRefundClick} onRefund={handleRefundClick}
refundSecsLeft={refundSecsLeft} refundSecsLeft={refundSecsLeftByTier[plan.tier] ?? 0}
/> />
))} ))}
</div> </div>
@@ -490,7 +509,7 @@ export default function PlanList() {
<Button <Button
variant="destructive" variant="destructive"
onClick={handleConfirmRefund} onClick={handleConfirmRefund}
disabled={refundLoading || refundSecsLeft === 0} disabled={refundLoading || refundPlanSecsLeft === 0}
> >
<RotateCcw className="size-4" /> <RotateCcw className="size-4" />
{refundLoading ? "Processing..." : "Confirm Refund"} {refundLoading ? "Processing..." : "Confirm Refund"}
@@ -510,32 +529,34 @@ export default function PlanList() {
{refundPlan ? fmtCurrency(refundPlan.price, refundPlan.currency) : "—"} {refundPlan ? fmtCurrency(refundPlan.price, refundPlan.currency) : "—"}
</span> </span>
</div> </div>
{myTier?.expires_at && ( {refundPlanTier?.expires_at && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Access until</span> <span className="text-muted-foreground">Access until</span>
<span className="font-medium"> <span className="font-medium">
{fmtDate(myTier.expires_at)} {fmtDate(refundPlanTier.expires_at)}
</span> </span>
</div> </div>
)} )}
<div className="flex justify-between items-center pt-1 border-t"> <div className="flex justify-between items-center pt-1 border-t">
<span className="text-muted-foreground">Refund window</span> <span className="text-muted-foreground">Refund window</span>
{refundSecsLeft > 0 ? ( {refundPlanSecsLeft > 0 ? (
<span className="font-semibold tabular-nums text-destructive"> <span className="font-semibold tabular-nums text-destructive">
{formatCountdown(refundSecsLeft)} remaining {formatCountdown(refundPlanSecsLeft)} remaining
</span> </span>
) : ( ) : (
<span className="font-semibold text-muted-foreground">Expired</span> <span className="font-semibold text-muted-foreground">Expired</span>
)} )}
</div> </div>
</div> </div>
{refundSecsLeft > 0 ? ( {refundPlanSecsLeft > 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Your refund will be processed through PayPal.{" "} Your refund will be processed through PayPal.{" "}
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
Access will be revoked immediately Access will be revoked immediately
</span>{" "} </span>{" "}
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."}
</p> </p>
) : ( ) : (
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
+3 -1
View File
@@ -80,7 +80,9 @@ const ViewPlan = () => {
const accentBorder = colors.panel.border; const accentBorder = colors.panel.border;
const features = plan.features ?? []; const features = plan.features ?? [];
const duration = formatDuration(plan.duration_days, plan.duration_unit); 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 totalSeconds = (plan.courses ?? []).reduce((sum, c) => sum + (c.duration_seconds ?? 0), 0);
const totalDuration = formatCourseDuration(totalSeconds); const totalDuration = formatCourseDuration(totalSeconds);