ready to test

Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-22 10:07:20 +08:00
parent 56d984a26a
commit fbef7cb6e6
283 changed files with 25961 additions and 1072 deletions
+259
View File
@@ -0,0 +1,259 @@
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Card, CardContent, 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 {
ArrowLeft, BookOpen, Clock, Check,
Tag, LockIcon, Zap, CalendarDays,
} from "lucide-react";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
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`;
}
const TIER_STYLES = {
free: {
badge: "bg-gradient-to-r from-lime-400 to-lime-600 text-white",
banner: "from-lime-50 to-green-50 dark:from-lime-950/30 dark:to-green-950/30 border-lime-200 dark:border-lime-800",
icon: Tag,
label: "Free",
button: "default",
},
premium: {
badge: "bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white",
banner: "from-fuchsia-50 to-purple-50 dark:from-fuchsia-950/30 dark:to-purple-950/30 border-fuchsia-200 dark:border-fuchsia-800",
icon: Zap,
label: "Premium",
button: "default",
},
exclusive: {
badge: "bg-gradient-to-r from-rose-500 to-red-600 text-white",
banner: "from-rose-50 to-red-50 dark:from-rose-950/30 dark:to-red-950/30 border-rose-200 dark:border-rose-800",
icon: LockIcon,
label: "Exclusive",
button: "default",
},
};
// ─── Skeleton ─────────────────────────────────────────────────────────────────
const ViewPlanSkeleton = () => (
<div className="mt-17 bg-muted min-h-screen">
<div className="p-6 lg:container lg:max-w-3xl lg:mx-auto space-y-4">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-40 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 } = useClientTiers();
useEffect(() => {
getMyTier();
if (!plans.length) getPlans();
}, [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 style = TIER_STYLES[plan.tier] ?? TIER_STYLES.free;
const Icon = style.icon;
const duration = formatDuration(plan.duration_days);
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} />
<div className="p-6 lg:container lg:max-w-3xl lg:mx-auto space-y-4">
{/* Back */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate("/plans")}>
<ArrowLeft className="size-4" />
</Button>
<div>
<h1 className="text-base font-semibold">Plan Details</h1>
<p className="text-xs text-muted-foreground">Review what's included before subscribing</p>
</div>
</div>
{/* Plan banner */}
<Card className={`bg-gradient-to-r ${style.banner} border`}>
<CardContent className="py-6 px-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="space-y-2">
<Badge className={style.badge}>
<Icon className="size-3.5" /> {style.label}
</Badge>
<h2 className="text-2xl font-bold">{plan.label}</h2>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-3xl font-bold">
{formatPrice(plan.price, plan.currency)}
</span>
{duration && (
<span className="text-sm text-muted-foreground flex items-center gap-1">
<CalendarDays className="size-3.5" /> {duration}
</span>
)}
</div>
</div>
{isCurrent ? (
<Badge className="bg-green-500 text-white self-start sm:self-center px-4 py-2 text-sm">
<Check className="size-3.5" /> Current Plan
</Badge>
) : (
<Button
size="lg"
className="self-start sm:self-center"
onClick={() => navigate(`/plans/checkout?plan_id=${plan.plan_id}`)}
>
Get {style.label} Plan
</Button>
)}
</div>
</CardContent>
</Card>
{/* Summary stats */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<Card>
<CardContent className=" flex flex-col gap-1">
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<BookOpen className="size-3.5" /> Courses
</p>
<p className="text-2xl font-bold">{plan.course_count ?? 0}</p>
</CardContent>
</Card>
<Card>
<CardContent className=" flex flex-col gap-1">
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Clock className="size-3.5" /> Total Duration
</p>
<p className="text-2xl font-bold">{totalDuration ?? "—"}</p>
</CardContent>
</Card>
<Card>
<CardContent className=" flex flex-col gap-1">
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<CalendarDays className="size-3.5" /> Access
</p>
<p className="text-2xl font-bold">{duration ?? "Lifetime"}</p>
</CardContent>
</Card>
</div>
{/* Included courses */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<BookOpen className="size-4" />
Included Courses
<Badge variant="secondary">{plan.course_count ?? 0}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{plan.courses?.length > 0 ? (
plan.courses.map((course, i) => (
<div key={course.course_id}>
{i > 0 && <Separator className="mb-3" />}
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center shrink-0">
<BookOpen className="size-4 text-secondary-foreground" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium line-clamp-2">{course.title}</p>
<div className="flex items-center gap-3 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>
<Check className="size-4 text-green-500 shrink-0 mt-0.5" />
</div>
</div>
))
) : (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground/50">
<BookOpen className="size-8 mb-2" />
<p className="text-sm">No courses assigned to this plan yet.</p>
</div>
)}
</CardContent>
</Card>
{/* Bottom CTA */}
{!isCurrent && (
<div className="flex justify-end gap-2 pb-6">
<Button variant="outline" onClick={() => navigate("/plans")}>
Back to Plans
</Button>
<Button
onClick={() => navigate(`/plans/checkout?plan_id=${plan.plan_id}`)}
>
Get {style.label} Plan — {formatPrice(plan.price, plan.currency)}
</Button>
</div>
)}
</div>
</div>
);
};
export default ViewPlan;