mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
add: more commits
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user