mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -465,7 +465,7 @@ export default function AddPlan() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>What's included</Label>
|
<Label>Benefits</Label>
|
||||||
<p className="text-xs text-muted-foreground -mt-1">
|
<p className="text-xs text-muted-foreground -mt-1">
|
||||||
Bullet points shown on the plans page and the comparison table.
|
Bullet points shown on the plans page and the comparison table.
|
||||||
</p>
|
</p>
|
||||||
@@ -593,7 +593,7 @@ export default function AddPlan() {
|
|||||||
|
|
||||||
{featureFields.length > 0 && (
|
{featureFields.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">What's included</Label>
|
<Label className="text-xs text-muted-foreground">Benefits</Label>
|
||||||
<ul className="text-sm space-y-1 list-disc list-inside">
|
<ul className="text-sm space-y-1 list-disc list-inside">
|
||||||
{watch("features")?.map((f, i) => (
|
{watch("features")?.map((f, i) => (
|
||||||
<li key={i}>{f.text}</li>
|
<li key={i}>{f.text}</li>
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const schema = z.object({
|
|||||||
price: z.coerce.number().min(0.01, "Price must be greater than 0."),
|
price: z.coerce.number().min(0.01, "Price must be greater than 0."),
|
||||||
currency: z.string().length(3),
|
currency: z.string().length(3),
|
||||||
is_active: z.boolean().default(true),
|
is_active: z.boolean().default(true),
|
||||||
|
is_recommended: z.boolean().default(false),
|
||||||
status: z.enum(["draft", "published"]).default("draft"),
|
status: z.enum(["draft", "published"]).default("draft"),
|
||||||
}).superRefine(({ duration_value, duration_unit }, ctx) => {
|
}).superRefine(({ duration_value, duration_unit }, ctx) => {
|
||||||
const rule = DURATION_UNIT_LIMITS[duration_unit];
|
const rule = DURATION_UNIT_LIMITS[duration_unit];
|
||||||
@@ -150,6 +151,7 @@ export default function EditPlan() {
|
|||||||
price: plan.price,
|
price: plan.price,
|
||||||
currency: plan.currency,
|
currency: plan.currency,
|
||||||
is_active: plan.is_active,
|
is_active: plan.is_active,
|
||||||
|
is_recommended: plan.is_recommended ?? false,
|
||||||
status: plan.status ?? "draft",
|
status: plan.status ?? "draft",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -317,7 +319,7 @@ export default function EditPlan() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>What's included</Label>
|
<Label>Benefits</Label>
|
||||||
<p className="text-xs text-muted-foreground -mt-1">
|
<p className="text-xs text-muted-foreground -mt-1">
|
||||||
Bullet points shown on the plans page and the comparison table.
|
Bullet points shown on the plans page and the comparison table.
|
||||||
</p>
|
</p>
|
||||||
@@ -427,6 +429,17 @@ export default function EditPlan() {
|
|||||||
onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })}
|
onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Recommended for you</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Highlights this plan with a "Recommended for you" banner on the client Plans page.</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={watch("is_recommended") ?? false}
|
||||||
|
onCheckedChange={(v) => setValue("is_recommended", v, { shouldDirty: true })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{plan?.tier && (
|
{plan?.tier && (
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoadin
|
|||||||
{plan.status ?? "draft"}
|
{plan.status ?? "draft"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</InfoRow>
|
</InfoRow>
|
||||||
|
<InfoRow label="Recommended">
|
||||||
|
<Badge variant={plan.is_recommended ? "default" : "secondary"} className="mt-0.5">
|
||||||
|
{plan.is_recommended ? "Yes" : "No"}
|
||||||
|
</Badge>
|
||||||
|
</InfoRow>
|
||||||
<InfoRow label="Active">
|
<InfoRow label="Active">
|
||||||
<Badge variant={plan.is_active ? "default" : "secondary"} className="mt-0.5">
|
<Badge variant={plan.is_active ? "default" : "secondary"} className="mt-0.5">
|
||||||
{plan.is_active ? "Active" : "Inactive"}
|
{plan.is_active ? "Active" : "Inactive"}
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import * as LucideIcons from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
Card, CardContent, CardDescription,
|
Card, CardContent, CardDescription,
|
||||||
CardFooter, CardHeader, CardTitle,
|
CardFooter, CardHeader, CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
|
||||||
import {
|
import {
|
||||||
BookOpen, Clock, Check, Lock,
|
BookOpen, Star, Lock, Sparkles,
|
||||||
Tag, RotateCcw, Plus,
|
RotateCcw, Plus, Ban, BadgeCheck,
|
||||||
|
GraduationCap, Book, Medal,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||||
@@ -27,6 +25,7 @@ import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
|
|||||||
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
|
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
|
||||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||||
import { getBundleContent, overlapsExistingAccess, isPlanCurrent } from "@/utils/planBundle.util";
|
import { getBundleContent, overlapsExistingAccess, isPlanCurrent } from "@/utils/planBundle.util";
|
||||||
|
import { TablePagination } from "@/components/generic/Table/TablePagination";
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -47,15 +46,6 @@ function formatDuration(days, unit) {
|
|||||||
return `${value} ${label}${value !== 1 ? "s" : ""}`;
|
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 ──────────────────────────────────────────────────────────────────
|
// ─── Skeleton ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -81,14 +71,32 @@ const PlanSkeleton = () => (
|
|||||||
|
|
||||||
// ─── Plan Card ────────────────────────────────────────────────────────────────
|
// ─── Plan Card ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const PREVIEW_COURSE_LIMIT = 2;
|
function ChecklistItem({ item, icon: Icon = Star, onClick }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 py-1.5 text-sm ${onClick ? "cursor-pointer hover:underline w-fit" : ""}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Icon className="text-blue-500 shrink-0" size={16} />
|
||||||
|
<span className="line-clamp-1">{item}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-bundle-type icon for "Benefits" rows — distinguishes a course
|
||||||
|
// grant from a unit or lesson grant at a glance instead of a flat checkmark.
|
||||||
|
const BUNDLE_ITEM_ICONS = { course: GraduationCap, unit: Book, lesson: Medal };
|
||||||
|
|
||||||
const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSecsLeft }) => {
|
const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSecsLeft }) => {
|
||||||
const { fmtCurrency } = useDateFormat();
|
const { fmtCurrency } = useDateFormat();
|
||||||
const [coursesOpen, setCoursesOpen] = useState(false);
|
|
||||||
const [notAvailableOpen, setNotAvailableOpen] = useState(false);
|
const [notAvailableOpen, setNotAvailableOpen] = useState(false);
|
||||||
const { label: tierLabel, cls: badgeCls } = resolveTierBadge(plan.tier, tierMap);
|
const [previewItem, setPreviewItem] = useState(null);
|
||||||
const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag;
|
const { label: tierLabel, cls: badgeCls, panel, gradient } = resolveTierBadge(plan.tier, tierMap);
|
||||||
|
// Admin-controlled — set from the "Recommended for you" toggle on the
|
||||||
|
// plan's Edit Plan page, drives the featured frame treatment below.
|
||||||
|
// Color comes from the plan's own tier category's actual gradient (same
|
||||||
|
// one used for its badge), not a hardcoded blue or a pale flat tint.
|
||||||
|
const isRecommended = !!plan.is_recommended;
|
||||||
// "Current" means the user actually purchased THIS exact plan (matched by
|
// "Current" means the user actually purchased THIS exact plan (matched by
|
||||||
// plan_id) and it's still active — NOT just any plan sharing the same tier
|
// plan_id) and it's still active — NOT just any plan sharing the same tier
|
||||||
// slug. Two different plans can be the same tier (e.g. two Premium
|
// slug. Two different plans can be the same tier (e.g. two Premium
|
||||||
@@ -99,18 +107,121 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
|
|||||||
// else the user holds. Card stays visible but disabled (Scenario 3).
|
// else the user holds. Card stays visible but disabled (Scenario 3).
|
||||||
const isBlockedByOverlap = !isCurrent && overlapsExistingAccess(plan, myTier?.my_grants);
|
const isBlockedByOverlap = !isCurrent && overlapsExistingAccess(plan, myTier?.my_grants);
|
||||||
const duration = formatDuration(plan.duration_days, plan.duration_unit);
|
const duration = formatDuration(plan.duration_days, plan.duration_unit);
|
||||||
const features = plan.features ?? [];
|
|
||||||
const bundle = getBundleContent(plan);
|
const bundle = getBundleContent(plan);
|
||||||
const previewItems = bundle?.items?.slice(0, PREVIEW_COURSE_LIMIT) ?? [];
|
// Footer button state — always visible when there's an actual action to
|
||||||
const extraCount = (bundle?.items?.length ?? 0) - PREVIEW_COURSE_LIMIT;
|
// take. A not-yet-owned free plan has nothing to do (it's the default),
|
||||||
const hasFooterAction = (isCurrent && refundSecsLeft > 0) || !plan.is_active || isBlockedByOverlap || (isCurrent && plan.tier !== "free");
|
// so it's the one case with no button. Base color follows the recommended
|
||||||
|
// flag (dark = recommended, light = plain), overridden for refund/blocked.
|
||||||
|
let footerLabel = null;
|
||||||
|
let footerAction = null;
|
||||||
|
let footerDisabled = false;
|
||||||
|
let footerBtnCls = "";
|
||||||
|
|
||||||
|
if (isCurrent && refundSecsLeft > 0) {
|
||||||
|
footerLabel = <><RotateCcw className="size-4" /> Refund ({formatCountdown(refundSecsLeft)})</>;
|
||||||
|
footerAction = () => onRefund(plan);
|
||||||
|
footerBtnCls = "bg-red-600 hover:bg-red-700 text-white";
|
||||||
|
} else if (!plan.is_active) {
|
||||||
|
footerLabel = <><Ban className="size-4" /> Not Available</>;
|
||||||
|
footerAction = () => setNotAvailableOpen(true);
|
||||||
|
footerDisabled = true;
|
||||||
|
footerBtnCls = "bg-gray-100 text-slate-800 dark:bg-primary dark:text-primary-foreground shadow-none";
|
||||||
|
} else if (isBlockedByOverlap) {
|
||||||
|
footerLabel = <><Lock className="size-4" /> Already Included</>;
|
||||||
|
footerDisabled = true;
|
||||||
|
footerBtnCls = "bg-gray-100 text-slate-400 shadow-none";
|
||||||
|
} else if (isCurrent && plan.tier !== "free") {
|
||||||
|
footerLabel = <><BadgeCheck className="size-4" /> Extend {tierLabel}</>;
|
||||||
|
footerAction = () => onSelect(plan);
|
||||||
|
} else if (!isCurrent && plan.tier !== "free") {
|
||||||
|
footerLabel = <><Sparkles className="size-4" /> Get {tierLabel}</>;
|
||||||
|
footerAction = () => onSelect(plan);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="border">
|
{/* Actual Card */}
|
||||||
askodasodkas
|
<div
|
||||||
|
className={`flex flex-col h-full rounded-2xl px-2 pb-2 relative ${isRecommended ? `${gradient} border-transparent` : "bg-transparent border border-transparent"
|
||||||
|
} ${isBlockedByOverlap ? "opacity-60" : ""}`}
|
||||||
|
>
|
||||||
|
<div className={`text-center text-sm font-medium my-2 ${isRecommended ? "text-white" : "text-slate-500 invisible"}`}>
|
||||||
|
Recommended for you
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex flex-col flex-1 rounded-xl p-6 relative overflow-hidden ${isRecommended ? "bg-card" : "bg-card border border-gray-100 dark:border-blue-900 shadow-sm"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
|
||||||
|
<div className="relative space-y-1.5 max-w-sm">
|
||||||
|
<h1
|
||||||
|
className="font-bold text-xl leading-relaxed hover:underline cursor-pointer w-fit"
|
||||||
|
onClick={() => onView(plan)}
|
||||||
|
>
|
||||||
|
{plan.label}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-500 leading-relaxed line-clamp-2">
|
||||||
|
{plan.description || "No information provided"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex items-center gap-1.5 mt-3 flex-wrap">
|
||||||
|
{isBlockedByOverlap && (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Lock className="size-3" />
|
||||||
|
Already Included
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex flex-col items-start gap-4 my-6">
|
||||||
|
<div className="flex items-end gap-1">
|
||||||
|
<h1 className="text-5xl font-bold">
|
||||||
|
{fmtCurrency(plan.price, plan.currency)}
|
||||||
|
</h1>
|
||||||
|
{duration && <p className="text-muted-foreground mb-1">/ {duration}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full h-px bg-gray-200" />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 items-start w-full">
|
||||||
|
<h1 className="font-medium text-sm text-muted-foreground mb-1">
|
||||||
|
{bundle ? `Bundle (${bundle.noun}s):` : "Bundle:"}
|
||||||
|
</h1>
|
||||||
|
<div className="w-full">
|
||||||
|
{bundle ? (
|
||||||
|
bundle.items.map((item) => (
|
||||||
|
<ChecklistItem
|
||||||
|
key={item[bundle.idKey]}
|
||||||
|
item={item.title}
|
||||||
|
icon={BUNDLE_ITEM_ICONS[bundle.type]}
|
||||||
|
onClick={() => setPreviewItem(item)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : !plan.description ? (
|
||||||
|
<p className="text-sm text-slate-500 py-1.5">
|
||||||
|
Access to all free course content.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{footerLabel && (
|
||||||
|
<Button
|
||||||
|
className={`w-full mt-auto ${footerBtnCls}`}
|
||||||
|
size="lg"
|
||||||
|
disabled={footerDisabled}
|
||||||
|
onClick={footerAction}
|
||||||
|
>
|
||||||
|
{footerLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* ── Not Available Dialog ──────────────────────────────────────── */}
|
{/* ── Not Available Dialog ──────────────────────────────────────── */}
|
||||||
<Dialog open={notAvailableOpen} onOpenChange={setNotAvailableOpen}>
|
<Dialog open={notAvailableOpen} onOpenChange={setNotAvailableOpen}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
@@ -129,104 +240,30 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* ── Plan Detail Dialog ─────────────────────────────────────────── */}
|
{/* ── Bundle Item Preview ─────────────────────────────────────────── */}
|
||||||
{/* Current disabled from line 170 to 172 */}
|
<ResponsiveModal
|
||||||
<Dialog open={coursesOpen} onOpenChange={setCoursesOpen}>
|
open={!!previewItem}
|
||||||
<DialogContent className="sm:max-w-[calc(100%-55rem)]">
|
onOpenChange={(v) => !v && setPreviewItem(null)}
|
||||||
<DialogHeader>
|
title={previewItem?.title}
|
||||||
<div className="flex items-start gap-2">
|
description={`Upgrade your plan to access this ${bundle?.noun?.toLowerCase() ?? "item"}.`}
|
||||||
<DialogTitle className="leading-snug">{plan.label}</DialogTitle>
|
>
|
||||||
<Badge className={`${badgeCls} shrink-0`}>
|
<div className="py-2">
|
||||||
<Icon className="size-3" />
|
<div className={`p-5 rounded-2xl border ${panel.bg} ${panel.border}`}>
|
||||||
{tierLabel}
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Badge className={badgeCls}>
|
||||||
|
<Lock className="size-3" /> {tierLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
<ul className="space-y-2 text-sm [&_svg]:size-4 mb-4">
|
||||||
|
<li className="flex items-center gap-2"><Star className="text-blue-500" /> Access to {tierLabel} content</li>
|
||||||
{/* Price + Duration */}
|
<li className="flex items-center gap-2"><Star className="text-blue-500" /> Certificates & achievements</li>
|
||||||
<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>
|
</ul>
|
||||||
)}
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Upgrade to a <span className="font-medium">{tierLabel}</span> plan to unlock this {bundle?.noun?.toLowerCase() ?? "item"}.
|
||||||
{/* Description */}
|
|
||||||
{plan.description && (
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed -mt-1">
|
|
||||||
{plan.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Bundle items horizontal scroll */}
|
|
||||||
{bundle && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1.5">
|
|
||||||
<bundle.icon className="size-3.5" />
|
|
||||||
Bundle
|
|
||||||
</p>
|
|
||||||
<ScrollArea className="w-md whitespace-nowrap">
|
|
||||||
<div className="flex gap-3 pb-3 pt-1 w-max">
|
|
||||||
{bundle.items.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item[bundle.idKey]}
|
|
||||||
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">
|
|
||||||
{item.title}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{item.level && (
|
|
||||||
<span className="text-[11px] text-muted-foreground capitalize">
|
|
||||||
{item.level}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{formatCourseDuration(item.duration_seconds) && (
|
|
||||||
<span className="text-[11px] text-muted-foreground flex items-center gap-1">
|
|
||||||
<Clock className="size-3" />
|
|
||||||
{formatCourseDuration(item.duration_seconds)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ResponsiveModal>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<ScrollBar orientation="horizontal" />
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isCurrent && !isBlockedByOverlap && plan.tier !== "free" && (
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => { setCoursesOpen(false); onSelect(plan); }}
|
|
||||||
>
|
|
||||||
Get {tierLabel}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -239,6 +276,11 @@ export default function PlanList() {
|
|||||||
const { fmtDate, fmtCurrency } = useDateFormat();
|
const { fmtDate, fmtCurrency } = useDateFormat();
|
||||||
const { adLists, listLoading: adLoading, getActiveAdvertisementList, handleAdCtaClick } = useClientAdvertisements();
|
const { adLists, listLoading: adLoading, getActiveAdvertisementList, handleAdCtaClick } = useClientAdvertisements();
|
||||||
|
|
||||||
|
// Plans are fetched all at once — pagination here just slices the
|
||||||
|
// already-loaded list client-side, it doesn't refetch.
|
||||||
|
const [plansPage, setPlansPage] = useState(1);
|
||||||
|
const [plansLimit, setPlansLimit] = useState(10);
|
||||||
|
|
||||||
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
|
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
|
||||||
const [refundLoading, setRefundLoading] = useState(false);
|
const [refundLoading, setRefundLoading] = useState(false);
|
||||||
// Keyed by plan_id, not tier slug — two different plans can share a tier
|
// Keyed by plan_id, not tier slug — two different plans can share a tier
|
||||||
@@ -293,6 +335,21 @@ export default function PlanList() {
|
|||||||
const refundPlanSecsLeft = refundSecsLeftByTier[refundPlan?.plan_id] ?? 0;
|
const refundPlanSecsLeft = refundSecsLeftByTier[refundPlan?.plan_id] ?? 0;
|
||||||
const isOnlyActiveTier = (myTier?.active_tiers ?? []).length <= 1;
|
const isOnlyActiveTier = (myTier?.active_tiers ?? []).length <= 1;
|
||||||
|
|
||||||
|
const plansTotalPages = Math.max(1, Math.ceil(plans.length / plansLimit));
|
||||||
|
const pagedPlans = plans.slice((plansPage - 1) * plansLimit, plansPage * plansLimit);
|
||||||
|
const plansPagination = {
|
||||||
|
page: plansPage,
|
||||||
|
limit: plansLimit,
|
||||||
|
totalPages: plansTotalPages,
|
||||||
|
totalRecords: plans.length,
|
||||||
|
hasPrevPage: plansPage > 1,
|
||||||
|
hasNextPage: plansPage < plansTotalPages,
|
||||||
|
};
|
||||||
|
const handlePlansPageSize = (size) => {
|
||||||
|
setPlansLimit(size);
|
||||||
|
setPlansPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
const handleConfirmRefund = async () => {
|
const handleConfirmRefund = async () => {
|
||||||
setRefundLoading(true);
|
setRefundLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -317,18 +374,16 @@ export default function PlanList() {
|
|||||||
<div className="lg:container lg:mx-auto space-y-8 p-6">
|
<div className="lg:container lg:mx-auto space-y-8 p-6">
|
||||||
<div className="relative xs:pt-2 lg:pt-8 space-y-4">
|
<div className="relative xs:pt-2 lg:pt-8 space-y-4">
|
||||||
{/* Advertisement Banner */}
|
{/* Advertisement Banner */}
|
||||||
{adLoading["tier_plans.banner"] ? (
|
{/* {adLoading["tier_plans.banner"] ? (
|
||||||
<BannerSkeleton />
|
<BannerSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<Banner ads={bannerAds} onCtaClick={handleAdCtaClick} />
|
<Banner ads={bannerAds} onCtaClick={handleAdCtaClick} />
|
||||||
)}
|
)} */}
|
||||||
|
|
||||||
|
<Banner ads={bannerAds} onCtaClick={handleAdCtaClick} />
|
||||||
|
|
||||||
{/* Section Header */}
|
{/* Section Header */}
|
||||||
<div className="relative flex flex-col items-center gap-4 mt-6">
|
<div className="relative flex flex-col items-center gap-4 mt-6">
|
||||||
<Plus aria-hidden className="hidden md:block absolute top-0 left-4 size-5 text-muted-foreground/15 pointer-events-none" />
|
|
||||||
<Plus aria-hidden className="hidden md:block absolute top-2 right-8 size-4 text-muted-foreground/15 pointer-events-none" />
|
|
||||||
<Plus aria-hidden className="hidden md:block absolute bottom-0 left-16 size-4 text-muted-foreground/15 pointer-events-none" />
|
|
||||||
<Plus aria-hidden className="hidden md:block absolute bottom-2 right-20 size-5 text-muted-foreground/15 pointer-events-none" />
|
|
||||||
<div className="text-center my-8">
|
<div className="text-center my-8">
|
||||||
<h2 className="text-3xl font-bold">Available Plans</h2>
|
<h2 className="text-3xl font-bold">Available Plans</h2>
|
||||||
<p className="text-muted-foreground mt-2">
|
<p className="text-muted-foreground mt-2">
|
||||||
@@ -348,8 +403,9 @@ export default function PlanList() {
|
|||||||
<p className="text-sm">No plans available at the moment.</p>
|
<p className="text-sm">No plans available at the moment.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{plans.map((plan) => (
|
{pagedPlans.map((plan) => (
|
||||||
<PlanCard
|
<PlanCard
|
||||||
key={plan.plan_id}
|
key={plan.plan_id}
|
||||||
plan={plan}
|
plan={plan}
|
||||||
@@ -362,6 +418,16 @@ export default function PlanList() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TablePagination
|
||||||
|
pagination={plansPagination}
|
||||||
|
onPageChange={setPlansPage}
|
||||||
|
rowCount={pagedPlans.length}
|
||||||
|
totalRecords={plans.length}
|
||||||
|
onPageSizeChange={handlePlansPageSize}
|
||||||
|
recordLabel="plan"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -157,11 +157,11 @@ const ViewPlan = () => {
|
|||||||
|
|
||||||
<div className="px-6 lg:container lg:max-w-3xl lg:mx-auto space-y-5 py-6">
|
<div className="px-6 lg:container lg:max-w-3xl lg:mx-auto space-y-5 py-6">
|
||||||
|
|
||||||
{/* ── What's included ───────────────────────────────────── */}
|
{/* ── Benefits ───────────────────────────────────── */}
|
||||||
{features.length > 0 && (
|
{features.length > 0 && (
|
||||||
<div className={`rounded-2xl border ${accentBorder} ${accentBg} p-5`}>
|
<div className={`rounded-2xl border ${accentBorder} ${accentBg} p-5`}>
|
||||||
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={accentStyle}>
|
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={accentStyle}>
|
||||||
What's included
|
Benefits
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{features.map(({ text }, i) => (
|
{features.map(({ text }, i) => (
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export const BOOLEAN_FIELD_LABELS = {
|
|||||||
is_banned: ["Not Banned", "Banned"],
|
is_banned: ["Not Banned", "Banned"],
|
||||||
is_required: ["No", "Yes"],
|
is_required: ["No", "Yes"],
|
||||||
accepts_submissions: ["No", "Yes"],
|
accepts_submissions: ["No", "Yes"],
|
||||||
|
is_recommended: ["No", "Yes"],
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderCell(attr, value) {
|
function renderCell(attr, value) {
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ export function resolveTierBadge(slug, tierMap = {}) {
|
|||||||
const colors = getTierColor(colorKey);
|
const colors = getTierColor(colorKey);
|
||||||
const rank = info?.rank ?? 0;
|
const rank = info?.rank ?? 0;
|
||||||
const label = info?.name ?? (slug ? slug.charAt(0).toUpperCase() + slug.slice(1) : 'Free');
|
const label = info?.name ?? (slug ? slug.charAt(0).toUpperCase() + slug.slice(1) : 'Free');
|
||||||
return { rank, label, cls: colors.badge, panel: colors.panel, colorKey };
|
// Just the "bg-gradient-to-r from-X to-Y" portion of the badge class —
|
||||||
|
// for surfaces that need the tier's actual gradient (not the pale panel
|
||||||
|
// tint) but supply their own text/border handling.
|
||||||
|
const gradient = colors.badge.replace(/\s*text-white\s*border-0\s*$/, '');
|
||||||
|
return { rank, label, cls: colors.badge, panel: colors.panel, gradient, colorKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns badge Tailwind class string for a stored color key. */
|
/** Returns badge Tailwind class string for a stored color key. */
|
||||||
|
|||||||
Reference in New Issue
Block a user