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.
547 lines
20 KiB
TypeScript
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
|