mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
units,lesson as standalone
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
import {
|
||||
Card, CardContent, CardDescription,
|
||||
CardFooter, CardHeader, CardTitle,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
BookOpen, Clock, Check,
|
||||
Tag, LockIcon, Zap, RotateCcw,
|
||||
Tag, RotateCcw, LaptopMinimal, Table as TableIcon,
|
||||
} from "lucide-react";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||
@@ -24,6 +25,8 @@ 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 PlanComparisonTable from "../components/PlanComparisonTable";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -53,31 +56,6 @@ function formatCourseDuration(seconds = 0) {
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
// Badge styles per tier
|
||||
const TIER_STYLES = {
|
||||
free: {
|
||||
badge: "bg-gradient-to-r from-lime-400 to-lime-600 text-white",
|
||||
button: "default",
|
||||
icon: Tag,
|
||||
label: "Free",
|
||||
ring: "",
|
||||
},
|
||||
premium: {
|
||||
badge: "bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white",
|
||||
button: "default",
|
||||
icon: Zap,
|
||||
label: "Premium",
|
||||
ring: "ring-2 ring-fuchsia-400/40",
|
||||
},
|
||||
exclusive: {
|
||||
badge: "bg-gradient-to-r from-rose-500 to-red-600 text-white",
|
||||
button: "default",
|
||||
icon: LockIcon,
|
||||
label: "Exclusive",
|
||||
ring: "ring-2 ring-rose-400/40",
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Skeleton ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const PlanSkeleton = () => (
|
||||
@@ -104,20 +82,22 @@ const PlanSkeleton = () => (
|
||||
|
||||
const PREVIEW_COURSE_LIMIT = 2;
|
||||
|
||||
const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft }) => {
|
||||
const fmtPlanPrice = (p) => `${p.currency} ${Number(p.price).toFixed(2)}`;
|
||||
const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSecsLeft }) => {
|
||||
const { fmtCurrency } = useDateFormat();
|
||||
const [coursesOpen, setCoursesOpen] = useState(false);
|
||||
const [notAvailableOpen, setNotAvailableOpen] = useState(false);
|
||||
const style = TIER_STYLES[plan.tier] ?? TIER_STYLES.free;
|
||||
const Icon = style.icon;
|
||||
const { label: tierLabel, cls: badgeCls, rank } = resolveTierBadge(plan.tier, tierMap);
|
||||
const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag;
|
||||
const ring = rank > 0 ? "ring-2 ring-primary/30" : "";
|
||||
const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
|
||||
const duration = formatDuration(plan.duration_days, plan.duration_unit);
|
||||
const features = plan.features ?? [];
|
||||
const previewCourses = plan.courses?.slice(0, PREVIEW_COURSE_LIMIT) ?? [];
|
||||
const extraCount = (plan.courses?.length ?? 0) - PREVIEW_COURSE_LIMIT;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={`relative flex flex-col ${style.ring}`}>
|
||||
<Card className={`relative flex flex-col ${ring}`}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{plan.label}</CardTitle>
|
||||
@@ -125,15 +105,15 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
|
||||
{isCurrent && (
|
||||
<Badge className="bg-green-500 text-white">Current Plan</Badge>
|
||||
)}
|
||||
<Badge className={style.badge}>
|
||||
<Badge className={badgeCls}>
|
||||
<Icon />
|
||||
{style.label}
|
||||
{tierLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
<span className="text-3xl font-bold text-foreground">
|
||||
{fmtPlanPrice(plan)}
|
||||
{fmtCurrency(plan.price, plan.currency)}
|
||||
</span>
|
||||
{duration && (
|
||||
<span className="text-sm text-muted-foreground ml-1">/ {duration}</span>
|
||||
@@ -142,7 +122,18 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 space-y-4">
|
||||
|
||||
|
||||
{features.length > 0 && (
|
||||
<ul className="space-y-1.5">
|
||||
{features.slice(0, 4).map((f, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm">
|
||||
<Check className="size-3.5 mt-0.5 shrink-0 text-green-500" />
|
||||
<span>{f.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{plan.courses?.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1.5">
|
||||
@@ -219,10 +210,9 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
|
||||
) : !isCurrent ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant={style.button}
|
||||
onClick={() => onSelect(plan)}
|
||||
>
|
||||
{plan.tier === "free" ? "Current" : `Get ${style.label}`}
|
||||
{plan.tier === "free" ? "Current" : `Get ${tierLabel}`}
|
||||
</Button>
|
||||
) : null}
|
||||
</CardFooter>
|
||||
@@ -253,9 +243,9 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
|
||||
<DialogHeader>
|
||||
<div className="flex items-start gap-2">
|
||||
<DialogTitle className="leading-snug">{plan.label}</DialogTitle>
|
||||
<Badge className={`${style.badge} shrink-0`}>
|
||||
<Badge className={`${badgeCls} shrink-0`}>
|
||||
<Icon className="size-3" />
|
||||
{style.label}
|
||||
{tierLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
@@ -263,13 +253,25 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
|
||||
{/* Price + Duration */}
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-2xl font-bold">
|
||||
{fmtPlanPrice(plan)}
|
||||
{fmtCurrency(plan.price, plan.currency)}
|
||||
</span>
|
||||
{duration && (
|
||||
<span className="text-sm text-muted-foreground">/ {duration}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
{features.length > 0 && (
|
||||
<ul className="space-y-1.5">
|
||||
{features.map((f, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm">
|
||||
<Check className="size-3.5 mt-0.5 shrink-0 text-green-500" />
|
||||
<span>{f.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{plan.description && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed -mt-1">
|
||||
@@ -326,7 +328,7 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
|
||||
className="w-full"
|
||||
onClick={() => { setCoursesOpen(false); onSelect(plan); }}
|
||||
>
|
||||
Get {style.label}
|
||||
Get {tierLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
@@ -340,11 +342,11 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
|
||||
|
||||
export default function PlanList() {
|
||||
const navigate = useNavigate();
|
||||
const { plans, plansLoading, myTier, tierLoading, getPlans, getMyTier, resetMyTier } = useClientTiers();
|
||||
const { fmtDate } = useDateFormat();
|
||||
const { plans, plansLoading, myTier, tierLoading, tierMap, getPlans, getMyTier, getTierCategories, resetMyTier } = useClientTiers();
|
||||
const { fmtDate, fmtCurrency } = useDateFormat();
|
||||
const { advertisements, loading: adLoading, getActiveAdvertisement, handleAdCtaClick } = useClientAdvertisements();
|
||||
const fmtPlanPrice = (p) => `${p.currency} ${Number(p.price).toFixed(2)}`;
|
||||
|
||||
const [view, setView] = useState("grid");
|
||||
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
|
||||
const [refundLoading, setRefundLoading] = useState(false);
|
||||
const [refundSecsLeft, setRefundSecsLeft] = useState(0);
|
||||
@@ -353,6 +355,7 @@ export default function PlanList() {
|
||||
useEffect(() => {
|
||||
getPlans();
|
||||
getMyTier();
|
||||
getTierCategories();
|
||||
getActiveAdvertisement("plans.banner");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [getPlans, getMyTier]);
|
||||
@@ -410,36 +413,59 @@ export default function PlanList() {
|
||||
)}
|
||||
|
||||
{/* Section Header */}
|
||||
<div className="text-center mt-6">
|
||||
<h2 className="text-3xl font-bold">Available Plans</h2>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Choose a subscription that matches your goals.
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-4 mt-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold">Available Plans</h2>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Choose a subscription that matches your goals.
|
||||
</p>
|
||||
</div>
|
||||
{!plansLoading && plans.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant={view === "grid" ? "default" : "outline"} onClick={() => setView("grid")}>
|
||||
<LaptopMinimal /> Cards
|
||||
</Button>
|
||||
<Button size="sm" variant={view === "table" ? "default" : "outline"} onClick={() => setView("table")}>
|
||||
<TableIcon /> Compare
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plan Cards */}
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{plansLoading || tierLoading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => <PlanSkeleton key={i} />)
|
||||
) : plans.length === 0 ? (
|
||||
<div className="col-span-full 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>
|
||||
) : (
|
||||
plans.map((plan) => (
|
||||
{/* Plan Cards / Comparison Table */}
|
||||
{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>
|
||||
) : view === "table" ? (
|
||||
<PlanComparisonTable
|
||||
plans={plans}
|
||||
myTier={myTier}
|
||||
tierMap={tierMap}
|
||||
fmtCurrency={fmtCurrency}
|
||||
onSelect={handleSelectPlan}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{plans.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.plan_id}
|
||||
plan={plan}
|
||||
myTier={myTier}
|
||||
tierMap={tierMap}
|
||||
onSelect={handleSelectPlan}
|
||||
onView={handleViewPlan}
|
||||
onRefund={handleRefundClick}
|
||||
refundSecsLeft={refundSecsLeft}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -479,7 +505,7 @@ export default function PlanList() {
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Refund amount</span>
|
||||
<span className="font-medium">
|
||||
{refundPlan ? fmtPlanPrice(refundPlan) : "—"}
|
||||
{refundPlan ? fmtCurrency(refundPlan.price, refundPlan.currency) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
{myTier?.expires_at && (
|
||||
|
||||
Reference in New Issue
Block a user