add: more commits

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-03 16:21:27 +08:00
parent 17326b2c2e
commit 7e964f2432
112 changed files with 9160 additions and 3461 deletions
+223 -96
View File
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, House } from "lucide-react";
import { ArrowLeft, ArrowRight, Check, House } from "lucide-react";
import { useTiers } from "@/contexts/AdminTiersContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
@@ -14,6 +14,7 @@ import { Spinner } from "@/components/ui/spinner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { PageMeta } from "@/contexts/MetadataContext";
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
import api from "@/utils/api.util";
// ─── Schema ───────────────────────────────────────────────────────────────────
@@ -26,6 +27,7 @@ const DURATION_UNITS = [
{ value: "year", label: "Year(s)" },
];
const DURATION_UNIT_LIMITS = {
minute: { max: 59, nextLabel: "Hour(s)", factor: 60 },
hour: { max: 23, nextLabel: "Day(s)", factor: 24 },
@@ -66,24 +68,92 @@ function SectionCard({ title, children }) {
);
}
// ─── Steps config ─────────────────────────────────────────────────────────────
const STEPS = [
{ label: "Category & Label", description: "Tier category, label & description" },
{ label: "Duration & Pricing", description: "Billing period, price & currency" },
{ label: "Assigned Courses", description: "Choose which courses this unlocks" },
];
function StepIndicator({ steps, current, maxStepReached, onStepClick }) {
return (
<div className="flex items-start w-full mb-8">
{steps.flatMap((step, i) => {
const reachable = i <= maxStepReached;
const items = [
<button
key={`step-${i}`}
type="button"
onClick={() => onStepClick(i)}
disabled={!reachable}
className="flex flex-col items-center gap-1.5 shrink-0 group disabled:cursor-not-allowed disabled:opacity-50"
>
<div
className={[
"w-8 h-8 rounded-full border-2 flex items-center justify-center text-sm font-semibold transition-all",
reachable && "group-hover:opacity-80",
i < current
? "bg-primary border-primary text-primary-foreground"
: i === current
? "border-primary text-primary"
: "border-border text-muted-foreground",
].filter(Boolean).join(" ")}
>
{i < current ? <Check className="h-4 w-4" /> : i + 1}
</div>
<p
className={[
"text-[11px] font-medium text-center leading-tight whitespace-nowrap",
i === current ? "text-foreground" : "text-muted-foreground",
].join(" ")}
>
{step.label}
</p>
</button>,
];
if (i < steps.length - 1) {
items.push(
<div
key={`line-${i}`}
className={[
"flex-1 h-px mt-4 mx-2 shrink",
i < current ? "bg-primary" : "bg-border",
].join(" ")}
/>
);
}
return items;
})}
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function AddPlan() {
const navigate = useNavigate();
const { createPlan, loading } = useTiers();
const [categories, setCategories] = useState([]);
const [catLoading, setCatLoading] = useState(true);
const [currentStep, setCurrentStep] = useState(0);
const [maxStepReached, setMaxStepReached] = useState(0);
const [categories, setCategories] = useState([]);
const [catLoading, setCatLoading] = useState(true);
const [currencies, setCurrencies] = useState([]);
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
const [courseConflicts, setCourseConflicts] = useState(0);
useEffect(() => {
api.get("/admin/tiers/categories")
.then(({ data }) => setCategories((data.data ?? []).filter((c) => !c.is_default && c.is_active)))
.catch(() => {})
.finally(() => setCatLoading(false));
api.get("/admin/tiers/currencies")
.then(({ data }) => setCurrencies(data.data ?? []))
.catch(() => {});
}, []);
const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm({
const { register, handleSubmit, trigger, setValue, watch, formState: { errors } } = useForm({
resolver: zodResolver(schema),
defaultValues: { tier_category_id: "", label: "", description: "", duration_value: 30, duration_unit: "day", price: "", currency: "USD" },
});
@@ -99,8 +169,29 @@ export default function AddPlan() {
// Reset picker when category changes
useEffect(() => {
setSelectedCourseIds(new Set());
setCourseConflicts(0);
}, [categorySlug]);
const STEP_FIELDS = [
["tier_category_id", "label", "description"],
["duration_value", "duration_unit", "price", "currency"],
[],
];
const handleNext = async () => {
const valid = await trigger(STEP_FIELDS[currentStep]);
if (!valid) return;
const next = Math.min(currentStep + 1, STEPS.length - 1);
setCurrentStep(next);
setMaxStepReached((s) => Math.max(s, next));
};
// Only allow jumping via the indicator to steps already reached through Next —
// prevents landing on "Assigned Courses" before a category is picked.
const handleStepClick = (i) => {
if (i <= maxStepReached) setCurrentStep(i);
};
const onSubmit = async (values) => {
const result = await createPlan(values);
if (!result) return;
@@ -139,115 +230,151 @@ export default function AddPlan() {
</div>
</div>
<StepIndicator steps={STEPS} current={currentStep} maxStepReached={maxStepReached} onStepClick={handleStepClick} />
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<SectionCard title="Plan Details">
{/* ── Step 0: Category & Label ── */}
{currentStep === 0 && (
<SectionCard title="Category & Label" description="Which tier category this plan belongs to, and how it's presented.">
<div className="space-y-1.5">
<Label>Tier Category <span className="text-destructive">*</span></Label>
{catLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<Spinner className="h-4 w-4" /> Loading categories…
</div>
) : categories.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
No tier categories found.{" "}
<a href="/admin/tiers/categories/add" className="underline text-primary">Add one first.</a>
</p>
) : (
<Select
value={selectedCategoryId}
onValueChange={(v) => setValue("tier_category_id", v, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select tier category" />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c.tier_category_id} value={String(c.tier_category_id)}>
{c.name}
<span className="ml-2 text-xs text-muted-foreground">({c.slug})</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
<FieldError message={errors.tier_category_id?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
<Input id="label" placeholder="e.g. Premium – 1 Month" {...register("label")} />
<FieldError message={errors.label?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Brief description shown to users on the plans page."
rows={3}
{...register("description")}
/>
<FieldError message={errors.description?.message} />
</div>
<div className="space-y-1.5">
<Label>Duration <span className="text-destructive">*</span></Label>
<div className="flex gap-2">
<Input
id="duration_value"
type="number"
min={1}
className="flex-1"
{...register("duration_value")}
/>
<Select
value={watch("duration_unit") ?? "day"}
onValueChange={(v) => setValue("duration_unit", v, { shouldDirty: true })}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DURATION_UNITS.map((u) => (
<SelectItem key={u.value} value={u.value}>{u.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="space-y-1.5">
<Label>Tier Category <span className="text-destructive">*</span></Label>
{catLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<Spinner className="h-4 w-4" /> Loading categories…
</div>
) : categories.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
No tier categories found.{" "}
<a href="/admin/tiers/categories/add" className="underline text-primary">Add one first.</a>
</p>
) : (
<Select
value={selectedCategoryId}
onValueChange={(v) => setValue("tier_category_id", v, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select tier category" />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c.tier_category_id} value={String(c.tier_category_id)}>
{c.name}
<span className="ml-2 text-xs text-muted-foreground">({c.slug})</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
<FieldError message={errors.tier_category_id?.message} />
</div>
<FieldError message={errors.duration_value?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="price">Price <span className="text-destructive">*</span></Label>
<Input id="price" type="number" step="0.01" min={0} placeholder="0.00" {...register("price")} />
<FieldError message={errors.price?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
<Input id="label" placeholder="e.g. Premium – 1 Month" {...register("label")} />
<FieldError message={errors.label?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="currency">Currency</Label>
<Input id="currency" maxLength={3} placeholder="USD" {...register("currency")} />
<FieldError message={errors.currency?.message} />
</div>
</SectionCard>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Brief description shown to users on the plans page."
rows={3}
{...register("description")}
/>
<FieldError message={errors.description?.message} />
</div>
</SectionCard>
)}
{categorySlug && (
<SectionCard title="Assigned Courses">
{/* ── Step 1: Duration & Pricing ── */}
{currentStep === 1 && (
<SectionCard title="Duration & Pricing" description="How long the plan lasts and what it costs.">
<div className="space-y-1.5">
<Label>Duration <span className="text-destructive">*</span></Label>
<div className="flex gap-2">
<Input
id="duration_value"
type="number"
min={1}
className="flex-1"
{...register("duration_value")}
/>
<Select
value={watch("duration_unit") ?? "day"}
onValueChange={(v) => setValue("duration_unit", v, { shouldDirty: true })}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DURATION_UNITS.map((u) => (
<SelectItem key={u.value} value={u.value}>{u.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<FieldError message={errors.duration_value?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="price">Price <span className="text-destructive">*</span></Label>
<Input id="price" type="number" step="0.01" min={0} placeholder="0.00" {...register("price")} />
<FieldError message={errors.price?.message} />
</div>
<div className="space-y-1.5">
<Label>Currency</Label>
<CurrencyPicker
value={watch("currency") ?? "USD"}
currencies={currencies}
onValueChange={(v) => setValue("currency", v, { shouldDirty: true })}
/>
<FieldError message={errors.currency?.message} />
</div>
</SectionCard>
)}
{/* ── Step 2: Assigned Courses ── */}
{currentStep === 2 && (
<SectionCard title="Assigned Courses" description="Choose which courses this plan unlocks.">
<CoursePicker
subscription={categorySlug}
selectedIds={selectedCourseIds}
onChange={setSelectedCourseIds}
onConflictsChange={setCourseConflicts}
/>
</SectionCard>
)}
<div className="flex justify-end gap-3 pt-1">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
<Button type="submit" disabled={loading || catLoading || !selectedCategoryId}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Plan
<Button
type="button"
variant="outline"
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
disabled={loading}
>
{currentStep === 0 ? "Cancel" : "Back"}
</Button>
{currentStep < STEPS.length - 1 ? (
<Button type="button" onClick={handleNext} disabled={catLoading}>
Next
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
) : (
<Button
type="button"
onClick={handleSubmit(onSubmit)}
disabled={loading || catLoading || !selectedCategoryId || courseConflicts > 0}
>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Plan
</Button>
)}
</div>
</form>
+16 -3
View File
@@ -17,6 +17,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { PageMeta } from "@/contexts/MetadataContext";
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
import api from "@/utils/api.util";
const DURATION_UNITS = [
@@ -27,6 +28,7 @@ const DURATION_UNITS = [
{ value: "year", label: "Year(s)" },
];
const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
function durationDaysToValue(days, unit) {
@@ -82,6 +84,8 @@ export default function EditPlan() {
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
const [coursesLoaded, setCoursesLoaded] = useState(false);
const [courseConflicts, setCourseConflicts] = useState(0);
const [currencies, setCurrencies] = useState([]);
const [impactDialog, setImpactDialog] = useState(false);
const [impactCount, setImpactCount] = useState(0);
const [impactLoading, setImpactLoading] = useState(false);
@@ -93,6 +97,9 @@ export default function EditPlan() {
useEffect(() => {
fetchPlan(planId);
api.get("/admin/tiers/currencies")
.then(({ data }) => setCurrencies(data.data ?? []))
.catch(() => {});
}, [planId]);
useEffect(() => {
@@ -250,8 +257,12 @@ export default function EditPlan() {
</div>
<div className="space-y-1.5">
<Label htmlFor="currency">Currency</Label>
<Input id="currency" maxLength={3} {...register("currency")} />
<Label>Currency</Label>
<CurrencyPicker
value={watch("currency") ?? "USD"}
currencies={currencies}
onValueChange={(v) => setValue("currency", v, { shouldDirty: true })}
/>
<FieldError message={errors.currency?.message} />
</div>
@@ -275,6 +286,8 @@ export default function EditPlan() {
selectedIds={selectedCourseIds}
onChange={setSelectedCourseIds}
isPreloaded={true}
currentPlanId={planId}
onConflictsChange={setCourseConflicts}
/>
) : (
<div className="space-y-3">
@@ -290,7 +303,7 @@ export default function EditPlan() {
<div className="flex justify-end gap-3 pt-1">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading || impactLoading}>Cancel</Button>
<Button type="submit" disabled={loading || impactLoading}>
<Button type="submit" disabled={loading || impactLoading || courseConflicts > 0}>
{(loading || impactLoading) && <Spinner className="h-4 w-4 mr-2" />}
Save Changes
</Button>
@@ -14,7 +14,7 @@ import * as LucideIcons from "lucide-react";
import { TIER_COLOR_OPTIONS, getTierColor } from "@/utils/tierColors";
import { Badge } from "@/components/ui/badge";
const BADGE_ICON_OPTIONS = [
export const BADGE_ICON_OPTIONS = [
// Prestige / rank
{ name: "ShieldCheck", icon: ShieldCheck },
{ name: "Shield", icon: Shield },
@@ -1,525 +0,0 @@
import { useEffect, useState, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Globe, House, Plus, Trash2, Loader2, Pencil, Check, X } from "lucide-react";
import { toast } from "sonner";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { PageMeta } from "@/contexts/MetadataContext";
import { useTiers } from "@/contexts/AdminTiersContext";
import api from "@/utils/api.util";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function SectionCard({ icon: Icon, title, description, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
<div>
<div className="flex items-center gap-2">
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
<h2 className="text-sm font-semibold">{title}</h2>
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5 ml-6">{description}</p>}
</div>
<Separator />
{children}
</div>
);
}
const EMPTY_FORM = { currency: "", price: "" };
// ─── Rate-hint helpers ────────────────────────────────────────────────────────
const LOWER_HARD = 0.70;
const LOWER_WARN = 0.85;
const UPPER_WARN = 1.50;
const UPPER_HARD = 3.00;
function computeZone(price, hint) {
if (!hint || !price || Number(price) === 0) return null;
const n = Number(price);
if (isNaN(n)) return null;
if (n < hint.hardMin || n > hint.hardMax) return "block";
if (n < hint.warnMin || n > hint.warnMax) return "warn";
return "pass";
}
const ZONE_INPUT = {
block: "border-red-400 focus-visible:ring-red-400",
warn: "border-yellow-400 focus-visible:ring-yellow-400",
pass: "border-green-400 focus-visible:ring-green-400",
};
const ZONE_MSG = {
block: (h, c) => `Outside acceptable range: ${h.hardMin.toFixed(2)} – ${h.hardMax.toFixed(2)} ${c}`,
warn: (h, c) => `Outside suggested range: ${h.warnMin.toFixed(2)} – ${h.warnMax.toFixed(2)} ${c}. Will save with caution.`,
pass: () => `Price looks good.`,
};
const ZONE_TEXT = { block: "text-red-500", warn: "text-yellow-600", pass: "text-green-600" };
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function LocalizedPrices() {
const navigate = useNavigate();
const { planId } = useParams();
const { fetchPlan, plan, loading: planLoading, plans, fetchPlans } = useTiers();
const [prices, setPrices] = useState([]);
const [pricesLoading, setPricesLoading] = useState(true);
const [currencies, setCurrencies] = useState([]);
// When accessed from toolbar (no planId), show plan picker first
const [selectedPlanId, setSelectedPlanId] = useState(planId ?? "");
const [addForm, setAddForm] = useState(EMPTY_FORM);
const [showAdd, setShowAdd] = useState(false);
const [adding, setAdding] = useState(false);
// Inline edit state: { [currency]: price }
const [editingRow, setEditingRow] = useState(null); // currency string
const [editPrice, setEditPrice] = useState("");
const [savingEdit, setSavingEdit] = useState(false);
const [removingCurrency, setRemovingCurrency] = useState(null);
// Rate hint for the add form
const [rateHint, setRateHint] = useState(null);
const [rateHintLoading, setRateHintLoading] = useState(false);
// Rate hint for inline edit
const [editRateHint, setEditRateHint] = useState(null);
const activePlanId = planId ?? selectedPlanId;
const activePlan = plan?.plan_id === Number(activePlanId) ? plan
: plans.find((p) => String(p.plan_id) === String(activePlanId));
// ─── Load ──────────────────────────────────────────────────────────────────
const loadPrices = useCallback(async (id) => {
if (!id) return;
setPricesLoading(true);
try {
const { data } = await api.get(`/admin/tiers/${id}/prices`);
setPrices(data.data ?? []);
} catch {
toast.error("Could not load localized prices.");
} finally {
setPricesLoading(false);
}
}, []);
useEffect(() => {
api.get("/client/tiers/currencies")
.then(({ data }) => setCurrencies(data.data ?? []))
.catch(() => {});
}, []);
useEffect(() => {
if (!plans.length) fetchPlans();
}, []);
useEffect(() => {
if (!activePlanId) return;
fetchPlan(activePlanId);
loadPrices(activePlanId);
}, [activePlanId]);
// Fetch rate when currency is selected in the add form
useEffect(() => {
if (!addForm.currency || !activePlan) { setRateHint(null); return; }
setRateHintLoading(true);
fetch(`https://api.frankfurter.app/latest?from=${activePlan.currency}&to=${addForm.currency}`)
.then((r) => r.json())
.then((json) => {
const rate = json?.rates?.[addForm.currency];
if (!rate) { setRateHint(null); return; }
const expected = Number(activePlan.price) * rate;
setRateHint({ rate, expected, hardMin: expected * LOWER_HARD, hardMax: expected * UPPER_HARD,
warnMin: expected * LOWER_WARN, warnMax: expected * UPPER_WARN });
})
.catch(() => setRateHint(null))
.finally(() => setRateHintLoading(false));
}, [addForm.currency, activePlan?.plan_id]); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch rate when opening an inline edit row
useEffect(() => {
if (!editingRow || !activePlan) { setEditRateHint(null); return; }
fetch(`https://api.frankfurter.app/latest?from=${activePlan.currency}&to=${editingRow}`)
.then((r) => r.json())
.then((json) => {
const rate = json?.rates?.[editingRow];
if (!rate) { setEditRateHint(null); return; }
const expected = Number(activePlan.price) * rate;
setEditRateHint({ rate, expected, hardMin: expected * LOWER_HARD, hardMax: expected * UPPER_HARD,
warnMin: expected * LOWER_WARN, warnMax: expected * UPPER_WARN });
})
.catch(() => setEditRateHint(null));
}, [editingRow, activePlan?.plan_id]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Actions ───────────────────────────────────────────────────────────────
const usedCurrencies = new Set(prices.map((p) => p.currency));
const availableCurrencies = currencies.filter(
(c) => !usedCurrencies.has(c.code) && c.code !== activePlan?.currency
);
const handleAdd = async () => {
if (!addForm.currency) { toast.error("Select a currency."); return; }
if (!addForm.price || Number(addForm.price) < 0) { toast.error("Enter a valid price."); return; }
const zone = computeZone(addForm.price, rateHint);
if (zone === "block") { toast.error("Price is outside the acceptable range. Adjust it before saving."); return; }
setAdding(true);
try {
const res = await api.post(`/admin/tiers/${activePlanId}/prices`, {
currency: addForm.currency,
price: Number(addForm.price),
});
if (res.data?.warning) toast.warning(res.data.message);
else toast.success("Localized price added.");
setAddForm(EMPTY_FORM);
setShowAdd(false);
setRateHint(null);
loadPrices(activePlanId);
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not add price.");
} finally {
setAdding(false);
}
};
const handleEditSave = async (currency) => {
if (editPrice === "" || Number(editPrice) < 0) { toast.error("Enter a valid price."); return; }
const zone = computeZone(editPrice, editRateHint);
if (zone === "block") { toast.error("Price is outside the acceptable range."); return; }
setSavingEdit(true);
try {
const res = await api.put(`/admin/tiers/${activePlanId}/prices/${currency}`, { price: Number(editPrice) });
if (res.data?.warning) toast.warning(res.data.message);
else toast.success("Price updated.");
setEditingRow(null);
setEditRateHint(null);
loadPrices(activePlanId);
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not update price.");
} finally {
setSavingEdit(false);
}
};
const handleRemove = async (currency) => {
setRemovingCurrency(currency);
try {
await api.delete(`/admin/tiers/${activePlanId}/prices/${currency}`);
toast.success(`${currency} price removed.`);
loadPrices(activePlanId);
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not remove price.");
} finally {
setRemovingCurrency(null);
}
};
// ─── Render ────────────────────────────────────────────────────────────────
const isLoading = planLoading || pricesLoading;
// ── Plan picker (toolbar entry, no planId in URL) ──────────────────────────
if (!planId) {
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Localized Prices - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Plans", to: "/admin/tiers/plans" },
{ label: "Localized Prices" },
]} />
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Localized Prices</h1>
<p className="text-sm text-muted-foreground">Select a plan to manage its currency overrides.</p>
</div>
</div>
<div className="rounded-lg border bg-card p-5 space-y-4">
<div className="space-y-1.5">
<Label>Plan</Label>
<Select value={selectedPlanId} onValueChange={setSelectedPlanId}>
<SelectTrigger>
<SelectValue placeholder="Select a plan…" />
</SelectTrigger>
<SelectContent>
{plans.filter((p) => !p.deletedAt).map((p) => (
<SelectItem key={p.plan_id} value={String(p.plan_id)}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedPlanId && (
<Button onClick={() => navigate(`/admin/tiers/plans/${selectedPlanId}/prices`)}>
Manage Prices →
</Button>
)}
</div>
</div>
</div>
</section>
);
}
// ── Per-plan management ────────────────────────────────────────────────────
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title={activePlan ? `Localized Prices — ${activePlan.label} - STARR` : "Localized Prices - STARR"} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Plans", to: "/admin/tiers/plans" },
{ label: activePlan?.label ?? `Plan #${planId}`, to: `/admin/tiers/plans/${planId}/view` },
{ label: "Localized Prices" },
]} />
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Localized Prices</h1>
<p className="text-sm text-muted-foreground capitalize">
{activePlan?.tier} — {activePlan?.label}
</p>
</div>
</div>
{isLoading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-20 w-full" />)}
</div>
) : (
<SectionCard
icon={Globe}
title="Currency Overrides"
description={`Base price is ${activePlan?.currency ?? "USD"} ${Number(activePlan?.price ?? 0).toFixed(2)}. Overrides take priority when a user's preferred currency matches.`}
>
{/* ── Existing prices ─────────────────────────────────────── */}
{prices.length > 0 ? (
<div className="space-y-2">
{prices.map((entry) => {
const isEditing = editingRow === entry.currency;
const currencyMeta = currencies.find((c) => c.code === entry.currency);
return (
<div
key={entry.currency}
className="flex items-center justify-between gap-3 rounded-lg border bg-muted/30 px-4 py-3"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<Badge variant="outline" className="font-mono text-xs shrink-0">
{entry.currency}
</Badge>
{currencyMeta && (
<span className="text-xs text-muted-foreground shrink-0">
{currencyMeta.name}
</span>
)}
{isEditing ? (
<div className="flex flex-col gap-0.5">
{(() => {
const zone = computeZone(editPrice, editRateHint);
return (
<>
<Input
type="number"
step="0.01"
min="0"
className={`h-7 w-28 text-sm ${zone ? ZONE_INPUT[zone] : ""}`}
value={editPrice}
autoFocus
onChange={(e) => setEditPrice(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleEditSave(entry.currency);
if (e.key === "Escape") { setEditingRow(null); setEditRateHint(null); }
}}
/>
{editRateHint && (
<p className="text-[10px] text-muted-foreground">
Good: {editRateHint.warnMin.toFixed(2)} – {editRateHint.warnMax.toFixed(2)}
</p>
)}
{zone && editPrice && (
<p className={`text-[10px] ${ZONE_TEXT[zone]}`}>
{zone === "block" ? "Out of range" : zone === "warn" ? "Caution" : ""}
</p>
)}
</>
);
})()}
</div>
) : (
<span className="text-sm font-semibold tabular-nums">
{Number(entry.price).toFixed(2)}
</span>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{isEditing ? (
<>
<Button
variant="ghost" size="icon" className="h-7 w-7 text-green-600 hover:text-green-500"
disabled={savingEdit}
onClick={() => handleEditSave(entry.currency)}
>
{savingEdit ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7"
onClick={() => { setEditingRow(null); setEditRateHint(null); }}
>
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
<>
<Button
variant="ghost" size="icon" className="h-7 w-7"
onClick={() => { setEditingRow(entry.currency); setEditPrice(String(entry.price)); }}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive"
disabled={removingCurrency === entry.currency}
onClick={() => handleRemove(entry.currency)}
>
{removingCurrency === entry.currency
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Trash2 className="h-3.5 w-3.5" />}
</Button>
</>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<Globe className="size-4 shrink-0" />
No localized prices yet. All users see the base price.
</div>
)}
{/* ── Add form ────────────────────────────────────────────── */}
{showAdd ? (
<div className="rounded-lg border bg-muted/20 p-4 space-y-4">
<p className="text-sm font-medium">Add Localized Price</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Currency <span className="text-destructive">*</span></Label>
<Select
value={addForm.currency}
onValueChange={(v) => setAddForm((f) => ({ ...f, currency: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent>
{availableCurrencies.map((c) => (
<SelectItem key={c.code} value={c.code}>
{c.code} — {c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Price <span className="text-destructive">*</span></Label>
{(() => {
const zone = computeZone(addForm.price, rateHint);
return (
<>
<Input
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={addForm.price}
className={zone ? ZONE_INPUT[zone] : ""}
onChange={(e) => setAddForm((f) => ({ ...f, price: e.target.value }))}
/>
{rateHintLoading && (
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" /> Fetching rate…
</p>
)}
{rateHint && !rateHintLoading && (
<p className="text-xs text-muted-foreground">
1 {activePlan.currency} ≈ {rateHint.rate.toFixed(4)} {addForm.currency}
{" · "}Good range: {rateHint.warnMin.toFixed(2)} – {rateHint.warnMax.toFixed(2)}
</p>
)}
{zone && addForm.price && (
<p className={`text-xs ${ZONE_TEXT[zone]}`}>
{ZONE_MSG[zone]?.(rateHint, addForm.currency)}
</p>
)}
</>
);
})()}
</div>
</div>
<div className="flex gap-2 justify-end pt-1">
<Button
variant="outline" size="sm"
onClick={() => { setShowAdd(false); setAddForm(EMPTY_FORM); }}
disabled={adding}
>
Cancel
</Button>
<Button size="sm" onClick={handleAdd} disabled={adding}>
{adding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4 mr-1" />}
Add Price
</Button>
</div>
</div>
) : (
<Button
variant="outline" size="sm"
onClick={() => setShowAdd(true)}
disabled={availableCurrencies.length === 0}
>
<Plus className="h-4 w-4 mr-1" />
{availableCurrencies.length === 0 ? "All currencies configured" : "Add Currency"}
</Button>
)}
</SectionCard>
)}
</div>
</div>
</section>
);
}
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, CreditCard, Tag, ShieldCheck, Plus, Trash2, Loader2, Globe, ExternalLink } from "lucide-react";
import { ArrowLeft, House, Tag, ShieldCheck, Plus, Trash2, Loader2 } from "lucide-react";
import { DateTimePicker } from "@/components/ui/date-time-picker";
import { toast } from "sonner";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
@@ -63,9 +63,6 @@ export default function PaymentPolicy() {
const [addForm, setAddForm] = useState(EMPTY_PROMO);
const [showAdd, setShowAdd] = useState(false);
// ── Localized prices (for notice in promo section)
const [localizedPrices, setLocalizedPrices] = useState([]);
// ─── Load ────────────────────────────────────────────────────────────────────
useEffect(() => {
@@ -87,9 +84,6 @@ export default function PaymentPolicy() {
.catch(() => {})
.finally(() => setPolicyLoading(false));
api.get(`/admin/tiers/${planId}/prices`)
.then(({ data }) => setLocalizedPrices(data.data ?? []))
.catch(() => {});
}, [planId]);
// ─── Save ────────────────────────────────────────────────────────────────────
@@ -182,24 +176,6 @@ export default function PaymentPolicy() {
) : (
<div className="space-y-5">
{/* ── Currency notice ───────────────────────────────────────── */}
<div className="w-full rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/30 p-4">
<div className="flex items-start gap-3">
<div className="rounded-md bg-blue-100 dark:bg-blue-900/50 p-2 shrink-0">
<CreditCard className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-blue-900 dark:text-blue-200">
Plan base currency: <span className="font-mono">{plan?.currency ?? "USD"}</span>
</p>
<p className="text-sm text-blue-700 dark:text-blue-400 mt-0.5 leading-relaxed">
Flat promo code discounts are applied in <b>{plan?.currency ?? "USD"}</b>. If you need currency-specific pricing, configure overrides via{" "}
<b>Localized Prices</b> from the tier plan lists.
</p>
</div>
</div>
</div>
{/* ── Refund Policy ─────────────────────────────────────────── */}
<SectionCard
icon={ShieldCheck}
@@ -262,30 +238,6 @@ export default function PaymentPolicy() {
title="Promo Codes"
description="Define discount codes users can apply at checkout. Flat reduces price by a fixed amount; percent reduces by a percentage."
>
{/* Localized price notice */}
{localizedPrices.length > 0 && (
<div className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30 p-3">
<Globe className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div className="text-xs text-amber-800 dark:text-amber-300 space-y-0.5">
<p className="font-semibold">
This plan has {localizedPrices.length} localized price{localizedPrices.length > 1 ? "s" : ""} set
{" "}({localizedPrices.map((p) => p.currency).join(", ")}).
</p>
<p>
Flat discounts are deducted in the currency the user is being charged — not converted from <b>{plan?.currency ?? "USD"}</b>.
The Currency select below only shows available currencies for this plan.
Use <b>percent</b> for consistent savings across all currencies.
{" "}<a
href={`/admin/tiers/plans/${planId}/prices`}
className="inline-flex items-center gap-0.5 underline underline-offset-2 font-medium"
>
Manage prices <ExternalLink className="h-3 w-3" />
</a>
</p>
</div>
</div>
)}
{/* Existing rules */}
{promoRules.length > 0 ? (
<div className="space-y-2">
@@ -25,54 +25,7 @@ export default function PlanList() {
</div>
<div className="w-full">
<div className="w-full rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/30 p-4 mb-4">
<div className="flex items-start gap-3">
<div className="rounded-md bg-blue-100 dark:bg-blue-900/50 p-2 shrink-0">
<Globe className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-blue-900 dark:text-blue-200">
Localized prices are configured per plan
</p>
<p className="text-sm text-blue-700 dark:text-blue-400 mt-0.5 leading-relaxed">
Each plan can have currency-specific prices for international users (e.g. CNY, EUR, JPY). Select <b>"Localized Prices"</b>. Users without a localized price fall back to the plan's base price. If no localized price is set, the price will be displayed in <b>US Dollar (USD)</b>.
</p>
<div className="flex items-center gap-4 mt-2">
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
<Globe /> Localized Prices (per currency)
</Badge>
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
<ShieldCheck /> Falls back to base price
</Badge>
</div>
</div>
</div>
</div>
<div className="w-full rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/30 p-4 mb-4">
<div className="flex items-start gap-3">
<div className="rounded-md bg-blue-100 dark:bg-blue-900/50 p-2 shrink-0">
<CreditCard className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-blue-900 dark:text-blue-200">
Payment Policies are configured per plan
</p>
<p className="text-sm text-blue-700 dark:text-blue-400 mt-0.5 leading-relaxed">
Each plan can have its own set of promo codes and refund window.
Open a plan's row actions and select <b>"Promo codes (flat or percent discount)"</b> to configure it.
</p>
<div className="flex items-center gap-4 mt-2">
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
<Tag /> Promo codes (flat or percent discount)
</Badge>
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
<ShieldCheck /> Refund window (minutes / hours / days)
</Badge>
</div>
</div>
</div>
</div>
<TierPlansTable />
</div>
+533 -146
View File
@@ -1,18 +1,27 @@
import { useEffect, useState, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, Pencil, Tag, BadgeCheck, BookOpen, Clock } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
ArrowLeft, CreditCard, Pencil, Tag, BadgeCheck, BookOpen, Clock,
ShieldCheck, Plus, Trash2, Loader2, Receipt,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DateTimePicker } from "@/components/ui/date-time-picker";
import { useTiers } from "@/contexts/AdminTiersContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext";
import api from "@/utils/api.util";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import PaymentsTable from "@/modules/admin/components/tiers/PaymentsTable";
const STATUS_BADGE = { true: "default", false: "secondary" };
// ─── Shared helpers ────────────────────────────────────────────────────────────
function InfoRow({ label, children }) {
return (
@@ -25,12 +34,15 @@ function InfoRow({ label, children }) {
);
}
function SectionCard({ icon: Icon, title, children }) {
function SectionCard({ icon: Icon, title, description, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
<div className="flex items-center gap-2">
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
<h2 className="text-sm font-semibold">{title}</h2>
<div>
<div className="flex items-center gap-2">
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
<h2 className="text-sm font-semibold">{title}</h2>
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5 ml-6">{description}</p>}
</div>
<Separator />
{children}
@@ -38,22 +50,13 @@ function SectionCard({ icon: Icon, title, children }) {
);
}
function LoadingSkeleton() {
return (
<div className="space-y-5">
<Skeleton className="h-8 w-64" />
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-32 w-full" />)}
</div>
);
}
function formatDuration(days, unit) {
if (!days) return `${days} days`;
const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
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' : ''}`;
const label = unit ?? "day";
return `${value} ${label}${value !== 1 ? "s" : ""}`;
}
function formatCourseDuration(seconds = 0) {
@@ -65,17 +68,452 @@ function formatCourseDuration(seconds = 0) {
return `${m}m`;
}
export default function ViewPlan() {
const navigate = useNavigate();
const { planId } = useParams();
const { fetchPlan, plan, loading } = useTiers();
// ─── Tab: Plan Details ─────────────────────────────────────────────────────────
function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoading }) {
const { fmtDateTime } = useDateFormat();
if (loading && !plan) {
return (
<div className="space-y-5">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-32 w-full" />)}
</div>
);
}
if (!plan) return <p className="text-sm text-muted-foreground">Plan not found.</p>;
return (
<div className="space-y-5">
<SectionCard icon={Tag} title="Plan Details">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Label">{plan.label}</InfoRow>
<InfoRow label="Tier">
{(() => {
const { cls, label } = resolveTierBadge(plan.tier, tierMap);
return <Badge className={`${cls} mt-0.5`}>{label}</Badge>;
})()}
</InfoRow>
<InfoRow label="Duration">{formatDuration(plan.duration_days, plan.duration_unit)}</InfoRow>
<InfoRow label="Price">{plan.currency} {Number(plan.price).toFixed(2)}</InfoRow>
<InfoRow label="Currency">{plan.currency}</InfoRow>
<InfoRow label="Status">
<Badge variant={plan.is_active ? "default" : "secondary"} className="mt-0.5">
{plan.is_active ? "Active" : "Inactive"}
</Badge>
</InfoRow>
</div>
{plan.description && (
<div className="flex flex-col gap-0.5 pt-1">
<span className="text-xs text-muted-foreground uppercase tracking-wide">Description</span>
<p className="text-sm">{plan.description}</p>
</div>
)}
</SectionCard>
<SectionCard icon={BookOpen} title="Assigned Courses">
{coursesLoading ? (
<div className="space-y-2">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : assignedCourses.length === 0 ? (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<BookOpen className="size-4 shrink-0" />
No courses assigned to this plan yet.
</div>
) : (
<div className="space-y-0 divide-y rounded-lg border overflow-hidden">
{assignedCourses.map((course) => (
<div key={course.course_id} className="flex items-center justify-between gap-3 px-4 py-3 bg-card hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="h-8 w-8 rounded-md bg-muted flex items-center justify-center shrink-0">
<BookOpen className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium line-clamp-1">{course.title}</p>
{(course.course_code || course.level) && (
<div className="flex items-center gap-2 mt-0.5">
{course.course_code && (
<span className="text-xs text-muted-foreground">{course.course_code}</span>
)}
{course.level && (
<Badge variant="outline" className="text-xs capitalize h-4 px-1.5">{course.level}</Badge>
)}
</div>
)}
</div>
</div>
{formatCourseDuration(course.duration_seconds) && (
<span className="text-xs text-muted-foreground flex items-center gap-1 shrink-0">
<Clock className="size-3" />
{formatCourseDuration(course.duration_seconds)}
</span>
)}
</div>
))}
</div>
)}
{!coursesLoading && (
<p className="text-xs text-muted-foreground">
{assignedCourses.length} course{assignedCourses.length !== 1 ? "s" : ""} assigned
</p>
)}
</SectionCard>
<SectionCard icon={BadgeCheck} title="Audit">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Created At">{plan.createdAt ? fmtDateTime(plan.createdAt) : "—"}</InfoRow>
<InfoRow label="Updated At">{plan.updatedAt ? fmtDateTime(plan.updatedAt) : "—"}</InfoRow>
</div>
</SectionCard>
</div>
);
}
// ─── Tab: Payment Policy ───────────────────────────────────────────────────────
const EMPTY_PROMO = { code: "", type: "flat", value: "", currency: "USD", max_discount: "", max_uses: "", expires_at: "", min_amount: "" };
const WINDOW_UNITS = ["minutes", "hours", "days"];
function PaymentPolicyTab({ planId, plan }) {
const [policyLoading, setPolicyLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [refundAllowed, setRefundAllowed] = useState(true);
const [refundWindowValue, setRefundWindowValue] = useState(5);
const [refundWindowUnit, setRefundWindowUnit] = useState("minutes");
const [refundReasonReqd, setRefundReasonReqd] = useState(false);
const [promoRules, setPromoRules] = useState([]);
const [addForm, setAddForm] = useState(EMPTY_PROMO);
const [showAdd, setShowAdd] = useState(false);
const [localizedPrices, setLocalizedPrices] = useState([]);
useEffect(() => {
setPolicyLoading(true);
api.get(`/admin/tier-policies/plans/${planId}/payment-policy`)
.then(({ data }) => {
const p = data.data;
if (p) {
const rp = p.refund_policy ?? {};
setRefundAllowed(rp.allowed ?? true);
setRefundWindowValue(rp.window_value ?? 5);
setRefundWindowUnit(rp.window_unit ?? "minutes");
setRefundReasonReqd(rp.reason_required ?? false);
setPromoRules(p.promo_rules ?? []);
}
})
.catch(() => {})
.finally(() => setPolicyLoading(false));
api.get(`/admin/tiers/${planId}/prices`)
.then(({ data }) => setLocalizedPrices(data.data ?? []))
.catch(() => {});
}, [planId]);
const handleSave = async () => {
setSaving(true);
try {
await api.put(`/admin/tier-policies/plans/${planId}/payment-policy`, {
refund_policy: {
allowed: refundAllowed,
window_value: Number(refundWindowValue),
window_unit: refundWindowUnit,
reason_required: refundReasonReqd,
},
promo_rules: promoRules,
});
toast.success("Payment policy saved.");
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not save payment policy.");
} finally {
setSaving(false);
}
};
const handleAddPromo = () => {
const code = addForm.code.trim().toUpperCase();
if (!code) { toast.error("Code is required."); return; }
if (!addForm.value || Number(addForm.value) <= 0) { toast.error("Value must be greater than 0."); return; }
if (promoRules.some((r) => r.code.toUpperCase() === code)) { toast.error("A rule with this code already exists."); return; }
const rule = {
code,
type: addForm.type,
value: Number(addForm.value),
...(addForm.type === "flat" && addForm.currency ? { currency: addForm.currency.trim().toUpperCase() } : {}),
...(addForm.type === "percent" && addForm.max_discount ? { max_discount: Number(addForm.max_discount) } : {}),
...(addForm.max_uses ? { max_uses: Number(addForm.max_uses) } : {}),
...(addForm.expires_at ? { expires_at: addForm.expires_at } : {}),
...(addForm.min_amount ? { min_amount: Number(addForm.min_amount) } : {}),
};
setPromoRules((prev) => [...prev, rule]);
setAddForm(EMPTY_PROMO);
setShowAdd(false);
};
if (policyLoading) {
return (
<div className="space-y-4">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-32 w-full" />)}
</div>
);
}
return (
<div className="space-y-5">
<SectionCard
icon={ShieldCheck}
title="Refund Policy"
description="Controls whether and how long after purchase a user can request a refund."
>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4">
<div>
<p className="text-sm font-medium">Allow Refunds</p>
<p className="text-xs text-muted-foreground">Users can request a refund within the window below.</p>
</div>
<Switch checked={refundAllowed} onCheckedChange={setRefundAllowed} />
</div>
{refundAllowed && (
<>
<div className="space-y-1.5">
<Label>Refund Window</Label>
<div className="flex gap-2">
<Input
type="number"
min={1}
className="w-28"
value={refundWindowValue}
onChange={(e) => setRefundWindowValue(e.target.value)}
placeholder="5"
/>
<Select value={refundWindowUnit} onValueChange={setRefundWindowUnit}>
<SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
<SelectContent>
{WINDOW_UNITS.map((u) => (
<SelectItem key={u} value={u} className="capitalize">{u}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">
Users have {refundWindowValue || "?"} {refundWindowUnit} from payment to request a refund.
</p>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div>
<p className="text-sm font-medium">Require Reason</p>
<p className="text-xs text-muted-foreground">User must provide a reason when requesting a refund.</p>
</div>
<Switch checked={refundReasonReqd} onCheckedChange={setRefundReasonReqd} />
</div>
</>
)}
</div>
</SectionCard>
<SectionCard
icon={Tag}
title="Promo Codes"
description="Define discount codes users can apply at checkout."
>
{promoRules.length > 0 ? (
<div className="space-y-2">
{promoRules.map((rule) => (
<div key={rule.code} className="flex items-center justify-between gap-3 rounded-lg border bg-muted/30 px-4 py-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
<code className="text-sm font-semibold tracking-wide">{rule.code}</code>
<Badge variant="outline" className="text-xs capitalize shrink-0">{rule.type}</Badge>
<span className="text-sm text-muted-foreground shrink-0">
{rule.type === "flat"
? `${rule.currency ?? "USD"} ${Number(rule.value).toFixed(2)} off`
: `${rule.value}% off${rule.max_discount ? ` (max ${rule.max_discount})` : ""}`}
</span>
{rule.max_uses && (
<span className="text-xs text-muted-foreground shrink-0">· {rule.max_uses} uses max</span>
)}
{rule.expires_at && (
<span className="text-xs text-muted-foreground shrink-0">
· expires {new Date(rule.expires_at).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}
</span>
)}
</div>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive shrink-0"
onClick={() => setPromoRules((prev) => prev.filter((r) => r.code !== rule.code))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<Tag className="size-4 shrink-0" />
No promo codes configured for this plan.
</div>
)}
{showAdd ? (
<div className="rounded-lg border bg-muted/20 p-4 space-y-4">
<p className="text-sm font-medium">New Promo Code</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Code <span className="text-destructive">*</span></Label>
<Input
placeholder="e.g. SAVE10"
value={addForm.code}
onChange={(e) => setAddForm((f) => ({ ...f, code: e.target.value.toUpperCase() }))}
/>
</div>
<div className="space-y-1.5">
<Label>Type <span className="text-destructive">*</span></Label>
<Select value={addForm.type} onValueChange={(v) => setAddForm((f) => ({ ...f, type: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="flat">Flat (fixed amount off)</SelectItem>
<SelectItem value="percent">Percent (% off)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>{addForm.type === "flat" ? "Amount Off" : "Percent Off"} <span className="text-destructive">*</span></Label>
<Input
type="number" step="0.01" min="0.01"
placeholder={addForm.type === "flat" ? "10.00" : "20"}
value={addForm.value}
onChange={(e) => setAddForm((f) => ({ ...f, value: e.target.value }))}
/>
</div>
{addForm.type === "flat" && (
<div className="space-y-1.5">
<Label>Currency</Label>
<Select value={addForm.currency} onValueChange={(v) => setAddForm((f) => ({ ...f, currency: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={plan?.currency ?? "USD"}>{plan?.currency ?? "USD"} — Base price</SelectItem>
{localizedPrices.map((p) => (
<SelectItem key={p.currency} value={p.currency}>{p.currency} — Localized price</SelectItem>
))}
</SelectContent>
</Select>
{localizedPrices.length === 0 && (
<p className="text-xs text-muted-foreground">No localized prices set — only base currency available.</p>
)}
</div>
)}
{addForm.type === "percent" && (
<div className="space-y-1.5">
<Label>Max Discount Cap</Label>
<Input
type="number" step="0.01" placeholder="50.00 (optional)"
value={addForm.max_discount}
onChange={(e) => setAddForm((f) => ({ ...f, max_discount: e.target.value }))}
/>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Max Uses</Label>
<Input
type="number" min="1" placeholder="Unlimited"
value={addForm.max_uses}
onChange={(e) => setAddForm((f) => ({ ...f, max_uses: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label>Expires At</Label>
<DateTimePicker
value={addForm.expires_at || null}
onChange={(iso) => setAddForm((f) => ({ ...f, expires_at: iso ?? "" }))}
placeholder="No expiry"
/>
</div>
</div>
<div className="space-y-1.5">
<Label>Minimum Purchase Amount</Label>
<Input
type="number" step="0.01" placeholder="No minimum"
value={addForm.min_amount}
onChange={(e) => setAddForm((f) => ({ ...f, min_amount: e.target.value }))}
/>
</div>
<div className="flex gap-2 justify-end pt-1">
<Button variant="outline" size="sm" onClick={() => { setShowAdd(false); setAddForm(EMPTY_PROMO); }}>
Cancel
</Button>
<Button size="sm" onClick={handleAddPromo}>
<Plus className="h-4 w-4 mr-1" /> Add Code
</Button>
</div>
</div>
) : (
<Button
variant="outline"
size="sm"
onClick={() => { setAddForm((f) => ({ ...f, currency: plan?.currency ?? "USD" })); setShowAdd(true); }}
>
<Plus className="h-4 w-4 mr-1" /> Add Promo Code
</Button>
)}
</SectionCard>
<div className="flex justify-end gap-3 pt-1">
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save Policy
</Button>
</div>
</div>
);
}
// ─── Tab: Payments ─────────────────────────────────────────────────────────────
function PaymentsTab({ planId }) {
const { fetchPayments } = useTiers();
useEffect(() => {
fetchPayments({ filters: [{ field: "plan_id", value: planId }] });
}, [planId]);
return <PaymentsTable planId={planId} />;
}
// ─── Tabs config ───────────────────────────────────────────────────────────────
const TABS = [
{ key: "details", label: "Plan Details", icon: CreditCard },
{ key: "policy", label: "Payment Policy", icon: ShieldCheck },
{ key: "payments", label: "Payments", icon: Receipt },
];
// ─── Page ──────────────────────────────────────────────────────────────────────
export default function ViewPlan() {
const navigate = useNavigate();
const { planId } = useParams();
const { fetchPlan, plan, loading } = useTiers();
const [activeTab, setActiveTab] = useState("details");
const [tierCategories, setTierCategories] = useState([]);
const tierMap = useMemo(() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])), [tierCategories]);
const [assignedCourses, setAssignedCourses] = useState([]);
const [coursesLoading, setCoursesLoading] = useState(false);
const [assignedCourses, setAssignedCourses] = useState([]);
const [coursesLoading, setCoursesLoading] = useState(false);
useEffect(() => {
fetchPlan(planId);
@@ -88,30 +526,31 @@ export default function ViewPlan() {
}, [planId]);
return (
<section className="bg-muted/60 min-h-full">
<div className="flex flex-col min-h-screen bg-muted/60">
<PageMeta title={plan ? `${plan.label} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Plans", to: "/admin/tiers/plans" },
{ label: plan?.label ?? `Plan #${planId}` },
]} />
</div>
<div className="flex items-start justify-between gap-3 mb-6">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Plan Details</h1>
<p className="text-sm text-muted-foreground">View plan information.</p>
</div>
{/* ── Sticky header ── */}
<div
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
style={{ top: "var(--navbar-h)" }}
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
<CreditCard className="h-5 w-5 text-muted-foreground" />
View Plan
</h1>
{plan && (
<p className="text-sm text-muted-foreground capitalize">
{plan.tier} — {plan.label}
</p>
)}
</div>
<div className="flex items-center gap-2">
{activeTab === "details" && (
<Button
variant="outline"
size="sm"
@@ -119,107 +558,55 @@ export default function ViewPlan() {
disabled={loading}
>
<Pencil className="h-4 w-4 mr-2" />
Edit
Edit Plan
</Button>
</div>
)}
</div>
{loading && !plan ? (
<LoadingSkeleton />
) : !plan ? (
<p className="text-sm text-muted-foreground">Plan not found.</p>
) : (
<div className="space-y-5">
<SectionCard icon={Tag} title="Plan Details">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Label">{plan.label}</InfoRow>
<InfoRow label="Tier">
{(() => { const { cls, label } = resolveTierBadge(plan.tier, tierMap); return <Badge className={`${cls} mt-0.5`}>{label}</Badge>; })()}
</InfoRow>
<InfoRow label="Duration">{formatDuration(plan.duration_days, plan.duration_unit)}</InfoRow>
<InfoRow label="Price">
{plan.currency} {Number(plan.price).toFixed(2)}
</InfoRow>
<InfoRow label="Currency">{plan.currency}</InfoRow>
<InfoRow label="Status">
<Badge variant={plan.is_active ? "default" : "secondary"} className="mt-0.5">
{plan.is_active ? "Active" : "Inactive"}
</Badge>
</InfoRow>
</div>
{plan.description && (
<div className="flex flex-col gap-0.5 pt-1">
<span className="text-xs text-muted-foreground uppercase tracking-wide">Description</span>
<p className="text-sm">{plan.description}</p>
</div>
)}
</SectionCard>
<SectionCard icon={BookOpen} title="Assigned Courses">
{coursesLoading ? (
<div className="space-y-2">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : assignedCourses.length === 0 ? (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<BookOpen className="size-4 shrink-0" />
No courses assigned to this plan yet.
</div>
) : (
<div className="space-y-0 divide-y rounded-lg border overflow-hidden">
{assignedCourses.map((course) => (
<div key={course.course_id} className="flex items-center justify-between gap-3 px-4 py-3 bg-card hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="h-8 w-8 rounded-md bg-muted flex items-center justify-center shrink-0">
<BookOpen className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium line-clamp-1">{course.title}</p>
{(course.course_code || course.level) && (
<div className="flex items-center gap-2 mt-0.5">
{course.course_code && (
<span className="text-xs text-muted-foreground">{course.course_code}</span>
)}
{course.level && (
<Badge variant="outline" className="text-xs capitalize h-4 px-1.5">{course.level}</Badge>
)}
</div>
)}
</div>
</div>
{formatCourseDuration(course.duration_seconds) && (
<span className="text-xs text-muted-foreground flex items-center gap-1 shrink-0">
<Clock className="size-3" />
{formatCourseDuration(course.duration_seconds)}
</span>
)}
</div>
))}
</div>
)}
{!coursesLoading && (
<p className="text-xs text-muted-foreground">
{assignedCourses.length} course{assignedCourses.length !== 1 ? "s" : ""} assigned
</p>
)}
</SectionCard>
<SectionCard icon={BadgeCheck} title="Audit">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Created At">
{plan.createdAt ? fmtDateTime(plan.createdAt) : "—"}
</InfoRow>
<InfoRow label="Updated At">
{plan.updatedAt ? fmtDateTime(plan.updatedAt) : "—"}
</InfoRow>
</div>
</SectionCard>
</div>
)}
{/* Underline tabs */}
<div className="flex gap-1 pb-0 -mb-px">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={[
"flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors",
activeTab === key
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground",
].join(" ")}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
</div>
</div>
</section>
{/* ── Content ── */}
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
{activeTab === "payments" ? (
<div className="pb-16">
<PaymentsTab planId={planId} />
</div>
) : (
<div className="max-w-3xl mx-auto space-y-5 pb-16">
{activeTab === "details" && (
<PlanDetailsTab
plan={plan}
loading={loading}
tierMap={tierMap}
assignedCourses={assignedCourses}
coursesLoading={coursesLoading}
/>
)}
{activeTab === "policy" && (
<PaymentPolicyTab planId={planId} plan={plan} />
)}
</div>
)}
</div>
</div>
);
}
}