replace view plan to dialog - client side
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -123,9 +123,7 @@ export function resolveNotificationLink(type, data) {
|
||||
: null;
|
||||
|
||||
case "tier_expired":
|
||||
return data.planId
|
||||
? { label: "Renew plan", go: (navigate) => navigate(`/subscriptions/view/${data.planId}`) }
|
||||
: { label: "View plans", go: (navigate) => navigate("/subscriptions") };
|
||||
return { label: "View plans", go: (navigate) => navigate("/subscriptions") };
|
||||
|
||||
case "announcement":
|
||||
if (data.groupId && data.groupCode) {
|
||||
@@ -135,7 +133,7 @@ export function resolveNotificationLink(type, data) {
|
||||
return { label: "Go to course", go: (navigate) => goToCourse(navigate, data.targetId) };
|
||||
}
|
||||
if (data.targetType === "tier_plan" && data.targetId) {
|
||||
return { label: "View plan", go: (navigate) => navigate(`/subscriptions/view/${data.targetId}`) };
|
||||
return { label: "View plan", go: (navigate) => navigate("/subscriptions") };
|
||||
}
|
||||
if (data.targetType === "task_list" && data.groupId && data.targetId) {
|
||||
return { label: "View task list", go: (navigate) => navigate(`/group/${data.groupId}/view/${data.targetId}`) };
|
||||
|
||||
@@ -8,13 +8,15 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
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";
|
||||
@@ -24,6 +26,7 @@ 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";
|
||||
|
||||
@@ -46,6 +49,15 @@ function formatDuration(days, unit) {
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -87,11 +99,16 @@ function ChecklistItem({ item, icon: Icon = Star, onClick }) {
|
||||
// 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, onView, onRefund, refundSecsLeft }) => {
|
||||
const PlanCard = ({ plan, myTier, tierMap, onSelect, onRefund, refundSecsLeft }) => {
|
||||
const { fmtCurrency } = useDateFormat();
|
||||
const [notAvailableOpen, setNotAvailableOpen] = useState(false);
|
||||
const [previewItem, setPreviewItem] = useState(null);
|
||||
const { label: tierLabel, cls: badgeCls, panel, gradient } = resolveTierBadge(plan.tier, tierMap);
|
||||
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
|
||||
@@ -108,6 +125,11 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
|
||||
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
|
||||
@@ -157,7 +179,7 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
|
||||
<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={() => onView(plan)}
|
||||
onClick={() => setViewOpen(true)}
|
||||
>
|
||||
{plan.label}
|
||||
</h1>
|
||||
@@ -240,6 +262,155 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
|
||||
</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}
|
||||
@@ -321,8 +492,6 @@ export default function PlanList() {
|
||||
return () => clearInterval(refundTimerRef.current);
|
||||
}, [myTier?.active_tiers]);
|
||||
|
||||
const handleViewPlan = (plan) => navigate(`/subscriptions/view/${plan.plan_id}`);
|
||||
|
||||
const handleSelectPlan = (plan) => navigate(`/subscriptions/checkout?plan_id=${plan.plan_id}`);
|
||||
|
||||
const handleRefundClick = (plan) => setRefundPlan(plan);
|
||||
@@ -412,7 +581,6 @@ export default function PlanList() {
|
||||
myTier={myTier}
|
||||
tierMap={tierMap}
|
||||
onSelect={handleSelectPlan}
|
||||
onView={handleViewPlan}
|
||||
onRefund={handleRefundClick}
|
||||
refundSecsLeft={refundSecsLeftByTier[plan.plan_id] ?? 0}
|
||||
/>
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
ArrowLeft, BookOpen, Clock, Check, Lock,
|
||||
Tag, CalendarDays,
|
||||
} from "lucide-react";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import { useProfile } from "@/contexts/ProfileProvider";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
import { getTierColor } from "@/utils/tierColors";
|
||||
import { getBundleContent, overlapsExistingAccess, isPlanCurrent } from "@/utils/planBundle.util";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
|
||||
|
||||
function formatDuration(days, unit) {
|
||||
if (!days) return null;
|
||||
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 ViewPlanSkeleton = () => (
|
||||
<div className="mt-17 bg-muted min-h-screen">
|
||||
<Skeleton className="h-72 w-full" />
|
||||
<div className="p-6 lg:container lg:max-w-3xl lg:mx-auto space-y-4 mt-4">
|
||||
<Skeleton className="h-28 w-full rounded-2xl" />
|
||||
<Skeleton className="h-64 w-full rounded-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ViewPlan = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { myTier, getMyTier, plans, plansLoading, getPlans, tierMap, getTierCategories } = useClientTiers();
|
||||
const { getProfile } = useProfile();
|
||||
const { fmtCurrency } = useDateFormat();
|
||||
|
||||
useEffect(() => {
|
||||
getProfile();
|
||||
getMyTier();
|
||||
getTierCategories();
|
||||
if (!plans.length) getPlans();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
const plan = plans.find((p) => String(p.plan_id) === String(id)) ?? null;
|
||||
const loading = plansLoading;
|
||||
|
||||
if (loading) return <ViewPlanSkeleton />;
|
||||
if (!plan) return null;
|
||||
|
||||
const { label: tierLabel, cls: badgeCls } = resolveTierBadge(plan.tier, tierMap);
|
||||
const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag;
|
||||
const colors = getTierColor(tierMap[plan.tier]?.color ?? "purple");
|
||||
const accentSwatch = colors.swatch;
|
||||
const accentStyle = { color: accentSwatch };
|
||||
const accentBg = colors.panel.bg;
|
||||
const accentBorder = colors.panel.border;
|
||||
const features = plan.features ?? [];
|
||||
const duration = formatDuration(plan.duration_days, plan.duration_unit);
|
||||
// "Current" means the user actually purchased THIS exact plan (matched by
|
||||
// plan_id), not just any plan sharing the same tier slug — two different
|
||||
// plans can share a tier and stay independently active (Tier Plans v2).
|
||||
const isCurrent = isPlanCurrent(plan, myTier);
|
||||
// Blocked-by-overlap ("already covered") — plan isn't owned, but at least
|
||||
// one of its bundle items is already granted by something else the user
|
||||
// holds (Scenario 3): shown, not hidden, but purchase is disabled.
|
||||
const isBlockedByOverlap = !isCurrent && overlapsExistingAccess(plan, myTier?.my_grants);
|
||||
|
||||
const bundle = getBundleContent(plan);
|
||||
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);
|
||||
|
||||
return (
|
||||
<div className="mt-17 bg-muted min-h-screen">
|
||||
<PageMeta title={plan ? `${plan.label} - STARR` : undefined} />
|
||||
|
||||
{/* ── Hero Banner ───────────────────────────────────────────── */}
|
||||
<div className={`relative ${badgeCls} overflow-hidden`}>
|
||||
{/* Decorative blobs */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute -top-24 -right-24 w-96 h-96 rounded-full bg-white/5" />
|
||||
<div className="absolute -bottom-16 -left-16 w-72 h-72 rounded-full bg-white/5" />
|
||||
<div className="absolute top-1/2 left-1/3 w-48 h-48 rounded-full bg-white/5" />
|
||||
</div>
|
||||
|
||||
<div className="relative px-6 pt-8 pb-10 lg:container lg:max-w-3xl lg:mx-auto">
|
||||
<button
|
||||
onClick={() => navigate("/subscriptions")}
|
||||
className="flex items-center gap-1.5 text-white/70 hover:text-white text-sm mb-8 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="size-4" /> Back to Subscriptions
|
||||
</button>
|
||||
|
||||
<Badge className="bg-white/20 border border-white/30 text-white mb-4 text-sm px-3 py-1">
|
||||
<Icon className="size-3.5 mr-1" /> {tierLabel}
|
||||
</Badge>
|
||||
|
||||
<h1 className="text-3xl sm:text-4xl font-extrabold text-white mb-2 leading-tight tracking-tight">
|
||||
{plan.label}
|
||||
</h1>
|
||||
<p className="text-white/70 text-sm mb-8 max-w-md leading-relaxed">
|
||||
{plan.description || `Everything you need with the ${tierLabel} plan.`}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<span className="text-5xl 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-4 py-2 text-sm 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-4 py-2 text-sm font-medium">
|
||||
Not Available at the Moment
|
||||
</Badge>
|
||||
) : isBlockedByOverlap ? (
|
||||
<Badge className="bg-white/20 border border-white/30 text-white px-4 py-2 text-sm font-medium">
|
||||
<Lock className="size-3.5 mr-1" /> Already Included in Your Plan
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 lg:container lg:max-w-3xl lg:mx-auto space-y-5 py-6">
|
||||
|
||||
{/* ── Benefits ───────────────────────────────────── */}
|
||||
{features.length > 0 && (
|
||||
<div className={`rounded-2xl border ${accentBorder} ${accentBg} p-5`}>
|
||||
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={accentStyle}>
|
||||
Benefits
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{features.map(({ text }, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<div className={`h-8 w-8 rounded-lg ${accentBg} border ${accentBorder} flex items-center justify-center shrink-0`}>
|
||||
<Check className="size-4" style={accentStyle} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">{text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Stats row ─────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ 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-4 flex flex-col items-center gap-1 text-center">
|
||||
<StatIcon className="size-4" style={accentStyle} />
|
||||
<p className="text-2xl font-bold leading-none mt-1">{value}</p>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Included Items ─────────────────────────────────────── */}
|
||||
<div className="rounded-2xl bg-card border overflow-hidden">
|
||||
<div className="px-5 py-4 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">
|
||||
{bundle ? (
|
||||
bundle.items.map((item) => (
|
||||
<div key={item[bundle.idKey]} className="flex items-start gap-4 px-5 py-4">
|
||||
<div className={`h-10 w-10 rounded-xl ${accentBg} border ${accentBorder} flex items-center justify-center shrink-0`}>
|
||||
<bundle.icon className="size-5" 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>
|
||||
)}
|
||||
{item.course_code && (
|
||||
<span className="text-xs text-muted-foreground">{item.course_code}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6 w-6 rounded-full bg-green-100 dark:bg-green-950 flex items-center justify-center shrink-0">
|
||||
<Check className="size-3.5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center px-6">
|
||||
<div className={`h-14 w-14 rounded-2xl ${accentBg} border ${accentBorder} flex items-center justify-center mb-3`}>
|
||||
<BookOpen className="size-7" 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. Subscribe now and get instant access the moment they go live.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Bottom CTA ────────────────────────────────────────── */}
|
||||
{!isCurrent && (
|
||||
<div className={`rounded-2xl ${badgeCls} p-6 text-center relative overflow-hidden`}>
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 rounded-full bg-white/5" />
|
||||
<div className="absolute -bottom-8 -left-8 w-32 h-32 rounded-full bg-white/5" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<p className="text-white font-bold text-lg mb-1">
|
||||
{isBlockedByOverlap ? "Already Included" : plan.is_active ? "Ready to get started?" : "Coming Soon"}
|
||||
</p>
|
||||
<p className="text-white/70 text-sm mb-5 max-w-xs mx-auto">
|
||||
{isBlockedByOverlap
|
||||
? "Your existing access already covers item(s) in this bundle, so it can't be purchased separately."
|
||||
: plan.is_active
|
||||
? (plan.description || `Everything you need with the ${tierLabel} plan.`)
|
||||
: "This plan is not available for purchase at the moment. Check back later."}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
{plan.is_active && !isBlockedByOverlap && (
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-white text-gray-900 hover:bg-white/90 font-bold shadow-lg"
|
||||
onClick={() => navigate(`/subscriptions/checkout?plan_id=${plan.plan_id}`)}
|
||||
>
|
||||
Get {tierLabel} Plan — {fmtCurrency(plan.price, plan.currency)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewPlan;
|
||||
@@ -18,7 +18,6 @@ import ProfilePage from '../pages/Profile'
|
||||
import Checkout from '../pages/Checkout'
|
||||
import EditProfile from '../pages/EditProfile'
|
||||
import PlanList from '../pages/PlanList'
|
||||
import ViewPlan from '../pages/ViewPlan'
|
||||
import ViewTask from '../pages/ViewTask'
|
||||
import CourseCheckout from '../pages/CourseCheckout'
|
||||
import MyCertificates from '../pages/MyCertificates'
|
||||
@@ -75,7 +74,6 @@ export const ClientRoutes = {
|
||||
children: [
|
||||
// { index: true, element: <Checkout /> },
|
||||
{ index: true, element: <PlanList /> },
|
||||
{ path: 'view/:id', element: <ViewPlan /> },
|
||||
{ path: 'checkout', element: <Checkout /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -18,7 +18,6 @@ export const CLIENT_ROUTE_PATHS = [
|
||||
"notifications",
|
||||
"ads/:uuid",
|
||||
"subscriptions",
|
||||
"subscriptions/view/:id",
|
||||
"subscriptions/checkout",
|
||||
"course",
|
||||
"course/:id",
|
||||
|
||||
Reference in New Issue
Block a user