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