Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
530 lines
19 KiB
TypeScript
530 lines
19 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'
|
|
|
|
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 [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 handleCreate = async () => {
|
|
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
|
|
}
|
|
|
|
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 handleToggleActive = async (code: DiscountCode, nextActive: boolean) => {
|
|
if (!canMutateCodes) {
|
|
addToast({ title: texts.events.discountToggleAfterEnd, color: 'warning' })
|
|
|
|
return
|
|
}
|
|
|
|
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 = async (code: DiscountCode) => {
|
|
if (!canMutateCodes) {
|
|
addToast({ title: texts.events.discountDeleteAfterEnd, color: 'warning' })
|
|
|
|
return
|
|
}
|
|
|
|
if (code.redeemedCount > 0) {
|
|
addToast({ title: texts.events.discountDeleteUsed, color: 'warning' })
|
|
|
|
return
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
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
|