admin/features/events/detail/EventDiscountsPanel.tsx
alisaza 3b86c2c72f feat(api): add notifications endpoints for user notifications
Introduced new API endpoints for managing in-app notifications. The `/api/v1/notifications/me` endpoint retrieves a paginated list of notifications for the authenticated user, while the `/api/v1/notifications/me/unread-count` endpoint returns the count of unread notifications. Enhanced the OpenAPI documentation to reflect these changes, including detailed parameter descriptions and response schemas for better clarity and usability.
2026-09-13 13:04:02 +03:30

551 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) => {
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={() => {
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