mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -0,0 +1,492 @@
|
||||
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 { 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);
|
||||
|
||||
// ── Localized prices (for notice in promo section)
|
||||
const [localizedPrices, setLocalizedPrices] = useState([]);
|
||||
|
||||
// ─── 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));
|
||||
|
||||
api.get(`/admin/tiers/${planId}/prices`)
|
||||
.then(({ data }) => setLocalizedPrices(data.data ?? []))
|
||||
.catch(() => {});
|
||||
}, [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.success("Payment policy saved.");
|
||||
navigate("/admin/tiers/plans");
|
||||
} catch (err) {
|
||||
toast.error(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.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);
|
||||
};
|
||||
|
||||
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(-1)}>
|
||||
<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">
|
||||
|
||||
{/* ── 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}
|
||||
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."
|
||||
>
|
||||
{/* 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">
|
||||
{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(-1)} 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user