admin/features/events/detail/EventDiscountsPanel.tsx
alisaza 96603d31f7 feat(alert-modal): integrate alert modal for confirmation actions
Enhanced various components to utilize the alert modal for user confirmations before executing critical actions. This includes marking messages as read, saving notification rules, restoring reviews, sending replies, changing ticket statuses, and managing event commissions. The integration improves user experience by ensuring actions are intentional and provides clear feedback on the outcomes.
2026-09-13 10:14:18 +03:30

547 lines
20 KiB
TypeScript

'use client'
import { useCallback, useEffect, useState } from 'react'
import { texts, format } from '@/texts'
import type { DiscountBearer, DiscountCode, DiscountRedemption, DiscountReport, DiscountType } from '@/services/discountCodes'
import { Chip } from '@/components/heroui/Chip'
import { addToast } from '@/lib/toast'
import Button from '@/components/formElements/Button'
import Input from '@/components/formElements/Input'
import StatusChip from '@/components/ui/StatusChip'
import { getActiveStatus } from '@/constants/status'
import { convertPersianToEnglish, formatCurrency, formatNumber, coerceToString } from '@/helpers'
import { formatPersianDate } from '@/lib/formatters'
import {
BULK_CREATE_DISCOUNT_CODES,
DELETE_DISCOUNT_CODE,
GET_DISCOUNT_MANAGEMENT_BOOTSTRAP,
SET_DISCOUNT_CODE_ACTIVE,
} from '@/services/discountCodes'
import useAlertModal from '@/hooks/useAlertModal'
interface EventDiscountsPanelProps {
eventId: string
isFree: boolean
isEnded?: boolean
/** Admins can shift the discount cost onto the platform. Hosts always absorb it. */
isAdmin?: boolean
}
const number = formatNumber
const toInt = (value: string): number | undefined => {
const parsed = Number(convertPersianToEnglish(value).replace(/[^\d]/g, ''))
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
}
const describeCode = (code: DiscountCode): string =>
code.type === 'percent'
? format(texts.events.percentOff, { value: number(code.value) })
: format(texts.events.amountOff, { value: formatCurrency(code.value) })
const DISCOUNT_TYPE_OPTIONS = [
{ code: 'percent', name: texts.events.discountTypePercent },
{ code: 'fixed', name: texts.events.discountTypeFixed },
] as const
const DISCOUNT_BEARER_OPTIONS = [
{ code: 'organizer', name: texts.events.host },
{ code: 'platform', name: texts.events.bearerPlatform },
] as const
const EventDiscountsPanel = ({ eventId, isFree, isEnded = false, isAdmin = false }: EventDiscountsPanelProps) => {
const { showAlert } = useAlertModal()
const [codes, setCodes] = useState<DiscountCode[]>([])
const [redemptions, setRedemptions] = useState<DiscountRedemption[]>([])
const [report, setReport] = useState<DiscountReport | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Create form
const [type, setType] = useState<DiscountType>('percent')
const [value, setValue] = useState('')
const [quantity, setQuantity] = useState('1')
const [maxUses, setMaxUses] = useState('')
const [maxUsesPerUser, setMaxUsesPerUser] = useState('')
const [bearer, setBearer] = useState<DiscountBearer>('organizer')
const [showAdvanced, setShowAdvanced] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [pendingCodeId, setPendingCodeId] = useState<string | null>(null)
const [lastBatch, setLastBatch] = useState<string[] | null>(null)
const load = useCallback(async () => {
setIsLoading(true)
setError(null)
const result = await GET_DISCOUNT_MANAGEMENT_BOOTSTRAP(eventId)
if (!result.ok) {
setError(texts.events.discountsLoadFailed)
setIsLoading(false)
return
}
setCodes(result.data.codes.items)
setReport(result.data.report)
setRedemptions(result.data.redemptions.items)
setIsLoading(false)
}, [eventId])
useEffect(() => {
if (!isFree) void load()
else setIsLoading(false)
}, [isFree, load])
const canCreate = !isFree && !isEnded
const canMutateCodes = !isEnded
const executeCreate = async (numericValue: number, numericQuantity: number) => {
setIsCreating(true)
const result = await BULK_CREATE_DISCOUNT_CODES(eventId, {
type,
value: numericValue,
quantity: numericQuantity,
maxUses: toInt(maxUses),
maxUsesPerUser: toInt(maxUsesPerUser),
bearer: isAdmin ? bearer : undefined,
})
setIsCreating(false)
if (!result.ok) {
addToast({ title: texts.events.discountCreateFailed, description: result.error.message, color: 'danger' })
return
}
addToast({ title: format(texts.events.discountCreatedCount, { count: number(result.data.count) }), color: 'success' })
setLastBatch(result.data.codes)
setValue('')
setQuantity('1')
setMaxUses('')
setMaxUsesPerUser('')
await load()
}
const handleCreate = () => {
const numericValue = toInt(value)
const numericQuantity = toInt(quantity)
if (!numericValue) {
addToast({ title: texts.events.discountValueRequired, color: 'warning' })
return
}
if (type === 'percent' && (numericValue < 1 || numericValue > 99)) {
addToast({ title: texts.events.discountPercentRange, color: 'warning' })
return
}
if (!numericQuantity) {
addToast({ title: texts.events.discountCountRequired, color: 'warning' })
return
}
showAlert(`${number(numericQuantity)} کد تخفیف ساخته شود؟`, () => {
void executeCreate(numericValue, numericQuantity)
})
}
const handleToggleActive = (code: DiscountCode, nextActive: boolean) => {
if (!canMutateCodes) {
addToast({ title: texts.events.discountToggleAfterEnd, color: 'warning' })
return
}
showAlert(nextActive ? `کد «${code.code}» فعال شود؟` : `کد «${code.code}» غیرفعال شود؟`, async () => {
setPendingCodeId(code.id)
const result = await SET_DISCOUNT_CODE_ACTIVE(code.id, nextActive)
setPendingCodeId(null)
if (!result.ok) {
addToast({ title: texts.events.discountToggleFailed, color: 'danger' })
return
}
setCodes((current) => current.map((item) => (item.id === code.id ? { ...item, isActive: nextActive } : item)))
})
}
const handleDelete = (code: DiscountCode) => {
if (!canMutateCodes) {
addToast({ title: texts.events.discountDeleteAfterEnd, color: 'warning' })
return
}
if (code.redeemedCount > 0) {
addToast({ title: texts.events.discountDeleteUsed, color: 'warning' })
return
}
showAlert(
`کد تخفیف «${code.code}» حذف شود؟`,
async () => {
setPendingCodeId(code.id)
const result = await DELETE_DISCOUNT_CODE(code.id)
setPendingCodeId(null)
if (!result.ok) {
addToast({ title: texts.events.discountDeleteFailed, description: result.error.message, color: 'danger' })
return
}
addToast({ title: texts.events.discountDeleted, color: 'success' })
setCodes((current) => current.filter((item) => item.id !== code.id))
},
undefined,
{ dangerAccept: true }
)
}
const copyCodes = async (list: string[]) => {
try {
await navigator.clipboard.writeText(list.join('\n'))
addToast({ title: texts.events.codesCopied, color: 'success' })
} catch {
addToast({ title: texts.events.copyFailed, color: 'danger' })
}
}
if (isFree) {
return (
<div className="consumer-surface p-6 text-center">
<h2 className="font-extrabold text-secondary-10">{texts.events.eventIsFreeTitle}</h2>
<p className="mt-2 text-sm text-secondary-30">{texts.events.freeEventNoDiscounts}</p>
</div>
)
}
return (
<section className="space-y-4">
{report ? (
<div className="space-y-1">
<div className="consumer-surface flex items-center justify-between p-3">
<span className="text-xs text-secondary-20">{texts.events.activeCodes}</span>
<strong className="mt-2 block text-lg font-semibold text-consumer-text">{number(report.totalCodes)}</strong>
</div>
<div className="consumer-surface flex items-center justify-between p-3">
<span className="text-xs text-secondary-20">{texts.events.redemptionCount}</span>
<strong className="mt-2 block text-lg font-semibold text-consumer-text">{number(report.totalRedemptions)}</strong>
</div>
<div className="consumer-surface flex items-center justify-between p-3">
<span className="text-xs text-secondary-20">{texts.events.totalDiscount}</span>
<strong className="mt-2 block font-extrabold text-consumer-text">{formatCurrency(report.totalDiscountAmount)}</strong>
</div>
<div className="consumer-surface flex items-center justify-between p-3">
<span className="text-xs text-secondary-20">{texts.events.platformDiscountShare}</span>
<strong className="mt-2 block font-extrabold text-consumer-text">{formatCurrency(report.totalPlatformAbsorbed)}</strong>
</div>
</div>
) : null}
<section className="consumer-surface space-y-4 p-5">
<div>
<h2 className="font-semibold text-secondary-10">{texts.events.createDiscountTitle}</h2>
<p className="mt-1 text-xs leading-6 text-secondary-30">{texts.events.createDiscountIntro}</p>
</div>
{!canCreate ? (
<div className="rounded-2xl bg-fourth-100 p-4 text-sm text-fourth-700">{texts.events.createDiscountEnded}</div>
) : (
<>
<div className="space-y-3">
<Input
generalType="select"
label={texts.events.discountTypeLabel}
name="discountType"
selectKey="code"
selectOptions={[...DISCOUNT_TYPE_OPTIONS]}
selectValue="name"
value={type}
variant="flat"
onValueChange={(next) => {
setType(coerceToString(next) as DiscountType)
}}
/>
<Input
englishDigitsOnly
direction="ltr"
generalType="input"
label={type === 'percent' ? texts.events.discountPercentValueLabel : texts.events.discountFixedValueLabel}
name="discountValue"
placeholder={type === 'percent' ? '20' : '50000'}
value={value}
variant="bordered"
onValueChange={(next) => {
setValue(coerceToString(next))
}}
/>
<Input
englishDigitsOnly
direction="ltr"
generalType="input"
label={texts.events.uniqueCodeCountLabel}
name="discountQuantity"
placeholder="1"
value={quantity}
variant="bordered"
onValueChange={(next) => {
setQuantity(coerceToString(next))
}}
/>
{isAdmin ? (
<Input
description={texts.events.bearerPlatformHint}
generalType="select"
label={texts.events.bearerLabel}
name="discountBearer"
selectKey="code"
selectOptions={[...DISCOUNT_BEARER_OPTIONS]}
selectValue="name"
value={bearer}
variant="bordered"
onValueChange={(next) => {
setBearer(coerceToString(next) as DiscountBearer)
}}
/>
) : null}
</div>
<Button
className="text-xs text-primary"
size="sm"
variant="light"
onClick={() => {
setShowAdvanced((prev) => !prev)
}}
>
{showAdvanced ? texts.events.advancedSettingsClose : texts.events.advancedSettingsOpen}
</Button>
{showAdvanced ? (
<div className="space-y-3">
<Input
englishDigitsOnly
direction="ltr"
generalType="input"
label={texts.events.maxUsesLabel}
name="maxUses"
placeholder={texts.events.unlimitedPlaceholder}
value={maxUses}
variant="bordered"
onValueChange={(next) => {
setMaxUses(coerceToString(next))
}}
/>
<Input
englishDigitsOnly
direction="ltr"
generalType="input"
label={texts.events.maxUsesPerUserLabel}
name="maxUsesPerUser"
placeholder={texts.events.unlimitedPlaceholder}
value={maxUsesPerUser}
variant="bordered"
onValueChange={(next) => {
setMaxUsesPerUser(coerceToString(next))
}}
/>
</div>
) : null}
<Button
fullWidth
isLoading={isCreating}
size="lg"
onClick={handleCreate}
>
{texts.events.createDiscountTitle}
</Button>
</>
)}
{lastBatch?.length ? (
<div className="rounded-2xl border border-fifth-200 bg-fifth-50 p-4">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-bold text-fifth-700">
{format(texts.events.codesCreatedCount, { count: number(lastBatch.length) })}
</p>
<Button
size="sm"
variant="flat"
onClick={() => void copyCodes(lastBatch)}
>
{texts.events.copyAll}
</Button>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{lastBatch.map((code) => (
<span
key={code}
className="rounded-lg bg-white px-3 py-1 font-mono text-sm font-bold text-secondary-20"
dir="ltr"
>
{code}
</span>
))}
</div>
</div>
) : null}
</section>
<section className="space-y-3">
<h2 className="font-extrabold text-secondary-10">{texts.events.definedCodesTitle}</h2>
{isLoading ? (
<div className="space-y-3">
{[0, 1, 2].map((item) => (
<div
key={item}
className="h-24 animate-pulse rounded-3xl border border-secondary-40 bg-white"
/>
))}
</div>
) : error ? (
<div className="rounded-2xl bg-fourth-100 p-4 text-sm text-fourth-700">{error}</div>
) : codes.length === 0 ? (
<div className="rounded-3xl border border-dashed border-secondary-40 bg-white p-6 text-center text-sm text-secondary-30">
{texts.events.noDiscountCodes}
</div>
) : (
<div className="space-y-3">
{codes.map((code) => (
<article
key={code.id}
className="consumer-surface space-y-3 p-4"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3
className="truncate font-mono text-base font-extrabold text-secondary-10"
dir="ltr"
>
{code.code}
</h3>
{code.bearer === 'platform' ? (
<Chip
color="secondary"
size="sm"
variant="flat"
>
{texts.events.platformCostChip}
</Chip>
) : null}
</div>
<p className="mt-1 text-sm font-semibold text-primary-700">{describeCode(code)}</p>
</div>
<StatusChip {...getActiveStatus(code.isActive)} />
</div>
<div className="grid grid-cols-3 gap-2 rounded-2xl bg-secondary-50 p-3 text-xs">
<div>
<span className="block text-secondary-30">{texts.events.redemptionCount}</span>
<span className="mt-1 block font-bold text-secondary-20">
{number(code.redeemedCount)}
{code.maxUses ? ` / ${number(code.maxUses)}` : ''}
</span>
</div>
<div>
<span className="block text-secondary-30">{texts.events.totalDiscount}</span>
<span className="mt-1 block font-bold text-secondary-20">{formatCurrency(code.totalDiscountAmount)}</span>
</div>
<div>
<span className="block text-secondary-30">{texts.events.createdLabel}</span>
<span className="mt-1 block font-bold text-secondary-20">{formatPersianDate(code.createdAt)}</span>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Input
disabled={!canMutateCodes || pendingCodeId === code.id}
generalType="switch"
label=""
name={`code-active-${code.id}`}
value={code.isActive}
onValueChange={(next) => void handleToggleActive(code, Boolean(next))}
/>
<span className="text-xs text-secondary-30">
{isEnded ? texts.events.eventEndedShort : code.isActive ? texts.events.availableToGuests : texts.events.deactivated}
</span>
</div>
<Button
color="danger"
disabled={!canMutateCodes || code.redeemedCount > 0 || pendingCodeId === code.id}
size="sm"
variant="flat"
onClick={() => void handleDelete(code)}
>
{texts.common.delete}
</Button>
</div>
</article>
))}
</div>
)}
</section>
{!isLoading && !error && redemptions.length ? (
<section className="space-y-3">
<h2 className="font-extrabold text-secondary-10">
{format(texts.events.redeemedCodesTitle, { count: number(redemptions.length) })}
</h2>
<div className="space-y-2">
{redemptions.map((r) => (
<article
key={r.id}
className="consumer-surface flex items-center justify-between gap-3 p-4"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span
className="font-mono text-sm font-bold text-secondary-10"
dir="ltr"
>
{r.code}
</span>
{r.bearer === 'platform' ? (
<Chip
color="secondary"
size="sm"
variant="flat"
>
{texts.events.bearerPlatform}
</Chip>
) : null}
</div>
<p className="mt-1 text-xs text-secondary-30">{formatPersianDate(r.createdAt)}</p>
</div>
<div className="text-left">
<p className="text-sm font-bold text-fifth-700">-{formatCurrency(r.discountAmount)}</p>
<p className="mt-1 text-xs text-secondary-30">
{format(texts.events.payableAmount, { amount: formatCurrency(r.payableAmount) })}
</p>
</div>
</article>
))}
</div>
</section>
) : null}
</section>
)
}
export default EventDiscountsPanel