"use client"; import { ArrowRight, Loader2, SlidersHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRef, useState } from "react"; import { toast } from "sonner"; import { applyBulkOffers, previewBulkOffers, undoBulkOffers, } from "@/actions/catalog-bulk"; import { MovePageCombobox } from "@/components/admin/catalog/catalog-items-table/field-helpers"; import { POINTS_TYPES } from "@/components/admin/catalog/catalog-items-table/types"; import { CurrencyIcon, currencyKindFromPointsType, } from "@/components/shared/currency-icon"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; type BulkInput = Parameters[0]; type Preview = Extract< Awaited>, { ok: true } >["data"]; type PriceMode = "set" | "add" | "percent"; type PriceField = "costCredits" | "costPoints"; type PriceDraft = { enabled: boolean; mode: PriceMode; value: string }; const emptyPrice = (): PriceDraft => ({ enabled: false, mode: "set", value: "0", }); export function BulkOfferEditor({ ids, allPages, catalog = "normal", disabled = false, onApplied, beforeEdit, }: { ids: number[]; allPages: { id: number; caption: string }[]; /** Which catalog the selection belongs to. BC has no undo history. */ catalog?: "normal" | "bc"; disabled?: boolean; onApplied: () => void | Promise; beforeEdit?: () => boolean; }) { const t = useTranslations("pages.admin.catalog.bulk"); const [open, setOpen] = useState(false); const [prices, setPrices] = useState>({ costCredits: emptyPrice(), costPoints: emptyPrice(), }); const [currencyEnabled, setCurrencyEnabled] = useState(false); const [pointsType, setPointsType] = useState(0); const [destinationEnabled, setDestinationEnabled] = useState(false); const [pageId, setPageId] = useState(null); const [preview, setPreview] = useState<{ input: BulkInput; data: Preview; requestKey: string; } | null>(null); const [busy, setBusy] = useState(false); const busyRef = useRef(false); const [error, setError] = useState(null); const tooMany = ids.length > 500; const selectedFields = prices.costCredits.enabled || prices.costPoints.enabled || currencyEnabled || destinationEnabled; function start() { if (disabled || tooMany || (beforeEdit && !beforeEdit())) return; setPrices({ costCredits: emptyPrice(), costPoints: emptyPrice() }); setCurrencyEnabled(false); setDestinationEnabled(false); setPageId(null); setPreview(null); setError(null); setOpen(true); } function updatePrice(field: PriceField, patch: Partial) { setPrices((previous) => ({ ...previous, [field]: { ...previous[field], ...patch }, })); } async function requestPreview() { if (busyRef.current || disabled || tooMany || (beforeEdit && !beforeEdit())) return; const changes: BulkInput["changes"] = {}; for (const field of ["costCredits", "costPoints"] as const) { const price = prices[field]; if (!price.enabled) continue; const value = Number(price.value); if ( !price.value.trim() || !Number.isFinite(value) || (price.mode !== "percent" && !Number.isInteger(value)) ) { setError(t("invalidPrice")); return; } changes[field] = { mode: price.mode, value }; } if (currencyEnabled) changes.pointsType = pointsType; if (destinationEnabled) { if (pageId === null) { setError(t("chooseDestination")); return; } changes.pageId = pageId; } if (!selectedFields) { setError(t("chooseFields")); return; } const input: BulkInput = { ids: [...ids], changes }; busyRef.current = true; setBusy(true); setError(null); try { const result = await previewBulkOffers(input, catalog); if (!result.ok) { setError(result.error); return; } setPreview({ input, data: result.data, requestKey: crypto.randomUUID() }); } catch { setError(t("requestFailed")); } finally { busyRef.current = false; setBusy(false); } } async function apply() { if ( !preview || busyRef.current || disabled || (beforeEdit && !beforeEdit()) ) return; busyRef.current = true; setBusy(true); setError(null); try { const result = await applyBulkOffers( preview.input, preview.data.fingerprint, preview.requestKey, catalog, ); if (!result.ok) { setError(result.error); setPreview(null); return; } const undoKey = crypto.randomUUID(); toast.success(t("success", { count: result.data.changedCount }), { duration: 15000, description: t("undoHint"), action: result.data.historyIds.length ? { label: t("undo"), onClick: async () => { if (busyRef.current || (beforeEdit && !beforeEdit())) return; busyRef.current = true; try { const restored = await undoBulkOffers( result.data.historyIds, undoKey, ); if (!restored.ok) { toast.error(restored.error); return; } toast.success( t("undoSuccess", { count: restored.data.changedCount }), ); await onApplied(); } catch { toast.error(t("requestFailed")); } finally { busyRef.current = false; } }, } : undefined, }); setOpen(false); await onApplied(); } catch { setError(t("requestFailed")); } finally { busyRef.current = false; setBusy(false); } } const pageNames = new Map( (preview?.data.pages ?? allPages).map((page) => [page.id, page.caption]), ); const currencyName = (type: number) => POINTS_TYPES[type] ? t(`currencies.${type}`) : t("unknownCurrency", { type }); const renderChange = (before: React.ReactNode, after: React.ReactNode) => ( {before} {after} ); return ( <>
{(disabled || tooMany) && ( {t(tooMany ? "selectionLimit" : "disabledReason")} )}
{ if (!busyRef.current) setOpen(value); }} > {t(preview ? "previewTitle" : "title")} {t("description", { count: ids.length })} {error && (

{error}

)} {!preview ? (

{t("fieldHint")}

{/* A BC offer has no price, points or currency column to write. The server refuses those changes too; hiding the fields keeps the dialog from offering what cannot be done. */} {catalog !== "bc" && ( <> {(["costCredits", "costPoints"] as const).map((field) => (
{prices[field].enabled && (
updatePrice(field, { value: event.target.value, }) } disabled={busy} />
)}
))}
{currencyEnabled && ( )}
)}
{destinationEnabled && ( )}

{t("priceHint")}

{t("visibilityHint")}

) : (

{t("previewCount", { count: preview.data.changedCount, total: preview.data.rows.length, })}

{t("beforeAfter")}

{(catalog === "bc" ? ["offer", "destination"] : [ "offer", "costCredits", "costPoints", "currency", "destination", ] ).map((key) => ( {t(key)} ))} {preview.data.rows.map((row) => (
{row.catalogName}
#{row.id}
{catalog !== "bc" && ( <> {renderChange( row.before.costCredits, row.after.costCredits, )} {renderChange( row.before.costPoints, row.after.costPoints, )} {renderChange( currencyName(row.before.pointsType), currencyName(row.after.pointsType), )} )} {renderChange( `${pageNames.get(row.before.pageId) ?? ""} #${row.before.pageId}`, `${pageNames.get(row.after.pageId) ?? ""} #${row.after.pageId}`, )}
))}
{preview.data.changedCount === 0 && (

{t("noChanges")}

)}
)}
{preview && ( )}
); }