mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
547 lines
27 KiB
React
547 lines
27 KiB
React
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,
|
|
} from "@/components/ui/card";
|
|
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 {
|
|
BookOpen, Clock, Check,
|
|
Tag, RotateCcw, LaptopMinimal, Table as TableIcon,
|
|
} 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 PlanComparisonTable from "../components/PlanComparisonTable";
|
|
|
|
// ─── 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 ────────────────────────────────────────────────────────────────
|
|
|
|
const PREVIEW_COURSE_LIMIT = 2;
|
|
|
|
const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSecsLeft }) => {
|
|
const { fmtCurrency } = useDateFormat();
|
|
const [coursesOpen, setCoursesOpen] = useState(false);
|
|
const [notAvailableOpen, setNotAvailableOpen] = useState(false);
|
|
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 ${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={badgeCls}>
|
|
<Icon />
|
|
{tierLabel}
|
|
</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>
|
|
|
|
<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">
|
|
<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>
|
|
</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"
|
|
onClick={() => onSelect(plan)}
|
|
>
|
|
{plan.tier === "free" ? "Current" : `Get ${tierLabel}`}
|
|
</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>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setNotAvailableOpen(false)}>
|
|
Got it
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ── 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={`${badgeCls} shrink-0`}>
|
|
<Icon className="size-3" />
|
|
{tierLabel}
|
|
</Badge>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
{/* Price + Duration */}
|
|
<div className="flex items-baseline gap-1.5">
|
|
<span className="text-2xl font-bold">
|
|
{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">
|
|
{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 {tierLabel}
|
|
</Button>
|
|
</DialogFooter>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
};
|
|
|
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
|
|
|
export default function PlanList() {
|
|
const navigate = useNavigate();
|
|
const { plans, plansLoading, myTier, tierLoading, tierMap, getPlans, getMyTier, getTierCategories, resetMyTier } = useClientTiers();
|
|
const { fmtDate, fmtCurrency } = useDateFormat();
|
|
const { advertisements, loading: adLoading, getActiveAdvertisement, handleAdCtaClick } = useClientAdvertisements();
|
|
|
|
const [view, setView] = useState("grid");
|
|
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
|
|
const [refundLoading, setRefundLoading] = useState(false);
|
|
const [refundSecsLeft, setRefundSecsLeft] = useState(0);
|
|
const refundTimerRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
getPlans();
|
|
getMyTier();
|
|
getTierCategories();
|
|
getActiveAdvertisement("tier_plans.banner");
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [getPlans, getMyTier]);
|
|
|
|
const bannerAd = advertisements["tier_plans.banner"] ?? null;
|
|
|
|
useEffect(() => {
|
|
clearInterval(refundTimerRef.current);
|
|
if (!myTier?.starts_at) { setRefundSecsLeft(0); return; }
|
|
const compute = () => {
|
|
const elapsed = Math.floor((Date.now() - new Date(myTier.starts_at).getTime()) / 1000);
|
|
return Math.max(0, REFUND_WINDOW_SECS - elapsed);
|
|
};
|
|
setRefundSecsLeft(compute());
|
|
refundTimerRef.current = setInterval(() => {
|
|
const left = compute();
|
|
setRefundSecsLeft(left);
|
|
if (left === 0) clearInterval(refundTimerRef.current);
|
|
}, 1000);
|
|
return () => clearInterval(refundTimerRef.current);
|
|
}, [myTier?.starts_at]);
|
|
|
|
const handleViewPlan = (plan) => navigate(`/plans/view/${plan.plan_id}`);
|
|
|
|
const handleSelectPlan = (plan) => navigate(`/plans/checkout?plan_id=${plan.plan_id}`);
|
|
|
|
const handleRefundClick = (plan) => setRefundPlan(plan);
|
|
|
|
const handleConfirmRefund = async () => {
|
|
setRefundLoading(true);
|
|
try {
|
|
const { data } = await api.post("/client/tiers/checkout/refund");
|
|
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="Plans - STARR" description="Browse available subscription plans." />
|
|
<div className="bg-muted min-h-screen">
|
|
<div className="lg:container lg:mx-auto space-y-8 p-6">
|
|
|
|
{/* Advertisement Banner */}
|
|
{adLoading["tier_plans.banner"] ? (
|
|
<BannerSkeleton />
|
|
) : (
|
|
<Banner ad={bannerAd} onCtaClick={handleAdCtaClick} />
|
|
)}
|
|
|
|
{/* Section Header */}
|
|
<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 / 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>
|
|
|
|
{/* ── 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 || refundSecsLeft === 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>
|
|
{myTier?.expires_at && (
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Access until</span>
|
|
<span className="font-medium">
|
|
{fmtDate(myTier.expires_at)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between items-center pt-1 border-t">
|
|
<span className="text-muted-foreground">Refund window</span>
|
|
{refundSecsLeft > 0 ? (
|
|
<span className="font-semibold tabular-nums text-destructive">
|
|
{formatCountdown(refundSecsLeft)} remaining
|
|
</span>
|
|
) : (
|
|
<span className="font-semibold text-muted-foreground">Expired</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{refundSecsLeft > 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>{" "}
|
|
and your account will be downgraded to Free.
|
|
</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>
|
|
);
|
|
} |