mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
682 lines
36 KiB
React
682 lines
36 KiB
React
import { useEffect, useRef, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import {
|
|
Card, CardContent, CardDescription,
|
|
CardFooter, CardHeader, CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
BookOpen, Star, Lock, Sparkles,
|
|
RotateCcw, Plus, Ban, BadgeCheck,
|
|
GraduationCap, Book, Medal,
|
|
Check, Clock, CalendarDays, Tag, XIcon,
|
|
} from "lucide-react";
|
|
import * as LucideIcons from "lucide-react";
|
|
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
|
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
|
import { toast } from "sonner";
|
|
import api from "@/utils/api.util";
|
|
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 { getTierColor } from "@/utils/tierColors";
|
|
import { getBundleContent, overlapsExistingAccess, isPlanCurrent } from "@/utils/planBundle.util";
|
|
import { TablePagination } from "@/components/generic/Table/TablePagination";
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
const REFUND_WINDOW_SECS = 5 * 60;
|
|
|
|
function formatCountdown(secs) {
|
|
const m = Math.floor(secs / 60);
|
|
const s = secs % 60;
|
|
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);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
if (h && m) return `${h}h ${m}m`;
|
|
if (h) return `${h}h`;
|
|
return `${m}m`;
|
|
}
|
|
|
|
|
|
// ─── Skeleton ──────────────────────────────────────────────────────────────────
|
|
|
|
const PlanSkeleton = () => (
|
|
<Card className="flex flex-col">
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<Skeleton className="h-5 w-20" />
|
|
<Skeleton className="h-5 w-16 rounded-full" />
|
|
</div>
|
|
<Skeleton className="h-8 w-24 mt-2" />
|
|
</CardHeader>
|
|
<CardContent className="flex-1 space-y-2">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-4 w-full" />
|
|
))}
|
|
</CardContent>
|
|
<CardFooter>
|
|
<Skeleton className="h-9 w-full" />
|
|
</CardFooter>
|
|
</Card>
|
|
);
|
|
|
|
// ─── Plan Card ────────────────────────────────────────────────────────────────
|
|
|
|
function ChecklistItem({ item, icon: Icon = Star, onClick }) {
|
|
return (
|
|
<div
|
|
className={`flex items-center gap-2 py-1.5 text-sm ${onClick ? "cursor-pointer hover:underline w-fit" : ""}`}
|
|
onClick={onClick}
|
|
>
|
|
<Icon className="text-blue-500 shrink-0" size={16} />
|
|
<span className="line-clamp-1">{item}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Per-bundle-type icon for "Benefits" rows — distinguishes a course
|
|
// grant from a unit or lesson grant at a glance instead of a flat checkmark.
|
|
const BUNDLE_ITEM_ICONS = { course: GraduationCap, unit: Book, lesson: Medal };
|
|
|
|
const PlanCard = ({ plan, myTier, tierMap, onSelect, onRefund, refundSecsLeft }) => {
|
|
const { fmtCurrency } = useDateFormat();
|
|
const [notAvailableOpen, setNotAvailableOpen] = useState(false);
|
|
const [previewItem, setPreviewItem] = useState(null);
|
|
const [viewOpen, setViewOpen] = useState(false);
|
|
const { label: tierLabel, cls: badgeCls, panel, gradient, colorKey } = resolveTierBadge(plan.tier, tierMap);
|
|
// Details-dialog-only accent — the swatch hex and tier icon aren't part
|
|
// of resolveTierBadge's return, mirrors the old ViewPlan page's styling.
|
|
const TierIcon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag;
|
|
const accentStyle = { color: getTierColor(colorKey).swatch };
|
|
// Admin-controlled — set from the "Recommended for you" toggle on the
|
|
// plan's Edit Plan page, drives the featured frame treatment below.
|
|
// Color comes from the plan's own tier category's actual gradient (same
|
|
// one used for its badge), not a hardcoded blue or a pale flat tint.
|
|
const isRecommended = !!plan.is_recommended;
|
|
// "Current" means the user actually purchased THIS exact plan (matched by
|
|
// plan_id) and it's still active — NOT just any plan sharing the same tier
|
|
// slug. Two different plans can be the same tier (e.g. two Premium
|
|
// bundles) and stay independently active (Tier Plans v2).
|
|
const isCurrent = isPlanCurrent(plan, myTier);
|
|
// Blocked-by-overlap ("already covered") — the plan isn't itself owned,
|
|
// 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 bundle = getBundleContent(plan);
|
|
const features = plan.features ?? [];
|
|
const bundleCount = bundle?.items?.length ?? 0;
|
|
const BundleIcon = bundle?.icon ?? BookOpen;
|
|
const totalSeconds = (bundle?.items ?? []).reduce((sum, c) => sum + (c.duration_seconds ?? 0), 0);
|
|
const totalDuration = formatCourseDuration(totalSeconds);
|
|
// Footer button state — always visible when there's an actual action to
|
|
// take. A not-yet-owned free plan has nothing to do (it's the default),
|
|
// so it's the one case with no button. Base color follows the recommended
|
|
// flag (dark = recommended, light = plain), overridden for refund/blocked.
|
|
let footerLabel = null;
|
|
let footerAction = null;
|
|
let footerDisabled = false;
|
|
let footerBtnCls = "";
|
|
|
|
if (isCurrent && refundSecsLeft > 0) {
|
|
footerLabel = <><RotateCcw className="size-4" /> Refund ({formatCountdown(refundSecsLeft)})</>;
|
|
footerAction = () => onRefund(plan);
|
|
footerBtnCls = "bg-red-600 hover:bg-red-700 text-white";
|
|
} else if (!plan.is_active) {
|
|
footerLabel = <><Ban className="size-4" /> Not Available</>;
|
|
footerAction = () => setNotAvailableOpen(true);
|
|
footerDisabled = true;
|
|
footerBtnCls = "bg-gray-100 text-slate-800 dark:bg-primary dark:text-primary-foreground shadow-none";
|
|
} else if (isBlockedByOverlap) {
|
|
footerLabel = <><Lock className="size-4" /> Already Included</>;
|
|
footerDisabled = true;
|
|
footerBtnCls = "bg-gray-100 text-slate-400 shadow-none";
|
|
} else if (isCurrent && plan.tier !== "free") {
|
|
footerLabel = <><BadgeCheck className="size-4" /> Extend {tierLabel}</>;
|
|
footerAction = () => onSelect(plan);
|
|
} else if (!isCurrent && plan.tier !== "free") {
|
|
footerLabel = <><Sparkles className="size-4" /> Get {tierLabel}</>;
|
|
footerAction = () => onSelect(plan);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/* Actual Card */}
|
|
<div
|
|
className={`flex flex-col h-full rounded-2xl px-2 pb-2 relative ${isRecommended ? `${gradient} border-transparent` : "bg-transparent border border-transparent"
|
|
} ${isBlockedByOverlap ? "opacity-60" : ""}`}
|
|
>
|
|
<div className={`text-center text-sm font-medium my-2 ${isRecommended ? "text-white" : "text-slate-500 invisible"}`}>
|
|
Recommended for you
|
|
</div>
|
|
|
|
<div
|
|
className={`flex flex-col flex-1 rounded-xl p-6 relative overflow-hidden ${isRecommended ? "bg-card" : "bg-card border border-gray-100 dark:border-blue-900 shadow-sm"
|
|
}`}
|
|
>
|
|
|
|
<div className="relative space-y-1.5 max-w-sm">
|
|
<h1
|
|
className="font-bold text-xl leading-relaxed hover:underline cursor-pointer w-fit"
|
|
onClick={() => setViewOpen(true)}
|
|
>
|
|
{plan.label}
|
|
</h1>
|
|
<p className="text-sm text-slate-500 leading-relaxed line-clamp-2">
|
|
{plan.description || "No information provided"}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="relative flex items-center gap-1.5 mt-3 flex-wrap">
|
|
{isBlockedByOverlap && (
|
|
<Badge variant="secondary" className="gap-1">
|
|
<Lock className="size-3" />
|
|
Already Included
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
<div className="relative flex flex-col items-start gap-4 my-6">
|
|
<div className="flex items-end gap-1">
|
|
<h1 className="text-5xl font-bold">
|
|
{fmtCurrency(plan.price, plan.currency)}
|
|
</h1>
|
|
{duration && <p className="text-muted-foreground mb-1">/ {duration}</p>}
|
|
</div>
|
|
|
|
<div className="w-full h-px bg-gray-200" />
|
|
|
|
<div className="flex flex-col gap-1 items-start w-full">
|
|
<h1 className="font-medium text-sm text-muted-foreground mb-1">
|
|
{bundle ? `Bundle (${bundle.noun}s):` : "Bundle:"}
|
|
</h1>
|
|
<div className="w-full">
|
|
{bundle ? (
|
|
bundle.items.map((item) => (
|
|
<ChecklistItem
|
|
key={item[bundle.idKey]}
|
|
item={item.title}
|
|
icon={BUNDLE_ITEM_ICONS[bundle.type]}
|
|
onClick={() => setPreviewItem(item)}
|
|
/>
|
|
))
|
|
) : !plan.description ? (
|
|
<p className="text-sm text-slate-500 py-1.5">
|
|
Access to all free course content.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{footerLabel && (
|
|
<Button
|
|
className={`w-full mt-auto ${footerBtnCls}`}
|
|
size="lg"
|
|
disabled={footerDisabled}
|
|
onClick={footerAction}
|
|
>
|
|
{footerLabel}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
|
|
{/* ── Not Available Dialog ──────────────────────────────────────── */}
|
|
<Dialog open={notAvailableOpen} onOpenChange={setNotAvailableOpen}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Unavailable</DialogTitle>
|
|
</DialogHeader>
|
|
<p className="text-sm text-muted-foreground py-1">
|
|
The <span className="font-medium text-foreground">{plan.label}</span> plan
|
|
is currently not available for purchase. Please check back later.
|
|
</p>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setNotAvailableOpen(false)}>
|
|
Got it
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ── Plan Details Dialog (replaces the old /subscriptions/view/:id page) ── */}
|
|
<Dialog open={viewOpen} onOpenChange={setViewOpen}>
|
|
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto p-0 gap-0" showCloseButton={false}>
|
|
<div className={`relative ${badgeCls} overflow-hidden rounded-t-xl`}>
|
|
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
|
<div className="absolute -top-16 -right-16 w-64 h-64 rounded-full bg-white/5" />
|
|
<div className="absolute -bottom-12 -left-12 w-48 h-48 rounded-full bg-white/5" />
|
|
</div>
|
|
<DialogClose asChild>
|
|
<Button variant="ghost" size="icon-sm" className="absolute top-2 right-2 text-white hover:bg-white/20 hover:text-white">
|
|
<XIcon />
|
|
<span className="sr-only">Close</span>
|
|
</Button>
|
|
</DialogClose>
|
|
<div className="relative px-6 pt-8 pb-6">
|
|
<Badge className="bg-white/20 border border-white/30 text-white mb-3 text-sm px-3 py-1">
|
|
<TierIcon className="size-3.5 mr-1" /> {tierLabel}
|
|
</Badge>
|
|
<DialogHeader className="p-0 space-y-1 text-left">
|
|
<DialogTitle className="text-2xl font-extrabold text-white leading-tight tracking-tight">
|
|
{plan.label}
|
|
</DialogTitle>
|
|
<p className="text-white/70 text-sm mb-2 leading-relaxed">
|
|
{plan.description || `Everything you need with the ${tierLabel} plan.`}
|
|
</p>
|
|
</DialogHeader>
|
|
<div className="flex flex-wrap items-end gap-3 mt-4">
|
|
<div>
|
|
<span className="text-4xl font-black text-white leading-none">
|
|
{fmtCurrency(plan.price, plan.currency)}
|
|
</span>
|
|
{duration && <span className="text-white/60 text-sm ml-2">/ {duration}</span>}
|
|
</div>
|
|
{isCurrent ? (
|
|
<Badge className="bg-white/20 border border-white/30 text-white px-3 py-1.5 text-xs font-medium">
|
|
<Check className="size-3.5 mr-1" /> Your Current Plan
|
|
</Badge>
|
|
) : !plan.is_active ? (
|
|
<Badge className="bg-white/20 border border-white/30 text-white px-3 py-1.5 text-xs font-medium">
|
|
Not Available
|
|
</Badge>
|
|
) : isBlockedByOverlap ? (
|
|
<Badge className="bg-white/20 border border-white/30 text-white px-3 py-1.5 text-xs font-medium">
|
|
<Lock className="size-3.5 mr-1" /> Already Included
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-6 py-5 space-y-4">
|
|
{features.length > 0 && (
|
|
<div className={`rounded-2xl border ${panel.border} ${panel.bg} p-4`}>
|
|
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={accentStyle}>
|
|
Benefits
|
|
</p>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
|
{features.map(({ text }, i) => (
|
|
<div key={i} className="flex items-center gap-2.5">
|
|
<div className={`h-7 w-7 rounded-lg ${panel.bg} border ${panel.border} flex items-center justify-center shrink-0`}>
|
|
<Check className="size-3.5" style={accentStyle} />
|
|
</div>
|
|
<span className="text-sm font-medium">{text}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-3 gap-2.5">
|
|
{[
|
|
{ label: bundle ? `${bundle.noun}s` : "Courses", value: bundleCount, icon: BundleIcon },
|
|
{ label: "Content", value: totalDuration ?? "—", icon: Clock },
|
|
{ label: "Access", value: duration ?? "Lifetime", icon: CalendarDays },
|
|
].map(({ label, value, icon: StatIcon }) => (
|
|
<div key={label} className="rounded-xl bg-card border p-3 flex flex-col items-center gap-1 text-center">
|
|
<StatIcon className="size-4" style={accentStyle} />
|
|
<p className="text-xl font-bold leading-none mt-1">{value}</p>
|
|
<p className="text-xs text-muted-foreground">{label}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="rounded-2xl bg-card border overflow-hidden">
|
|
<div className="px-4 py-3 border-b flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<BundleIcon className="size-4" style={accentStyle} />
|
|
<span className="text-sm font-semibold">Bundle</span>
|
|
</div>
|
|
<Badge variant="secondary">{bundleCount}</Badge>
|
|
</div>
|
|
<div className="divide-y max-h-64 overflow-y-auto">
|
|
{bundle ? (
|
|
bundle.items.map((item) => (
|
|
<div key={item[bundle.idKey]} className="flex items-start gap-3 px-4 py-3">
|
|
<div className={`h-9 w-9 rounded-xl ${panel.bg} border ${panel.border} flex items-center justify-center shrink-0`}>
|
|
<bundle.icon className="size-4" style={accentStyle} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-semibold line-clamp-1">{item.title}</p>
|
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
|
{item.level && (
|
|
<Badge variant="outline" className="text-xs capitalize h-5">
|
|
{item.level}
|
|
</Badge>
|
|
)}
|
|
{formatCourseDuration(item.duration_seconds) && (
|
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
|
<Clock className="size-3" />
|
|
{formatCourseDuration(item.duration_seconds)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="h-5 w-5 rounded-full bg-green-100 dark:bg-green-950 flex items-center justify-center shrink-0 mt-0.5">
|
|
<Check className="size-3 text-green-600 dark:text-green-400" />
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center py-8 text-center px-6">
|
|
<div className={`h-12 w-12 rounded-2xl ${panel.bg} border ${panel.border} flex items-center justify-center mb-2`}>
|
|
<BookOpen className="size-6" style={accentStyle} />
|
|
</div>
|
|
<p className="font-semibold text-sm">Content coming soon</p>
|
|
<p className="text-xs text-muted-foreground mt-1 max-w-xs">
|
|
We're curating the best content for this plan.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{footerLabel && (
|
|
<DialogFooter className="mx-0 mb-0 rounded-b-xl border-t-0 bg-transparent px-6 pb-6 pt-0">
|
|
<Button
|
|
className={`w-full ${footerBtnCls}`}
|
|
size="lg"
|
|
disabled={footerDisabled}
|
|
onClick={() => { setViewOpen(false); footerAction?.(); }}
|
|
>
|
|
{footerLabel}
|
|
</Button>
|
|
</DialogFooter>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ── Bundle Item Preview ─────────────────────────────────────────── */}
|
|
<ResponsiveModal
|
|
open={!!previewItem}
|
|
onOpenChange={(v) => !v && setPreviewItem(null)}
|
|
title={previewItem?.title}
|
|
description={`Upgrade your plan to access this ${bundle?.noun?.toLowerCase() ?? "item"}.`}
|
|
>
|
|
<div className="py-2">
|
|
<div className={`p-5 rounded-2xl border ${panel.bg} ${panel.border}`}>
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<Badge className={badgeCls}>
|
|
<Lock className="size-3" /> {tierLabel}
|
|
</Badge>
|
|
</div>
|
|
<ul className="space-y-2 text-sm [&_svg]:size-4 mb-4">
|
|
<li className="flex items-center gap-2"><Star className="text-blue-500" /> Access to {tierLabel} content</li>
|
|
<li className="flex items-center gap-2"><Star className="text-blue-500" /> Certificates & achievements</li>
|
|
</ul>
|
|
<p className="text-sm text-muted-foreground">
|
|
Upgrade to a <span className="font-medium">{tierLabel}</span> plan to unlock this {bundle?.noun?.toLowerCase() ?? "item"}.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</ResponsiveModal>
|
|
</>
|
|
);
|
|
};
|
|
|
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
|
|
|
export default function PlanList() {
|
|
const navigate = useNavigate();
|
|
const { plans, plansLoading, myTier, tierLoading, tierMap, getPlans, getMyTier, getTierCategories, resetMyTier } = useClientTiers();
|
|
const { fmtDate, fmtCurrency } = useDateFormat();
|
|
const { adLists, listLoading: adLoading, getActiveAdvertisementList, handleAdCtaClick } = useClientAdvertisements();
|
|
|
|
// Plans are fetched all at once — pagination here just slices the
|
|
// already-loaded list client-side, it doesn't refetch.
|
|
const [plansPage, setPlansPage] = useState(1);
|
|
const [plansLimit, setPlansLimit] = useState(10);
|
|
|
|
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
|
|
const [refundLoading, setRefundLoading] = useState(false);
|
|
// Keyed by plan_id, not tier slug — two different plans can share a tier
|
|
// (Tier Plans v2) and each has its own independent refund window based on
|
|
// its own starts_at.
|
|
const [refundSecsLeftByTier, setRefundSecsLeftByTier] = useState({});
|
|
const refundTimerRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
getPlans();
|
|
getMyTier();
|
|
getTierCategories();
|
|
getActiveAdvertisementList("tier_plans.banner");
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [getPlans, getMyTier]);
|
|
|
|
const bannerAds = adLists["tier_plans.banner"] ?? [];
|
|
|
|
useEffect(() => {
|
|
clearInterval(refundTimerRef.current);
|
|
const activeTiers = myTier?.active_tiers ?? [];
|
|
if (!activeTiers.length) { setRefundSecsLeftByTier({}); return; }
|
|
|
|
const compute = () => {
|
|
const map = {};
|
|
for (const t of activeTiers) {
|
|
if (!t.starts_at || t.plan_id == null) continue;
|
|
const elapsed = Math.floor((Date.now() - new Date(t.starts_at).getTime()) / 1000);
|
|
map[t.plan_id] = Math.max(0, REFUND_WINDOW_SECS - elapsed);
|
|
}
|
|
return map;
|
|
};
|
|
|
|
setRefundSecsLeftByTier(compute());
|
|
refundTimerRef.current = setInterval(() => {
|
|
setRefundSecsLeftByTier(compute());
|
|
}, 1000);
|
|
return () => clearInterval(refundTimerRef.current);
|
|
}, [myTier?.active_tiers]);
|
|
|
|
const handleSelectPlan = (plan) => navigate(`/subscriptions/checkout?plan_id=${plan.plan_id}`);
|
|
|
|
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 by plan_id
|
|
// (not tier slug, since two plans can share a tier) for its own
|
|
// expires_at/refund window instead of assuming it matches myTier.
|
|
const refundPlanTier = (myTier?.active_tiers ?? []).find((t) => t.plan_id != null && String(t.plan_id) === String(refundPlan?.plan_id)) ?? null;
|
|
const refundPlanSecsLeft = refundSecsLeftByTier[refundPlan?.plan_id] ?? 0;
|
|
const isOnlyActiveTier = (myTier?.active_tiers ?? []).length <= 1;
|
|
|
|
const plansTotalPages = Math.max(1, Math.ceil(plans.length / plansLimit));
|
|
const pagedPlans = plans.slice((plansPage - 1) * plansLimit, plansPage * plansLimit);
|
|
const plansPagination = {
|
|
page: plansPage,
|
|
limit: plansLimit,
|
|
totalPages: plansTotalPages,
|
|
totalRecords: plans.length,
|
|
hasPrevPage: plansPage > 1,
|
|
hasNextPage: plansPage < plansTotalPages,
|
|
};
|
|
const handlePlansPageSize = (size) => {
|
|
setPlansLimit(size);
|
|
setPlansPage(1);
|
|
};
|
|
|
|
const handleConfirmRefund = async () => {
|
|
setRefundLoading(true);
|
|
try {
|
|
// 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();
|
|
getMyTier();
|
|
} catch (err) {
|
|
toast(err?.response?.data?.message ?? "Refund failed. Please try again.");
|
|
} finally {
|
|
setRefundLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="mt-17">
|
|
<PageMeta title="Subscriptions - STARR" description="Browse available subscription plans." />
|
|
<div className="relative overflow-hidden bg-gradient-to-b from-primary/5 via-background to-background min-h-screen">
|
|
<div className="lg:container lg:mx-auto space-y-8 p-6">
|
|
<div className="relative xs:pt-2 lg:pt-8 space-y-4">
|
|
{/* Advertisement Banner */}
|
|
{/* {adLoading["tier_plans.banner"] ? (
|
|
<BannerSkeleton />
|
|
) : (
|
|
<Banner ads={bannerAds} onCtaClick={handleAdCtaClick} />
|
|
)} */}
|
|
|
|
<Banner ads={bannerAds} onCtaClick={handleAdCtaClick} />
|
|
|
|
{/* Section Header */}
|
|
<div className="relative flex flex-col items-center gap-4 mt-6">
|
|
<div className="text-center my-8">
|
|
<h2 className="text-3xl font-bold">Available Subscriptions</h2>
|
|
<p className="text-muted-foreground mt-2">
|
|
Choose a subscription that matches your goals.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Plan Cards */}
|
|
{plansLoading || tierLoading ? (
|
|
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
|
{Array.from({ length: 3 }).map((_, i) => <PlanSkeleton key={i} />)}
|
|
</div>
|
|
) : plans.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-20">
|
|
<BookOpen className="size-10 mb-3" />
|
|
<p className="text-sm">No plans available at the moment.</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
|
{pagedPlans.map((plan) => (
|
|
<PlanCard
|
|
key={plan.plan_id}
|
|
plan={plan}
|
|
myTier={myTier}
|
|
tierMap={tierMap}
|
|
onSelect={handleSelectPlan}
|
|
onRefund={handleRefundClick}
|
|
refundSecsLeft={refundSecsLeftByTier[plan.plan_id] ?? 0}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<TablePagination
|
|
pagination={plansPagination}
|
|
onPageChange={setPlansPage}
|
|
rowCount={pagedPlans.length}
|
|
totalRecords={plans.length}
|
|
onPageSizeChange={handlePlansPageSize}
|
|
recordLabel="plan"
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Refund Confirmation Modal ──────────────────────────────────── */}
|
|
<ResponsiveModal
|
|
open={!!refundPlan}
|
|
onOpenChange={(v) => !v && setRefundPlan(null)}
|
|
title="Request Refund"
|
|
description={`Are you sure you want to refund your ${refundPlan?.label ?? "current"} plan?`}
|
|
footer={
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setRefundPlan(null)}
|
|
disabled={refundLoading}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={handleConfirmRefund}
|
|
disabled={refundLoading || refundPlanSecsLeft === 0}
|
|
>
|
|
<RotateCcw className="size-4" />
|
|
{refundLoading ? "Processing..." : "Confirm Refund"}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-4 py-2">
|
|
<div className="rounded-xl border bg-muted/60 p-4 space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Plan</span>
|
|
<span className="font-medium capitalize">{refundPlan?.tier}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Refund amount</span>
|
|
<span className="font-medium">
|
|
{refundPlan ? fmtCurrency(refundPlan.price, refundPlan.currency) : "—"}
|
|
</span>
|
|
</div>
|
|
{refundPlanTier?.expires_at && (
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Access until</span>
|
|
<span className="font-medium">
|
|
{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>
|
|
{refundPlanSecsLeft > 0 ? (
|
|
<span className="font-semibold tabular-nums text-destructive">
|
|
{formatCountdown(refundPlanSecsLeft)} remaining
|
|
</span>
|
|
) : (
|
|
<span className="font-semibold text-muted-foreground">Expired</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{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>{" "}
|
|
{isOnlyActiveTier
|
|
? "and your account will be downgraded to Free."
|
|
: "Your other active plan(s) will be unaffected."}
|
|
</p>
|
|
) : (
|
|
<p className="text-sm text-destructive">
|
|
The 5-minute refund window has expired. Refunds are no longer available for this payment.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</ResponsiveModal>
|
|
</div>
|
|
);
|
|
} |