mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
286 lines
16 KiB
React
286 lines
16 KiB
React
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,
|
|
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";
|
|
|
|
// ─── 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);
|
|
const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
|
|
|
|
const totalSeconds = (plan.courses ?? []).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("/plans")}
|
|
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 Plans
|
|
</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>
|
|
) : (
|
|
<Button
|
|
size="lg"
|
|
className="bg-white text-gray-900 hover:bg-white/90 font-bold shadow-xl"
|
|
onClick={() => navigate(`/plans/checkout?plan_id=${plan.plan_id}`)}
|
|
>
|
|
Get Started →
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-6 lg:container lg:max-w-3xl lg:mx-auto space-y-5 py-6">
|
|
|
|
{/* ── What's included ───────────────────────────────────── */}
|
|
{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}>
|
|
What's included
|
|
</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: "Courses", value: plan.course_count ?? 0, icon: BookOpen },
|
|
{ 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 Courses ──────────────────────────────────── */}
|
|
<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">
|
|
<BookOpen className="size-4" style={accentStyle} />
|
|
<span className="text-sm font-semibold">Included Courses</span>
|
|
</div>
|
|
<Badge variant="secondary">{plan.course_count ?? 0}</Badge>
|
|
</div>
|
|
|
|
<div className="divide-y">
|
|
{plan.courses?.length > 0 ? (
|
|
plan.courses.map((course) => (
|
|
<div key={course.course_id} 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`}>
|
|
<BookOpen className="size-5" style={accentStyle} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-semibold line-clamp-1">{course.title}</p>
|
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
|
{course.level && (
|
|
<Badge variant="outline" className="text-xs capitalize h-5">
|
|
{course.level}
|
|
</Badge>
|
|
)}
|
|
{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>
|
|
)}
|
|
{course.course_code && (
|
|
<span className="text-xs text-muted-foreground">{course.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">Courses 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">
|
|
{plan.is_active ? "Ready to get started?" : "Coming Soon"}
|
|
</p>
|
|
<p className="text-white/70 text-sm mb-5 max-w-xs mx-auto">
|
|
{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">
|
|
<Button
|
|
variant="ghost"
|
|
className="text-white/80 hover:text-white hover:bg-white/10"
|
|
onClick={() => navigate("/plans")}
|
|
>
|
|
View All Plans
|
|
</Button>
|
|
{plan.is_active && (
|
|
<Button
|
|
size="lg"
|
|
className="bg-white text-gray-900 hover:bg-white/90 font-bold shadow-lg"
|
|
onClick={() => navigate(`/plans/checkout?plan_id=${plan.plan_id}`)}
|
|
>
|
|
Get {tierLabel} Plan — {fmtCurrency(plan.price, plan.currency)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ViewPlan;
|