commit subscriptions

Signed-off-by: rgrgogu <obsequio.rus@gmail.com>
This commit is contained in:
rgrgogu
2026-08-14 02:21:27 +08:00
parent 3c5432a32b
commit 8174987382
7 changed files with 233 additions and 144 deletions
+2 -2
View File
@@ -465,7 +465,7 @@ export default function AddPlan() {
</div>
<div className="space-y-2">
<Label>What's included</Label>
<Label>Benefits</Label>
<p className="text-xs text-muted-foreground -mt-1">
Bullet points shown on the plans page and the comparison table.
</p>
@@ -593,7 +593,7 @@ export default function AddPlan() {
{featureFields.length > 0 && (
<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">
{watch("features")?.map((f, i) => (
<li key={i}>{f.text}</li>
+14 -1
View File
@@ -62,6 +62,7 @@ const schema = z.object({
price: z.coerce.number().min(0.01, "Price must be greater than 0."),
currency: z.string().length(3),
is_active: z.boolean().default(true),
is_recommended: z.boolean().default(false),
status: z.enum(["draft", "published"]).default("draft"),
}).superRefine(({ duration_value, duration_unit }, ctx) => {
const rule = DURATION_UNIT_LIMITS[duration_unit];
@@ -150,6 +151,7 @@ export default function EditPlan() {
price: plan.price,
currency: plan.currency,
is_active: plan.is_active,
is_recommended: plan.is_recommended ?? false,
status: plan.status ?? "draft",
});
}
@@ -317,7 +319,7 @@ export default function EditPlan() {
</div>
<div className="space-y-2">
<Label>What's included</Label>
<Label>Benefits</Label>
<p className="text-xs text-muted-foreground -mt-1">
Bullet points shown on the plans page and the comparison table.
</p>
@@ -427,6 +429,17 @@ export default function EditPlan() {
onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })}
/>
</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>
{plan?.tier && (
@@ -111,6 +111,11 @@ function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoadin
{plan.status ?? "draft"}
</Badge>
</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">
<Badge variant={plan.is_active ? "default" : "secondary"} className="mt-0.5">
{plan.is_active ? "Active" : "Inactive"}
+204 -138
View File
@@ -1,21 +1,19 @@
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, Lock,
Tag, RotateCcw, Plus,
BookOpen, Star, Lock, Sparkles,
RotateCcw, Plus, Ban, BadgeCheck,
GraduationCap, Book, Medal,
} from "lucide-react";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
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 { resolveTierBadge } from "@/utils/tierBadge.util";
import { getBundleContent, overlapsExistingAccess, isPlanCurrent } from "@/utils/planBundle.util";
import { TablePagination } from "@/components/generic/Table/TablePagination";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -47,15 +46,6 @@ function formatDuration(days, unit) {
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 ──────────────────────────────────────────────────────────────────
@@ -81,14 +71,32 @@ const PlanSkeleton = () => (
// ─── 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 { fmtCurrency } = useDateFormat();
const [coursesOpen, setCoursesOpen] = useState(false);
const [notAvailableOpen, setNotAvailableOpen] = useState(false);
const { label: tierLabel, cls: badgeCls } = resolveTierBadge(plan.tier, tierMap);
const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag;
const [previewItem, setPreviewItem] = useState(null);
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
// 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
@@ -99,18 +107,121 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
// else the user holds. Card stays visible but disabled (Scenario 3).
const isBlockedByOverlap = !isCurrent && overlapsExistingAccess(plan, myTier?.my_grants);
const duration = formatDuration(plan.duration_days, plan.duration_unit);
const features = plan.features ?? [];
const bundle = getBundleContent(plan);
const previewItems = bundle?.items?.slice(0, PREVIEW_COURSE_LIMIT) ?? [];
const extraCount = (bundle?.items?.length ?? 0) - PREVIEW_COURSE_LIMIT;
const hasFooterAction = (isCurrent && refundSecsLeft > 0) || !plan.is_active || isBlockedByOverlap || (isCurrent && plan.tier !== "free");
// Footer button state — always visible when there's an actual action to
// take. A not-yet-owned free plan has nothing to do (it's the default),
// 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 (
<>
<div className="border">
askodasodkas
{/* Actual Card */}
<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
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 ──────────────────────────────────────── */}
<Dialog open={notAvailableOpen} onOpenChange={setNotAvailableOpen}>
<DialogContent className="sm:max-w-md">
@@ -129,104 +240,30 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
</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}
{/* ── Bundle Item Preview ─────────────────────────────────────────── */}
<ResponsiveModal
open={!!previewItem}
onOpenChange={(v) => !v && setPreviewItem(null)}
title={previewItem?.title}
description={`Upgrade your plan to access this ${bundle?.noun?.toLowerCase() ?? "item"}.`}
>
<div className="py-2">
<div className={`p-5 rounded-2xl border ${panel.bg} ${panel.border}`}>
<div className="flex items-center gap-3 mb-3">
<Badge className={badgeCls}>
<Lock 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 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>
<li className="flex items-center gap-2"><Star className="text-blue-500" /> Certificates & achievements</li>
</ul>
)}
{/* Description */}
{plan.description && (
<p className="text-sm text-muted-foreground leading-relaxed -mt-1">
{plan.description}
<p className="text-sm text-muted-foreground">
Upgrade to a <span className="font-medium">{tierLabel}</span> plan to unlock this {bundle?.noun?.toLowerCase() ?? "item"}.
</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>
</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>
<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>
</div>
</div>
</ResponsiveModal>
</>
);
};
@@ -239,6 +276,11 @@ export default function PlanList() {
const { fmtDate, fmtCurrency } = useDateFormat();
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 [refundLoading, setRefundLoading] = useState(false);
// 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 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 () => {
setRefundLoading(true);
try {
@@ -317,18 +374,16 @@ export default function PlanList() {
<div className="lg:container lg:mx-auto space-y-8 p-6">
<div className="relative xs:pt-2 lg:pt-8 space-y-4">
{/* Advertisement Banner */}
{adLoading["tier_plans.banner"] ? (
{/* {adLoading["tier_plans.banner"] ? (
<BannerSkeleton />
) : (
<Banner ads={bannerAds} onCtaClick={handleAdCtaClick} />
)}
)} */}
<Banner ads={bannerAds} onCtaClick={handleAdCtaClick} />
{/* Section Header */}
<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">
<h2 className="text-3xl font-bold">Available Plans</h2>
<p className="text-muted-foreground mt-2">
@@ -348,20 +403,31 @@ export default function PlanList() {
<p className="text-sm">No plans available at the moment.</p>
</div>
) : (
<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={refundSecsLeftByTier[plan.plan_id] ?? 0}
/>
))}
</div>
<>
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{pagedPlans.map((plan) => (
<PlanCard
key={plan.plan_id}
plan={plan}
myTier={myTier}
tierMap={tierMap}
onSelect={handleSelectPlan}
onView={handleViewPlan}
onRefund={handleRefundClick}
refundSecsLeft={refundSecsLeftByTier[plan.plan_id] ?? 0}
/>
))}
</div>
<TablePagination
pagination={plansPagination}
onPageChange={setPlansPage}
rowCount={pagedPlans.length}
totalRecords={plans.length}
onPageSizeChange={handlePlansPageSize}
recordLabel="plan"
/>
</>
)}
</div>
+2 -2
View File
@@ -157,11 +157,11 @@ const ViewPlan = () => {
<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 && (
<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
Benefits
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{features.map(({ text }, i) => (
+1
View File
@@ -119,6 +119,7 @@ export const BOOLEAN_FIELD_LABELS = {
is_banned: ["Not Banned", "Banned"],
is_required: ["No", "Yes"],
accepts_submissions: ["No", "Yes"],
is_recommended: ["No", "Yes"],
};
function renderCell(attr, value) {
+5 -1
View File
@@ -10,7 +10,11 @@ export function resolveTierBadge(slug, tierMap = {}) {
const colors = getTierColor(colorKey);
const rank = info?.rank ?? 0;
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. */