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) => {
|
{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
|
||||||
|
|||||||
@@ -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,12 +309,23 @@ 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">
|
||||||
{badgeImgUrl
|
<div className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold ${tierBadge.cls}`}>
|
||||||
? <img src={badgeImgUrl} className="size-3.5 rounded-full object-cover" />
|
{badgeImgUrl
|
||||||
: TierIcon && <TierIcon className="size-3" />
|
? <img src={badgeImgUrl} className="size-3.5 rounded-full object-cover" />
|
||||||
}
|
: TierIcon && <TierIcon className="size-3" />
|
||||||
{tierBadge.label}
|
}
|
||||||
|
{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>
|
||||||
)}
|
)}
|
||||||
</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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user