mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
future tier plans to payment
This commit is contained in:
@@ -23,7 +23,9 @@ export default function PlanComparisonTable({ plans, myTier, tierMap, fmtCurrenc
|
||||
{plans.map((plan) => {
|
||||
const { label, cls } = resolveTierBadge(plan.tier, tierMap);
|
||||
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 (
|
||||
<th key={plan.plan_id} className="p-4 text-center align-bottom min-w-[160px]">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
@@ -74,7 +76,9 @@ export default function PlanComparisonTable({ plans, myTier, tierMap, fmtCurrenc
|
||||
<tr>
|
||||
<td className="p-4" />
|
||||
{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 (
|
||||
<td key={plan.plan_id} className="p-4 text-center">
|
||||
<Button
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
User, Settings, LogOut, SquareArrowOutUpRight, TableOfContents,
|
||||
@@ -31,6 +32,7 @@ import { useClientTiers } from "@/contexts/ClientTiersProvider"
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util"
|
||||
import { useGroup } from "@/contexts/ClientGroupContext"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useDateFormat } from "@/hooks/useDateFormat"
|
||||
import { AVATAR_COLORS } from "@/data/profile.data"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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() {
|
||||
<span className="text-transparent select-none">Loading</span>
|
||||
</div>
|
||||
) : 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}`}>
|
||||
{badgeImgUrl
|
||||
? <img src={badgeImgUrl} className="size-3.5 rounded-full object-cover" />
|
||||
: TierIcon && <TierIcon className="size-3" />
|
||||
}
|
||||
{tierBadge.label}
|
||||
<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
|
||||
? <img src={badgeImgUrl} className="size-3.5 rounded-full object-cover" />
|
||||
: TierIcon && <TierIcon className="size-3" />
|
||||
}
|
||||
{tierBadge.label}
|
||||
</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>
|
||||
@@ -344,6 +403,12 @@ function ClientNav() {
|
||||
</nav>
|
||||
|
||||
<ReferDialog open={referOpen} onOpenChange={setReferOpen} />
|
||||
<ExtraTiersDialog
|
||||
open={extraTiersOpen}
|
||||
onOpenChange={setExtraTiersOpen}
|
||||
tiers={extraActiveTiers}
|
||||
tierMap={tierMap}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -490,7 +509,7 @@ export default function PlanList() {
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmRefund}
|
||||
disabled={refundLoading || refundSecsLeft === 0}
|
||||
disabled={refundLoading || refundPlanSecsLeft === 0}
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
{refundLoading ? "Processing..." : "Confirm Refund"}
|
||||
@@ -510,32 +529,34 @@ export default function PlanList() {
|
||||
{refundPlan ? fmtCurrency(refundPlan.price, refundPlan.currency) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
{myTier?.expires_at && (
|
||||
{refundPlanTier?.expires_at && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Access until</span>
|
||||
<span className="font-medium">
|
||||
{fmtDate(myTier.expires_at)}
|
||||
{fmtDate(refundPlanTier.expires_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center pt-1 border-t">
|
||||
<span className="text-muted-foreground">Refund window</span>
|
||||
{refundSecsLeft > 0 ? (
|
||||
{refundPlanSecsLeft > 0 ? (
|
||||
<span className="font-semibold tabular-nums text-destructive">
|
||||
{formatCountdown(refundSecsLeft)} remaining
|
||||
{formatCountdown(refundPlanSecsLeft)} remaining
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-semibold text-muted-foreground">Expired</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{refundSecsLeft > 0 ? (
|
||||
{refundPlanSecsLeft > 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your refund will be processed through PayPal.{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
Access will be revoked immediately
|
||||
</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 className="text-sm text-destructive">
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user