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>
<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>
+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."), 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"}
+189 -123
View File
@@ -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>
+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"> <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) => (
+1
View File
@@ -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) {
+5 -1
View File
@@ -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. */