added asset adjustments

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-09-09 13:55:33 +08:00
parent 79203b1654
commit f67d8d3797
4 changed files with 135 additions and 16 deletions
+2 -2
View File
@@ -40,8 +40,8 @@ exports.createOrder = async ({ amount, currency = 'USD', referenceId, returnUrl,
amount: { currency_code: currency, value: String(amount) },
}],
application_context: {
return_url: returnUrl ?? `${process.env.FRONTEND_URL}/plans/checkout`,
cancel_url: cancelUrl ?? `${process.env.FRONTEND_URL}/plans/checkout?cancelled=true`,
return_url: returnUrl ?? `${process.env.FRONTEND_URL}/subscriptions/checkout`,
cancel_url: cancelUrl ?? `${process.env.FRONTEND_URL}/subscriptions/checkout?cancelled=true`,
brand_name: process.env.PAYPAL_BRAND_NAME ?? 'STARR',
user_action: 'PAY_NOW',
},
@@ -15,6 +15,7 @@ import {
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog"
import {
User, Settings, LogOut, SquareArrowOutUpRight, TableOfContents,
@@ -29,7 +30,7 @@ import api from "@/utils/api.util"
import { ClientProvider } from "@/contexts/provider/ClientProvider"
import { useProfile } from "@/contexts/ProfileProvider"
import { useClientTiers } from "@/contexts/ClientTiersProvider"
import { resolveTierBadge } from "@/utils/tierBadge.util"
import { resolveTierBadge, formatPlanDuration, formatTimeRemaining } from "@/utils/tierBadge.util"
import { useGroup } from "@/contexts/ClientGroupContext"
import { resolveAssetSrc } from "@/utils/media.util"
import { useEffect, useRef, useState } from "react"
@@ -176,6 +177,99 @@ function ExtraTiersDialog({ open, onOpenChange, tiers, tierMap }) {
)
}
// ─── My plan dialog (opens from the tier badge in the header) ─────────────────
function MyTierDialog({ open, onOpenChange, myTier, tierBadge, badgeImgUrl, TierIcon }) {
const navigate = useNavigate()
const { fmtDate, fmtCurrency } = useDateFormat()
const isActive = myTier?.status === 'active' && myTier?.tier !== 'free'
const plan = myTier?.plan ?? null
const planId = myTier?.plan_id ?? plan?.plan_id ?? null
const duration = plan ? formatPlanDuration(plan.duration_days, plan.duration_unit) : null
const timeRemaining = isActive ? formatTimeRemaining(myTier?.expires_at) : null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className={`w-fit inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold mb-1 ${tierBadge.cls}`}>
{badgeImgUrl
? <img src={badgeImgUrl} className="size-3.5 rounded-full object-cover" />
: TierIcon && <TierIcon className="size-3" />
}
{tierBadge.label}
</div>
<DialogTitle>{plan?.label ?? (isActive ? tierBadge.label : "Free plan")}</DialogTitle>
<DialogDescription>
{plan?.description || (isActive
? "Your current active subscription."
: "You're on the Free plan. Upgrade to unlock more content.")}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-1">
{plan && (
<div className="flex items-end gap-1">
<span className="text-2xl font-bold">{fmtCurrency(plan.price, plan.currency)}</span>
{duration && <span className="text-sm text-muted-foreground mb-0.5">/ {duration}</span>}
</div>
)}
{isActive && (
<div className="rounded-lg border bg-muted/40 p-3 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Started</span>
<span className="font-medium">
{myTier?.starts_at ? fmtDate(myTier.starts_at) : "—"}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Expires</span>
<span className="font-medium">
{myTier?.expires_at ? fmtDate(myTier.expires_at) : "Never"}
</span>
</div>
{timeRemaining && (
<div className="flex justify-between pt-1.5 border-t">
<span className="text-muted-foreground">Time remaining</span>
<span className="font-semibold">{timeRemaining}</span>
</div>
)}
</div>
)}
{plan?.features?.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Includes</p>
{plan.features.map(({ text }, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<Check className="size-3.5 text-emerald-500 shrink-0" />
{text}
</div>
))}
</div>
)}
</div>
<DialogFooter>
<Button
className="w-full"
onClick={() => {
onOpenChange(false)
navigate(isActive && planId
? `/subscriptions/checkout?plan_id=${planId}`
: "/subscriptions")
}}
>
{isActive ? "Manage Subscription" : "View Plans"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getInitials(name = "") {
@@ -194,6 +288,7 @@ function ClientNav() {
const [referOpen, setReferOpen] = useState(false)
const [extraTiersOpen, setExtraTiersOpen] = useState(false)
const [myTierOpen, setMyTierOpen] = 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.
@@ -314,13 +409,17 @@ function ClientNav() {
</div>
) : tierBadge && (
<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}`}>
<button
type="button"
onClick={() => setMyTierOpen(true)}
className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold cursor-pointer hover:brightness-110 transition-[filter] ${tierBadge.cls}`}
>
{badgeImgUrl
? <img src={badgeImgUrl} className="size-3.5 rounded-full object-cover" />
: TierIcon && <TierIcon className="size-3" />
}
{tierBadge.label}
</div>
</button>
{extraActiveTiers.length > 0 && (
<button
type="button"
@@ -413,6 +512,14 @@ function ClientNav() {
tiers={extraActiveTiers}
tierMap={tierMap}
/>
<MyTierDialog
open={myTierOpen}
onOpenChange={setMyTierOpen}
myTier={myTier}
tierBadge={tierBadge}
badgeImgUrl={badgeImgUrl}
TierIcon={TierIcon}
/>
</>
)
}
+2 -11
View File
@@ -25,7 +25,7 @@ import { PageMeta } from "@/contexts/MetadataContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import { resolveTierBadge, formatPlanDuration } from "@/utils/tierBadge.util";
import { getTierColor } from "@/utils/tierColors";
import { getBundleContent, overlapsExistingAccess, isPlanCurrent } from "@/utils/planBundle.util";
import { TablePagination } from "@/components/generic/Table/TablePagination";
@@ -40,15 +40,6 @@ function formatCountdown(secs) {
return `${m}:${String(s).padStart(2, "0")}`;
}
function formatDuration(days, unit) {
if (!days) return null;
const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
const multiplier = UNIT_TO_DAYS[unit] ?? 1;
const value = Math.round((days / multiplier) * 1000) / 1000;
const label = unit ?? "day";
return `${value} ${label}${value !== 1 ? "s" : ""}`;
}
function formatCourseDuration(seconds = 0) {
if (!seconds) return null;
const h = Math.floor(seconds / 3600);
@@ -123,7 +114,7 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onRefund, refundSecsLeft })
// but at least one of its bundle items is already granted by something
// 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 duration = formatPlanDuration(plan.duration_days, plan.duration_unit);
const bundle = getBundleContent(plan);
const features = plan.features ?? [];
const bundleCount = bundle?.items?.length ?? 0;
+21
View File
@@ -27,6 +27,27 @@ export function tierPanelColors(colorKey = 'green') {
return getTierColor(colorKey).panel;
}
/** Converts a plan's stored duration_days into its display unit, e.g. "30 days". */
export function formatPlanDuration(days, unit) {
if (!days) return null;
const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
const multiplier = UNIT_TO_DAYS[unit] ?? 1;
const value = Math.round((days / multiplier) * 1000) / 1000;
const label = unit ?? 'day';
return `${value} ${label}${value !== 1 ? 's' : ''}`;
}
/** Human-readable countdown to an ISO expiry timestamp, e.g. "12 days left". */
export function formatTimeRemaining(expiresAt) {
if (!expiresAt) return null;
const ms = new Date(expiresAt).getTime() - Date.now();
if (ms <= 0) return 'Expired';
const days = Math.floor(ms / 86400000);
if (days >= 1) return `${days} day${days !== 1 ? 's' : ''} left`;
const hours = Math.max(1, Math.ceil(ms / 3600000));
return `${hours} hour${hours !== 1 ? 's' : ''} left`;
}
/**
* Picks the lowest-rank (cheapest) tier slug among several access paths —
* e.g. a Unit's own subscription plus every course that also unlocks it, or