mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
added asset adjustments
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user