mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -8,6 +8,10 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Megaphone, BookOpen, Clock, Check,
|
||||
Tag, LockIcon, Zap, RotateCcw,
|
||||
@@ -18,6 +22,7 @@ import { toast } from "sonner";
|
||||
import api from "@/utils/api.util";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { useCurrency } from "@/hooks/useCurrency";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -29,11 +34,13 @@ function formatCountdown(secs) {
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatDuration(days) {
|
||||
function formatDuration(days, unit) {
|
||||
if (!days) return null;
|
||||
if (days % 365 === 0) return `${days / 365} year${days / 365 > 1 ? "s" : ""}`;
|
||||
if (days % 30 === 0) return `${days / 30} month${days / 30 > 1 ? "s" : ""}`;
|
||||
return `${days} days`;
|
||||
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) {
|
||||
@@ -48,25 +55,25 @@ function formatCourseDuration(seconds = 0) {
|
||||
// Badge styles per tier
|
||||
const TIER_STYLES = {
|
||||
free: {
|
||||
badge: "bg-gradient-to-r from-lime-400 to-lime-600 text-white",
|
||||
badge: "bg-gradient-to-r from-lime-400 to-lime-600 text-white",
|
||||
button: "default",
|
||||
icon: Tag,
|
||||
label: "Free",
|
||||
ring: "",
|
||||
icon: Tag,
|
||||
label: "Free",
|
||||
ring: "",
|
||||
},
|
||||
premium: {
|
||||
badge: "bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
icon: LockIcon,
|
||||
label: "Exclusive",
|
||||
ring: "ring-2 ring-rose-400/40",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -94,104 +101,237 @@ const PlanSkeleton = () => (
|
||||
|
||||
// ─── Plan Card ────────────────────────────────────────────────────────────────
|
||||
|
||||
const PREVIEW_COURSE_LIMIT = 2;
|
||||
|
||||
const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft }) => {
|
||||
const { fmtCurrency } = useDateFormat();
|
||||
const style = TIER_STYLES[plan.tier] ?? TIER_STYLES.free;
|
||||
const Icon = style.icon;
|
||||
const { fmtPlanPrice } = useCurrency();
|
||||
const [coursesOpen, setCoursesOpen] = useState(false);
|
||||
const [notAvailableOpen, setNotAvailableOpen] = useState(false);
|
||||
const style = TIER_STYLES[plan.tier] ?? TIER_STYLES.free;
|
||||
const Icon = style.icon;
|
||||
const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
|
||||
const duration = formatDuration(plan.duration_days);
|
||||
const duration = formatDuration(plan.duration_days, plan.duration_unit);
|
||||
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}`}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{plan.label}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{isCurrent && (
|
||||
<Badge className="bg-green-500 text-white">Current Plan</Badge>
|
||||
<>
|
||||
<Card className={`relative flex flex-col ${style.ring}`}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{plan.label}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{isCurrent && (
|
||||
<Badge className="bg-green-500 text-white">Current Plan</Badge>
|
||||
)}
|
||||
<Badge className={style.badge}>
|
||||
<Icon />
|
||||
{style.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
<span className="text-3xl font-bold text-foreground">
|
||||
{fmtPlanPrice(plan)}
|
||||
</span>
|
||||
{duration && (
|
||||
<span className="text-sm text-muted-foreground ml-1">/ {duration}</span>
|
||||
)}
|
||||
<Badge className={style.badge}>
|
||||
<Icon />
|
||||
{style.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
<span className="text-3xl font-bold text-foreground">
|
||||
{fmtCurrency(plan.price, plan.currency)}
|
||||
</span>
|
||||
{duration && (
|
||||
<span className="text-sm text-muted-foreground ml-1">/ {duration}</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 space-y-4">
|
||||
{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">
|
||||
<BookOpen className="size-3.5" />
|
||||
Course{plan.course_count !== 1 ? "s" : ""} Included
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{plan.courses.map((course) => (
|
||||
<li key={course.course_id} className="flex items-start gap-2 text-sm">
|
||||
<Check className="size-3.5 mt-0.5 shrink-0 text-green-500" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="line-clamp-1">{course.title}</span>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{course.level && (
|
||||
<span className="text-xs text-muted-foreground capitalize">{course.level}</span>
|
||||
)}
|
||||
{formatCourseDuration(course.duration_seconds) && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{formatCourseDuration(course.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
<CardContent className="flex-1 space-y-4">
|
||||
|
||||
{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">
|
||||
<BookOpen className="size-3.5" />
|
||||
Course{plan.course_count !== 1 ? "s" : ""} Included
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{previewCourses.map((course) => (
|
||||
<li key={course.course_id} className="flex items-start gap-2 text-sm">
|
||||
<Check className="size-3.5 mt-0.5 shrink-0 text-green-500" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="line-clamp-1">{course.title}</span>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{course.level && (
|
||||
<span className="text-xs text-muted-foreground capitalize">{course.level}</span>
|
||||
)}
|
||||
{formatCourseDuration(course.duration_seconds) && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{formatCourseDuration(course.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Access to all free course content.
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{extraCount > 0 && (
|
||||
<Badge
|
||||
type="button"
|
||||
// onClick={() => setCoursesOpen(true)}
|
||||
// DIALOG DISABLED
|
||||
variant="secondary"
|
||||
|
||||
>
|
||||
+{extraCount} more course{extraCount !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
) : !plan.description ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Access to all free course content.
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
<Separator />
|
||||
|
||||
<CardFooter className="flex gap-2 pt-4">
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="outline"
|
||||
onClick={() => onView(plan)}
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
{isCurrent && refundSecsLeft > 0 ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="destructive"
|
||||
onClick={() => onRefund(plan)}
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
Refund ({formatCountdown(refundSecsLeft)})
|
||||
</Button>
|
||||
) : !plan.is_active ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="secondary"
|
||||
onClick={() => setNotAvailableOpen(true)}
|
||||
>
|
||||
Not Available
|
||||
</Button>
|
||||
) : !isCurrent ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant={style.button}
|
||||
onClick={() => onSelect(plan)}
|
||||
>
|
||||
{plan.tier === "free" ? "Current" : `Get ${style.label}`}
|
||||
</Button>
|
||||
) : null}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* ── 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>
|
||||
)}
|
||||
</CardContent>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setNotAvailableOpen(false)}>
|
||||
Got it
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Separator />
|
||||
{/* ── Plan Detail Dialog ─────────────────────────────────────────── */}
|
||||
{/* Current disabled from line 170 to 172 */}
|
||||
<Dialog open={coursesOpen} onOpenChange={setCoursesOpen}>
|
||||
<DialogContent className="sm:max-w-[calc(100%-55rem)]">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start gap-2">
|
||||
<DialogTitle className="leading-snug">{plan.label}</DialogTitle>
|
||||
<Badge className={`${style.badge} shrink-0`}>
|
||||
<Icon className="size-3" />
|
||||
{style.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<CardFooter className="flex gap-2 pt-4">
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="outline"
|
||||
onClick={() => onView(plan)}
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
{isCurrent && refundSecsLeft > 0 ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="destructive"
|
||||
onClick={() => onRefund(plan)}
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
Refund ({formatCountdown(refundSecsLeft)})
|
||||
</Button>
|
||||
) : !isCurrent ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant={style.button}
|
||||
onClick={() => onSelect(plan)}
|
||||
>
|
||||
{plan.tier === "free" ? "Current" : `Get ${style.label}`}
|
||||
</Button>
|
||||
) : null}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
{/* Price + Duration */}
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-2xl font-bold">
|
||||
{fmtPlanPrice(plan)}
|
||||
</span>
|
||||
{duration && (
|
||||
<span className="text-sm text-muted-foreground">/ {duration}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{plan.description && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed -mt-1">
|
||||
{plan.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Courses horizontal scroll */}
|
||||
{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">
|
||||
<BookOpen className="size-3.5" />
|
||||
{plan.courses.length} Course{plan.courses.length !== 1 ? "s" : ""} Included
|
||||
</p>
|
||||
<ScrollArea className="w-md whitespace-nowrap">
|
||||
<div className="flex gap-3 pb-3 pt-1 w-max">
|
||||
{plan.courses.map((course) => (
|
||||
<div
|
||||
key={course.course_id}
|
||||
className="w-40 shrink-0 rounded-xl border bg-muted/50 p-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<Check className="size-3.5 mt-0.5 shrink-0 text-green-500" />
|
||||
<p className="text-xs font-medium leading-snug line-clamp-3">
|
||||
{course.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{course.level && (
|
||||
<span className="text-[11px] text-muted-foreground capitalize">
|
||||
{course.level}
|
||||
</span>
|
||||
)}
|
||||
{formatCourseDuration(course.duration_seconds) && (
|
||||
<span className="text-[11px] text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{formatCourseDuration(course.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCurrent && plan.tier !== "free" && (
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => { setCoursesOpen(false); onSelect(plan); }}
|
||||
>
|
||||
Get {style.label}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -200,9 +340,10 @@ 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 { fmtCurrency, fmtDate } = useDateFormat();
|
||||
const { fmtDate } = useDateFormat();
|
||||
const { fmtPlanPrice } = useCurrency();
|
||||
|
||||
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
|
||||
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
|
||||
const [refundLoading, setRefundLoading] = useState(false);
|
||||
const [refundSecsLeft, setRefundSecsLeft] = useState(0);
|
||||
const refundTimerRef = useRef(null);
|
||||
@@ -345,7 +486,7 @@ export default function PlanList() {
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Refund amount</span>
|
||||
<span className="font-medium">
|
||||
{refundPlan ? fmtCurrency(refundPlan.price, refundPlan.currency) : "—"}
|
||||
{refundPlan ? fmtPlanPrice(refundPlan) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
{myTier?.expires_at && (
|
||||
|
||||
Reference in New Issue
Block a user