Files
starr-philproperties/src/modules/admin/pages/tiers/PaymentPolicy.jsx
T
kennethobsequio b1805cfe4d try to deploy
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-07-13 21:27:56 +08:00

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>
);
}