mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
ready to test
Testing Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
import { useEffect, 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 { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Megaphone, BookOpen, Clock, Check,
|
||||
Tag, LockIcon, Zap, RotateCcw,
|
||||
} 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";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatPrice(price, currency = "USD") {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currency,
|
||||
minimumFractionDigits: 2,
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
function formatDuration(days) {
|
||||
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`;
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
// 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 = () => (
|
||||
<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 PlanCard = ({ plan, myTier, onSelect, onView, onRefund }) => {
|
||||
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);
|
||||
|
||||
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>
|
||||
)}
|
||||
<Badge className={style.badge}>
|
||||
<Icon />
|
||||
{style.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
<span className="text-3xl font-bold text-foreground">
|
||||
{formatPrice(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">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Access to all free course content.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<Separator />
|
||||
|
||||
<CardFooter className="flex gap-2 pt-4">
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="outline"
|
||||
onClick={() => onView(plan)}
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
{isCurrent ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="destructive"
|
||||
onClick={() => onRefund(plan)}
|
||||
>
|
||||
<RotateCcw className="size-4" /> Refund
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant={style.button}
|
||||
onClick={() => onSelect(plan)}
|
||||
>
|
||||
{plan.tier === "free" ? "Current" : `Get ${style.label}`}
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PlanList() {
|
||||
const navigate = useNavigate();
|
||||
const { plans, plansLoading, myTier, tierLoading, getPlans, getMyTier, resetMyTier } = useClientTiers();
|
||||
|
||||
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
|
||||
const [refundLoading, setRefundLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getPlans();
|
||||
getMyTier();
|
||||
}, [getPlans, getMyTier]);
|
||||
|
||||
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.success(data.message ?? "Refund processed. Access remains until end of billing period.");
|
||||
setRefundPlan(null);
|
||||
resetMyTier();
|
||||
getMyTier();
|
||||
} catch (err) {
|
||||
toast.error(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 */}
|
||||
<Card className="overflow-hidden border-primary/20 bg-gradient-to-r from-primary/10 via-primary/5 to-background">
|
||||
<CardContent className="flex flex-col gap-4 py-20 md:flex-row md:items-center md:justify-between pl-10">
|
||||
<div className="space-y-2">
|
||||
<Badge>
|
||||
<Megaphone /> Limited Time Offer
|
||||
</Badge>
|
||||
<div className="max-w-xl">
|
||||
<h1 className="text-3xl font-bold line-clamp-3 leading-relaxed">
|
||||
Upgrade Your Learning Journey
|
||||
</h1>
|
||||
<p className="mt-2 text-sm line-clamp-3 text-muted-foreground">
|
||||
Unlock premium courses, certificates, and exclusive educational content.
|
||||
Get access to industry-leading materials and grow your skills today.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Section Header */}
|
||||
<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>
|
||||
|
||||
{/* 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) => (
|
||||
<PlanCard
|
||||
key={plan.plan_id}
|
||||
plan={plan}
|
||||
myTier={myTier}
|
||||
onSelect={handleSelectPlan}
|
||||
onView={handleViewPlan}
|
||||
onRefund={handleRefundClick}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</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}
|
||||
>
|
||||
<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 ? formatPrice(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">
|
||||
{new Date(myTier.expires_at).toLocaleDateString("en-US", {
|
||||
month: "long", day: "numeric", year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your refund will be processed through PayPal. You will retain access to your current plan until{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{myTier?.expires_at
|
||||
? new Date(myTier.expires_at).toLocaleDateString("en-US", {
|
||||
month: "long", day: "numeric", year: "numeric",
|
||||
})
|
||||
: "the end of the billing period"}
|
||||
</span>.
|
||||
</p>
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user