mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
445 lines
21 KiB
React
445 lines
21 KiB
React
import { useEffect, useState } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
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";
|
|
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 { Badge } from "@/components/ui/badge";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
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_PROMO = {
|
|
code: "", type: "flat", value: "", currency: "USD",
|
|
max_discount: "", max_uses: "", expires_at: "", min_amount: "",
|
|
};
|
|
|
|
const WINDOW_UNITS = ["minutes", "hours", "days"];
|
|
|
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
|
|
|
export default function PaymentPolicy() {
|
|
const navigate = useNavigate();
|
|
const { planId } = useParams();
|
|
const { fetchPlan, plan, loading: planLoading } = useTiers();
|
|
|
|
const [policy, setPolicy] = useState(null);
|
|
const [policyLoading, setPolicyLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
// ── Refund policy fields
|
|
const [refundAllowed, setRefundAllowed] = useState(true);
|
|
const [refundWindowValue, setRefundWindowValue] = useState(5);
|
|
const [refundWindowUnit, setRefundWindowUnit] = useState("minutes");
|
|
const [refundReasonReqd, setRefundReasonReqd] = useState(false);
|
|
|
|
// ── Promo rules
|
|
const [promoRules, setPromoRules] = useState([]);
|
|
const [addForm, setAddForm] = useState(EMPTY_PROMO);
|
|
const [showAdd, setShowAdd] = useState(false);
|
|
|
|
// ─── Load ────────────────────────────────────────────────────────────────────
|
|
|
|
useEffect(() => {
|
|
fetchPlan(planId);
|
|
setPolicyLoading(true);
|
|
api.get(`/admin/tier-policies/plans/${planId}/payment-policy`)
|
|
.then(({ data }) => {
|
|
const p = data.data;
|
|
if (p) {
|
|
setPolicy(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));
|
|
|
|
}, [planId]);
|
|
|
|
// ─── Save ────────────────────────────────────────────────────────────────────
|
|
|
|
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("Payment policy saved.");
|
|
navigate("/admin/tiers/plans");
|
|
} catch (err) {
|
|
toast(err?.response?.data?.message ?? "Could not save payment policy.");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
// ─── Promo helpers ────────────────────────────────────────────────────────────
|
|
|
|
const handleAddPromo = () => {
|
|
const code = addForm.code.trim().toUpperCase();
|
|
if (!code) { toast("Code is required."); return; }
|
|
if (!addForm.value || Number(addForm.value) <= 0) { toast("Value must be greater than 0."); return; }
|
|
if (promoRules.some((r) => r.code.toUpperCase() === code)) {
|
|
toast("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);
|
|
};
|
|
|
|
const handleRemovePromo = (code) =>
|
|
setPromoRules((prev) => prev.filter((r) => r.code !== code));
|
|
|
|
// ─── Render ───────────────────────────────────────────────────────────────────
|
|
|
|
const isLoading = planLoading || policyLoading;
|
|
|
|
return (
|
|
<section className="bg-muted/60 min-h-full">
|
|
<PageMeta title={plan ? `Payment Policy — ${plan.label} - STARR` : "Payment Policy - 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: plan?.label ?? `Plan #${planId}`, to: `/admin/tiers/plans/${planId}/view` },
|
|
{ label: "Payment Policy" },
|
|
]} />
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/tiers/plans/${planId}/view`)}>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</Button>
|
|
<div>
|
|
<h1 className="text-xl font-semibold">Payment Policy</h1>
|
|
<p className="text-sm text-muted-foreground capitalize">
|
|
{plan?.tier} — {plan?.label}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="space-y-4">
|
|
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-32 w-full" />)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-5">
|
|
|
|
{/* ── Refund Policy ─────────────────────────────────────────── */}
|
|
<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>
|
|
|
|
{/* ── Promo Codes ───────────────────────────────────────────── */}
|
|
<SectionCard
|
|
icon={Tag}
|
|
title="Promo Codes"
|
|
description="Define discount codes users can apply at checkout. Flat reduces price by a fixed amount; percent reduces by a percentage."
|
|
>
|
|
{/* Existing rules */}
|
|
{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={() => handleRemovePromo(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>
|
|
)}
|
|
|
|
{/* Add form */}
|
|
{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>
|
|
|
|
{/* ── Save ─────────────────────────────────────────────────── */}
|
|
<div className="flex justify-end gap-3 pt-1">
|
|
<Button variant="outline" onClick={() => navigate(`/admin/tiers/plans/${planId}/view`)} disabled={saving}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleSave} disabled={saving}>
|
|
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
|
Save Policy
|
|
</Button>
|
|
</div>
|
|
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|