feat(events): manage tiered cancellation policies
This commit is contained in:
parent
0421b4f6ee
commit
98e6482c0c
@ -5,44 +5,77 @@ import { texts } from '@/texts'
|
|||||||
|
|
||||||
interface CancellationPolicyEditorProps {
|
interface CancellationPolicyEditorProps {
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
value: number
|
cancellationFeePercent: number
|
||||||
onChange: (value: number) => void
|
cancellationFeePercent12To24Hours: number
|
||||||
|
cancellationFeePercentMoreThan24Hours: number
|
||||||
|
onChange: (
|
||||||
|
field: 'cancellationFeePercent' | 'cancellationFeePercent12To24Hours' | 'cancellationFeePercentMoreThan24Hours',
|
||||||
|
value: number
|
||||||
|
) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes the cancellation fee explicit to hosts while keeping the persisted
|
* Makes all three time-based cancellation fee tiers explicit to hosts.
|
||||||
* event contract to the existing `cancellationFeePercent` field.
|
|
||||||
*/
|
*/
|
||||||
export default function CancellationPolicyEditor({ disabled = false, value, onChange }: CancellationPolicyEditorProps) {
|
export default function CancellationPolicyEditor({
|
||||||
const normalizedValue = Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 0
|
disabled = false,
|
||||||
|
cancellationFeePercent,
|
||||||
|
cancellationFeePercent12To24Hours,
|
||||||
|
cancellationFeePercentMoreThan24Hours,
|
||||||
|
onChange,
|
||||||
|
}: CancellationPolicyEditorProps) {
|
||||||
|
const tiers = [
|
||||||
|
{
|
||||||
|
field: 'cancellationFeePercentMoreThan24Hours' as const,
|
||||||
|
label: texts.events.cancellationFeeMoreThan24HoursLabel,
|
||||||
|
value: cancellationFeePercentMoreThan24Hours,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'cancellationFeePercent12To24Hours' as const,
|
||||||
|
label: texts.events.cancellationFee12To24HoursLabel,
|
||||||
|
value: cancellationFeePercent12To24Hours,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'cancellationFeePercent' as const,
|
||||||
|
label: texts.events.cancellationFeeLabel,
|
||||||
|
value: cancellationFeePercent,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-consumer-control bg-[#E2E2E280] p-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
{tiers.map((tier) => {
|
||||||
<div>
|
const normalizedValue = Number.isFinite(tier.value) ? Math.min(100, Math.max(0, tier.value)) : 0
|
||||||
<p className="text-sm font-medium text-consumer-text">{texts.events.cancellationFeeLabel}</p>
|
|
||||||
<p className="mt-1 text-xs leading-5 text-secondary-20">{texts.events.cancellationSectionDescription}</p>
|
|
||||||
</div>
|
|
||||||
<div className="relative shrink-0">
|
|
||||||
<Input
|
|
||||||
aria-label={texts.events.cancellationFeeLabel}
|
|
||||||
disabled={disabled}
|
|
||||||
generalType="numberInput"
|
|
||||||
inputWrapper="h-10 w-16 rounded-xl border-0 bg-white px-2 text-center text-sm font-medium text-tertiary-900 shadow-none [&_input]:text-center disabled:bg-white/70"
|
|
||||||
maxValue={100}
|
|
||||||
minValue={0}
|
|
||||||
name="cancellationFeePercent"
|
|
||||||
value={normalizedValue}
|
|
||||||
onValueChange={(nextValue) => {
|
|
||||||
const next = Number(nextValue)
|
|
||||||
|
|
||||||
onChange(Number.isFinite(next) ? Math.min(100, Math.max(0, next)) : 0)
|
return (
|
||||||
}}
|
<div
|
||||||
/>
|
key={tier.field}
|
||||||
{/* This marker belongs to the compact numeric field, not to its value. */}
|
className="rounded-consumer-control bg-[#E2E2E280] p-3"
|
||||||
<span className="pointer-events-none absolute inset-y-0 start-1 flex items-center text-xs text-secondary-20">٪</span>
|
>
|
||||||
</div>
|
<div className="flex items-center justify-between gap-3">
|
||||||
</div>
|
<p className="text-sm font-medium text-consumer-text">{tier.label}</p>
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<Input
|
||||||
|
aria-label={tier.label}
|
||||||
|
disabled={disabled}
|
||||||
|
generalType="numberInput"
|
||||||
|
inputWrapper="h-10 w-16 rounded-xl border-0 bg-white px-2 text-center text-sm font-medium text-tertiary-900 shadow-none [&_input]:text-center disabled:bg-white/70"
|
||||||
|
maxValue={100}
|
||||||
|
minValue={0}
|
||||||
|
name={tier.field}
|
||||||
|
value={normalizedValue}
|
||||||
|
onValueChange={(nextValue) => {
|
||||||
|
const next = Number(nextValue)
|
||||||
|
|
||||||
|
onChange(tier.field, Number.isFinite(next) ? Math.min(100, Math.max(0, next)) : 0)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 start-1 flex items-center text-xs text-secondary-20">٪</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -239,6 +239,8 @@ export default function EventCreateWizard({ finishRoute = APP_ROUTES.MANAGE_EVEN
|
|||||||
genderRestriction: data.genderRestriction,
|
genderRestriction: data.genderRestriction,
|
||||||
ageRestriction: data.ageRestriction,
|
ageRestriction: data.ageRestriction,
|
||||||
cancellationFeePercent: data.cancellationFeePercent,
|
cancellationFeePercent: data.cancellationFeePercent,
|
||||||
|
cancellationFeePercent12To24Hours: data.cancellationFeePercent12To24Hours,
|
||||||
|
cancellationFeePercentMoreThan24Hours: data.cancellationFeePercentMoreThan24Hours,
|
||||||
settings: {
|
settings: {
|
||||||
isDiscoverable: data.isDiscoverable,
|
isDiscoverable: data.isDiscoverable,
|
||||||
autoCreateGroup: data.autoCreateGroup,
|
autoCreateGroup: data.autoCreateGroup,
|
||||||
|
|||||||
@ -30,6 +30,8 @@ const baseEvent = {
|
|||||||
reservedCapacity: 3,
|
reservedCapacity: 3,
|
||||||
bookedCount: 5,
|
bookedCount: 5,
|
||||||
cancellationFeePercent: 10,
|
cancellationFeePercent: 10,
|
||||||
|
cancellationFeePercent12To24Hours: 10,
|
||||||
|
cancellationFeePercentMoreThan24Hours: 10,
|
||||||
commissionPercent: null,
|
commissionPercent: null,
|
||||||
effectiveCommissionPercent: 7,
|
effectiveCommissionPercent: 7,
|
||||||
status: 'published',
|
status: 'published',
|
||||||
|
|||||||
@ -55,6 +55,8 @@ export function mapEventToWizardCloneData(event: CreatedEvent, media: EventMedia
|
|||||||
genderRestriction: event.genderRestriction ?? 'open',
|
genderRestriction: event.genderRestriction ?? 'open',
|
||||||
ageRestriction: event.ageRestriction ?? 'open',
|
ageRestriction: event.ageRestriction ?? 'open',
|
||||||
cancellationFeePercent: event.cancellationFeePercent,
|
cancellationFeePercent: event.cancellationFeePercent,
|
||||||
|
cancellationFeePercent12To24Hours: event.cancellationFeePercent12To24Hours,
|
||||||
|
cancellationFeePercentMoreThan24Hours: event.cancellationFeePercentMoreThan24Hours,
|
||||||
isDiscoverable: event.settings.isDiscoverable,
|
isDiscoverable: event.settings.isDiscoverable,
|
||||||
autoCreateGroup: event.settings.autoCreateGroup,
|
autoCreateGroup: event.settings.autoCreateGroup,
|
||||||
addressVisibility: event.settings.addressVisibility,
|
addressVisibility: event.settings.addressVisibility,
|
||||||
|
|||||||
@ -32,6 +32,8 @@ export default function Step3PricingCapacity({ data, onBack, onChange, onNext }:
|
|||||||
genderRestriction: data.genderRestriction,
|
genderRestriction: data.genderRestriction,
|
||||||
ageRestriction: data.ageRestriction,
|
ageRestriction: data.ageRestriction,
|
||||||
cancellationFeePercent: data.cancellationFeePercent,
|
cancellationFeePercent: data.cancellationFeePercent,
|
||||||
|
cancellationFeePercent12To24Hours: data.cancellationFeePercent12To24Hours,
|
||||||
|
cancellationFeePercentMoreThan24Hours: data.cancellationFeePercentMoreThan24Hours,
|
||||||
isDiscoverable: data.isDiscoverable,
|
isDiscoverable: data.isDiscoverable,
|
||||||
autoCreateGroup: data.autoCreateGroup,
|
autoCreateGroup: data.autoCreateGroup,
|
||||||
waitlistAutoOffer: data.waitlistAutoOffer,
|
waitlistAutoOffer: data.waitlistAutoOffer,
|
||||||
@ -51,6 +53,8 @@ export default function Step3PricingCapacity({ data, onBack, onChange, onNext }:
|
|||||||
genderRestriction: values.genderRestriction ?? 'open',
|
genderRestriction: values.genderRestriction ?? 'open',
|
||||||
ageRestriction: values.ageRestriction ?? 'open',
|
ageRestriction: values.ageRestriction ?? 'open',
|
||||||
cancellationFeePercent: Number(values.cancellationFeePercent ?? 0),
|
cancellationFeePercent: Number(values.cancellationFeePercent ?? 0),
|
||||||
|
cancellationFeePercent12To24Hours: Number(values.cancellationFeePercent12To24Hours ?? 0),
|
||||||
|
cancellationFeePercentMoreThan24Hours: Number(values.cancellationFeePercentMoreThan24Hours ?? 0),
|
||||||
isDiscoverable: values.isDiscoverable ?? false,
|
isDiscoverable: values.isDiscoverable ?? false,
|
||||||
autoCreateGroup: values.autoCreateGroup ?? true,
|
autoCreateGroup: values.autoCreateGroup ?? true,
|
||||||
waitlistAutoOffer: values.waitlistAutoOffer ?? true,
|
waitlistAutoOffer: values.waitlistAutoOffer ?? true,
|
||||||
@ -72,6 +76,8 @@ export default function Step3PricingCapacity({ data, onBack, onChange, onNext }:
|
|||||||
genderRestriction: values.genderRestriction,
|
genderRestriction: values.genderRestriction,
|
||||||
ageRestriction: values.ageRestriction,
|
ageRestriction: values.ageRestriction,
|
||||||
cancellationFeePercent: values.cancellationFeePercent,
|
cancellationFeePercent: values.cancellationFeePercent,
|
||||||
|
cancellationFeePercent12To24Hours: values.cancellationFeePercent12To24Hours,
|
||||||
|
cancellationFeePercentMoreThan24Hours: values.cancellationFeePercentMoreThan24Hours,
|
||||||
isDiscoverable: values.isDiscoverable,
|
isDiscoverable: values.isDiscoverable,
|
||||||
autoCreateGroup: values.autoCreateGroup,
|
autoCreateGroup: values.autoCreateGroup,
|
||||||
waitlistAutoOffer: values.waitlistAutoOffer,
|
waitlistAutoOffer: values.waitlistAutoOffer,
|
||||||
@ -145,9 +151,11 @@ export default function Step3PricingCapacity({ data, onBack, onChange, onNext }:
|
|||||||
|
|
||||||
<WizardFormFullWidth>
|
<WizardFormFullWidth>
|
||||||
<CancellationPolicyEditor
|
<CancellationPolicyEditor
|
||||||
value={form.watch('cancellationFeePercent')}
|
cancellationFeePercent={form.watch('cancellationFeePercent')}
|
||||||
onChange={(cancellationFeePercent) => {
|
cancellationFeePercent12To24Hours={form.watch('cancellationFeePercent12To24Hours')}
|
||||||
form.setValue('cancellationFeePercent', cancellationFeePercent, { shouldDirty: true, shouldValidate: true })
|
cancellationFeePercentMoreThan24Hours={form.watch('cancellationFeePercentMoreThan24Hours')}
|
||||||
|
onChange={(field, value) => {
|
||||||
|
form.setValue(field, value, { shouldDirty: true, shouldValidate: true })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</WizardFormFullWidth>
|
</WizardFormFullWidth>
|
||||||
|
|||||||
@ -44,6 +44,8 @@ export interface EventWizardFormData {
|
|||||||
genderRestriction: EventGenderRestriction
|
genderRestriction: EventGenderRestriction
|
||||||
ageRestriction: EventAgeRestriction
|
ageRestriction: EventAgeRestriction
|
||||||
cancellationFeePercent: number
|
cancellationFeePercent: number
|
||||||
|
cancellationFeePercent12To24Hours: number
|
||||||
|
cancellationFeePercentMoreThan24Hours: number
|
||||||
isDiscoverable: boolean
|
isDiscoverable: boolean
|
||||||
autoCreateGroup: boolean
|
autoCreateGroup: boolean
|
||||||
addressVisibility: 'public' | 'attendees_only'
|
addressVisibility: 'public' | 'attendees_only'
|
||||||
@ -75,7 +77,9 @@ export const INITIAL_WIZARD_DATA: EventWizardFormData = {
|
|||||||
reservedCapacity: 0,
|
reservedCapacity: 0,
|
||||||
genderRestriction: 'open',
|
genderRestriction: 'open',
|
||||||
ageRestriction: 'open',
|
ageRestriction: 'open',
|
||||||
cancellationFeePercent: 0,
|
cancellationFeePercent: 30,
|
||||||
|
cancellationFeePercent12To24Hours: 20,
|
||||||
|
cancellationFeePercentMoreThan24Hours: 10,
|
||||||
// Defaults to true here in the wizard, unlike the backend's own
|
// Defaults to true here in the wizard, unlike the backend's own
|
||||||
// default of false -- since this is the only place that currently
|
// default of false -- since this is the only place that currently
|
||||||
// sets it, and an organizer/admin creating an event almost always
|
// sets it, and an organizer/admin creating an event almost always
|
||||||
|
|||||||
@ -70,6 +70,8 @@ export const createHostEvent = <T extends Record<string, unknown>>(overrides: T)
|
|||||||
cityId: 2,
|
cityId: 2,
|
||||||
provinceId: 1,
|
provinceId: 1,
|
||||||
cancellationFeePercent: 0,
|
cancellationFeePercent: 0,
|
||||||
|
cancellationFeePercent12To24Hours: 0,
|
||||||
|
cancellationFeePercentMoreThan24Hours: 0,
|
||||||
discoveryPlacements: [],
|
discoveryPlacements: [],
|
||||||
createdAt: '2029-12-01T10:00:00.000Z',
|
createdAt: '2029-12-01T10:00:00.000Z',
|
||||||
publishedAt: null,
|
publishedAt: null,
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { formatNumber, formatPersonName } from '@/helpers'
|
|||||||
import { getPublicMapApiKey } from '@/helpers/mapir'
|
import { getPublicMapApiKey } from '@/helpers/mapir'
|
||||||
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
|
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
|
||||||
import { ageRestrictionLabel, genderRestrictionLabel } from '@/lib/eventAudience'
|
import { ageRestrictionLabel, genderRestrictionLabel } from '@/lib/eventAudience'
|
||||||
|
import { texts } from '@/texts'
|
||||||
|
|
||||||
import { DetailItem } from './AdminEventDetailUi'
|
import { DetailItem } from './AdminEventDetailUi'
|
||||||
|
|
||||||
@ -141,7 +142,11 @@ const AdminEventDetailsSection = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DetailItem>
|
</DetailItem>
|
||||||
<DetailItem label="جریمه لغو">{number(event.cancellationFeePercent)}٪</DetailItem>
|
<DetailItem label={texts.events.cancellationFeeMoreThan24HoursLabel}>
|
||||||
|
{number(event.cancellationFeePercentMoreThan24Hours)}٪
|
||||||
|
</DetailItem>
|
||||||
|
<DetailItem label={texts.events.cancellationFee12To24HoursLabel}>{number(event.cancellationFeePercent12To24Hours)}٪</DetailItem>
|
||||||
|
<DetailItem label={texts.events.cancellationFeeLabel}>{number(event.cancellationFeePercent)}٪</DetailItem>
|
||||||
<DetailItem label="محدودیت جنسیتی">
|
<DetailItem label="محدودیت جنسیتی">
|
||||||
{restrictionStatus(event.genderRestriction, genderRestrictionLabel(event.genderRestriction))}
|
{restrictionStatus(event.genderRestriction, genderRestrictionLabel(event.genderRestriction))}
|
||||||
</DetailItem>
|
</DetailItem>
|
||||||
|
|||||||
@ -152,6 +152,8 @@ const baseEvent = {
|
|||||||
ageRestriction: 'open' as const,
|
ageRestriction: 'open' as const,
|
||||||
bookedCount: 3,
|
bookedCount: 3,
|
||||||
cancellationFeePercent: 10,
|
cancellationFeePercent: 10,
|
||||||
|
cancellationFeePercent12To24Hours: 10,
|
||||||
|
cancellationFeePercentMoreThan24Hours: 10,
|
||||||
settings: {
|
settings: {
|
||||||
isDiscoverable: true,
|
isDiscoverable: true,
|
||||||
autoCreateGroup: true,
|
autoCreateGroup: true,
|
||||||
@ -215,7 +217,9 @@ describe('EventEditForm', () => {
|
|||||||
expect(screen.getByLabelText('تاریخ شروع')).toBeDisabled()
|
expect(screen.getByLabelText('تاریخ شروع')).toBeDisabled()
|
||||||
expect(screen.getByLabelText('آدرس')).toBeDisabled()
|
expect(screen.getByLabelText('آدرس')).toBeDisabled()
|
||||||
expect(screen.getByLabelText('قیمت (تومان)')).toBeDisabled()
|
expect(screen.getByLabelText('قیمت (تومان)')).toBeDisabled()
|
||||||
expect(screen.getByLabelText('درصد جریمه لغو (۰–۱۰۰)')).toBeDisabled()
|
expect(screen.getByLabelText('لغو زودهنگام — بیش از ۲۴ ساعت مانده')).toBeDisabled()
|
||||||
|
expect(screen.getByLabelText('لغو در آستانه برگزاری — ۱۲ تا ۲۴ ساعت مانده')).toBeDisabled()
|
||||||
|
expect(screen.getByLabelText('لغو لحظه آخری — ۱ دقیقه تا ۱۲ ساعت مانده')).toBeDisabled()
|
||||||
expect(screen.getByLabelText('محدودیت جنسی')).toBeDisabled()
|
expect(screen.getByLabelText('محدودیت جنسی')).toBeDisabled()
|
||||||
expect(screen.getByLabelText('محدودیت سنی')).toBeDisabled()
|
expect(screen.getByLabelText('محدودیت سنی')).toBeDisabled()
|
||||||
expect(screen.getByLabelText('عنوان')).not.toBeDisabled()
|
expect(screen.getByLabelText('عنوان')).not.toBeDisabled()
|
||||||
@ -268,6 +272,8 @@ describe('EventEditForm', () => {
|
|||||||
expect(payload).not.toHaveProperty('price')
|
expect(payload).not.toHaveProperty('price')
|
||||||
expect(payload).not.toHaveProperty('isFree')
|
expect(payload).not.toHaveProperty('isFree')
|
||||||
expect(payload).not.toHaveProperty('cancellationFeePercent')
|
expect(payload).not.toHaveProperty('cancellationFeePercent')
|
||||||
|
expect(payload).not.toHaveProperty('cancellationFeePercent12To24Hours')
|
||||||
|
expect(payload).not.toHaveProperty('cancellationFeePercentMoreThan24Hours')
|
||||||
expect(payload).not.toHaveProperty('genderRestriction')
|
expect(payload).not.toHaveProperty('genderRestriction')
|
||||||
expect(payload).not.toHaveProperty('ageRestriction')
|
expect(payload).not.toHaveProperty('ageRestriction')
|
||||||
expect(updateEventAsAdmin).not.toHaveBeenCalled()
|
expect(updateEventAsAdmin).not.toHaveBeenCalled()
|
||||||
|
|||||||
@ -63,6 +63,8 @@ interface EventEditFormValues {
|
|||||||
genderRestriction: 'open' | 'female_only' | 'male_only'
|
genderRestriction: 'open' | 'female_only' | 'male_only'
|
||||||
ageRestriction: 'open' | 'age_15_19' | 'age_20_24' | 'age_25_plus'
|
ageRestriction: 'open' | 'age_15_19' | 'age_20_24' | 'age_25_plus'
|
||||||
cancellationFeePercent: number
|
cancellationFeePercent: number
|
||||||
|
cancellationFeePercent12To24Hours: number
|
||||||
|
cancellationFeePercentMoreThan24Hours: number
|
||||||
isDiscoverable: boolean
|
isDiscoverable: boolean
|
||||||
autoCreateGroup: boolean
|
autoCreateGroup: boolean
|
||||||
addressVisibility: 'public' | 'attendees_only'
|
addressVisibility: 'public' | 'attendees_only'
|
||||||
@ -122,6 +124,8 @@ function eventToFormValues(event: CreatedEvent): EventEditFormValues {
|
|||||||
genderRestriction: event.genderRestriction ?? 'open',
|
genderRestriction: event.genderRestriction ?? 'open',
|
||||||
ageRestriction: event.ageRestriction ?? 'open',
|
ageRestriction: event.ageRestriction ?? 'open',
|
||||||
cancellationFeePercent: event.cancellationFeePercent,
|
cancellationFeePercent: event.cancellationFeePercent,
|
||||||
|
cancellationFeePercent12To24Hours: event.cancellationFeePercent12To24Hours,
|
||||||
|
cancellationFeePercentMoreThan24Hours: event.cancellationFeePercentMoreThan24Hours,
|
||||||
isDiscoverable: event.settings.isDiscoverable,
|
isDiscoverable: event.settings.isDiscoverable,
|
||||||
autoCreateGroup: event.settings.autoCreateGroup,
|
autoCreateGroup: event.settings.autoCreateGroup,
|
||||||
addressVisibility: event.settings.addressVisibility,
|
addressVisibility: event.settings.addressVisibility,
|
||||||
@ -268,6 +272,15 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
values.cancellationFeePercent < values.cancellationFeePercent12To24Hours ||
|
||||||
|
values.cancellationFeePercent12To24Hours < values.cancellationFeePercentMoreThan24Hours
|
||||||
|
) {
|
||||||
|
addToast({ title: texts.validation.eventWizard.cancelPenaltyOrder, color: 'danger' })
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const price = values.isFree ? 0 : Number(values.price)
|
const price = values.isFree ? 0 : Number(values.price)
|
||||||
const payload: Partial<CreateEventPayload> = {
|
const payload: Partial<CreateEventPayload> = {
|
||||||
title: values.title.trim(),
|
title: values.title.trim(),
|
||||||
@ -315,6 +328,8 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
|||||||
|
|
||||||
if (!cancellationFeeLocked) {
|
if (!cancellationFeeLocked) {
|
||||||
payload.cancellationFeePercent = Number(values.cancellationFeePercent ?? 0)
|
payload.cancellationFeePercent = Number(values.cancellationFeePercent ?? 0)
|
||||||
|
payload.cancellationFeePercent12To24Hours = Number(values.cancellationFeePercent12To24Hours ?? 0)
|
||||||
|
payload.cancellationFeePercentMoreThan24Hours = Number(values.cancellationFeePercentMoreThan24Hours ?? 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!audienceLocked) {
|
if (!audienceLocked) {
|
||||||
@ -599,6 +614,22 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
|||||||
selectKey="selectKey"
|
selectKey="selectKey"
|
||||||
/>
|
/>
|
||||||
</WizardFormFullWidth>
|
</WizardFormFullWidth>
|
||||||
|
<Input
|
||||||
|
disabled={cancellationFeeLocked}
|
||||||
|
generalType="numberInput"
|
||||||
|
label={texts.events.cancellationFeeMoreThan24HoursLabel}
|
||||||
|
maxValue={100}
|
||||||
|
minValue={0}
|
||||||
|
name="cancellationFeePercentMoreThan24Hours"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
disabled={cancellationFeeLocked}
|
||||||
|
generalType="numberInput"
|
||||||
|
label={texts.events.cancellationFee12To24HoursLabel}
|
||||||
|
maxValue={100}
|
||||||
|
minValue={0}
|
||||||
|
name="cancellationFeePercent12To24Hours"
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
disabled={cancellationFeeLocked}
|
disabled={cancellationFeeLocked}
|
||||||
generalType="numberInput"
|
generalType="numberInput"
|
||||||
|
|||||||
@ -14,6 +14,8 @@ export const EVENT_SENSITIVE_FIELDS = [
|
|||||||
'isFree',
|
'isFree',
|
||||||
'price',
|
'price',
|
||||||
'cancellationFeePercent',
|
'cancellationFeePercent',
|
||||||
|
'cancellationFeePercent12To24Hours',
|
||||||
|
'cancellationFeePercentMoreThan24Hours',
|
||||||
'genderRestriction',
|
'genderRestriction',
|
||||||
'ageRestriction',
|
'ageRestriction',
|
||||||
] as const
|
] as const
|
||||||
|
|||||||
@ -3,38 +3,50 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import { calculateCancellationPreview, resolveCancellationTicketPrice } from '@/services/bookings'
|
import { calculateCancellationPreview, resolveCancellationTicketPrice } from '@/services/bookings'
|
||||||
|
|
||||||
describe('booking cancellation preview', () => {
|
describe('booking cancellation preview', () => {
|
||||||
|
const startsAt = '2026-09-08T12:00:00.000Z'
|
||||||
|
const policy = {
|
||||||
|
cancellationFeePercent: 10,
|
||||||
|
cancellationFeePercent12To24Hours: 10,
|
||||||
|
cancellationFeePercentMoreThan24Hours: 10,
|
||||||
|
}
|
||||||
|
|
||||||
it('matches the backend floor-based commission formula', () => {
|
it('matches the backend floor-based commission formula', () => {
|
||||||
// 7% mirrors the platform default (backend/src/common/constants/business.constants.ts#COMMISSION_PERCENT);
|
// 7% mirrors the platform default (backend/src/common/constants/business.constants.ts#COMMISSION_PERCENT);
|
||||||
// commissionPercent is always passed in from the booking's resolved rate, never hardcoded here.
|
// commissionPercent is always passed in from the booking's resolved rate, never hardcoded here.
|
||||||
expect(calculateCancellationPreview(101_000, 10, 7)).toEqual({
|
expect(calculateCancellationPreview(101_000, policy, 7, startsAt, new Date('2026-09-08T11:00:00Z'))).toEqual({
|
||||||
ticketPrice: 101_000,
|
ticketPrice: 101_000,
|
||||||
platformCommission: 7_070,
|
cancellationFeePercent: 10,
|
||||||
|
platformCommission: 707,
|
||||||
commissionPercent: 7,
|
commissionPercent: 7,
|
||||||
hostCancellationFee: 10_100,
|
hostCancellationFee: 9_393,
|
||||||
refundToGuest: 83_830,
|
refundToGuest: 90_900,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not produce fractional Toman values', () => {
|
it('does not produce fractional Toman values', () => {
|
||||||
// A different rate here (5%) than the test above (7%) is deliberate — it
|
// A different rate here (5%) than the test above (7%) is deliberate — it
|
||||||
// exercises an event-level commission override, not just the platform default.
|
// exercises an event-level commission override, not just the platform default.
|
||||||
expect(calculateCancellationPreview(999, 7, 5)).toEqual({
|
expect(
|
||||||
|
calculateCancellationPreview(999, { ...policy, cancellationFeePercent: 7 }, 5, startsAt, new Date('2026-09-08T11:00:00Z'))
|
||||||
|
).toEqual({
|
||||||
ticketPrice: 999,
|
ticketPrice: 999,
|
||||||
platformCommission: 49,
|
cancellationFeePercent: 7,
|
||||||
|
platformCommission: 3,
|
||||||
commissionPercent: 5,
|
commissionPercent: 5,
|
||||||
hostCancellationFee: 69,
|
hostCancellationFee: 66,
|
||||||
refundToGuest: 881,
|
refundToGuest: 930,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses post-discount paid amount so fees match refund.service gross', () => {
|
it('uses post-discount paid amount so fees match refund.service gross', () => {
|
||||||
// List 10_000, discount 2_000 → paid 8_000; 7% + 10% → refund 6_640
|
// List 10_000, discount 2_000 → paid 8_000; 10% penalty → refund 7_200.
|
||||||
expect(calculateCancellationPreview(8_000, 10, 7)).toEqual({
|
expect(calculateCancellationPreview(8_000, policy, 7, startsAt, new Date('2026-09-08T11:00:00Z'))).toEqual({
|
||||||
ticketPrice: 8_000,
|
ticketPrice: 8_000,
|
||||||
platformCommission: 560,
|
cancellationFeePercent: 10,
|
||||||
|
platformCommission: 56,
|
||||||
commissionPercent: 7,
|
commissionPercent: 7,
|
||||||
hostCancellationFee: 800,
|
hostCancellationFee: 744,
|
||||||
refundToGuest: 6_640,
|
refundToGuest: 7_200,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -299,7 +299,9 @@ export const CANCEL_BOOKING = async (
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Client-side guest-cancel refund preview — booking-cancellation-refund.md
|
* Client-side guest-cancel refund preview — booking-cancellation-refund.md
|
||||||
* formula: refund = paidGross × (1 - platform commission % - cancellationFeePercent).
|
* formula: refund = paidGross - selected cancellation fee. The platform
|
||||||
|
* commission is taken only from that fee and the remainder belongs to the
|
||||||
|
* host after the event completes.
|
||||||
* Mirrors refund.service.ts#refundGuestCancellation exactly (same
|
* Mirrors refund.service.ts#refundGuestCancellation exactly (same
|
||||||
* floor() rounding on each component) so this preview never shows a
|
* floor() rounding on each component) so this preview never shows a
|
||||||
* different number than what's actually credited.
|
* different number than what's actually credited.
|
||||||
@ -324,20 +326,46 @@ export interface CancellationPreview {
|
|||||||
platformCommission: number
|
platformCommission: number
|
||||||
/** The rate actually applied, echoed back so callers can label the commission line without re-touching the booking. */
|
/** The rate actually applied, echoed back so callers can label the commission line without re-touching the booking. */
|
||||||
commissionPercent: number
|
commissionPercent: number
|
||||||
|
cancellationFeePercent: number
|
||||||
hostCancellationFee: number
|
hostCancellationFee: number
|
||||||
refundToGuest: number
|
refundToGuest: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CancellationFeePolicy {
|
||||||
|
cancellationFeePercent: number
|
||||||
|
cancellationFeePercent12To24Hours: number
|
||||||
|
cancellationFeePercentMoreThan24Hours: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOUR_MS = 60 * 60 * 1000
|
||||||
|
|
||||||
|
export const resolveCancellationFeePercent = (
|
||||||
|
policy: CancellationFeePolicy,
|
||||||
|
eventStartsAt: string | Date,
|
||||||
|
cancelledAt: Date = new Date()
|
||||||
|
): number => {
|
||||||
|
const remainingMs = new Date(eventStartsAt).getTime() - cancelledAt.getTime()
|
||||||
|
|
||||||
|
if (remainingMs <= 12 * HOUR_MS) return policy.cancellationFeePercent
|
||||||
|
if (remainingMs <= 24 * HOUR_MS) return policy.cancellationFeePercent12To24Hours
|
||||||
|
|
||||||
|
return policy.cancellationFeePercentMoreThan24Hours
|
||||||
|
}
|
||||||
|
|
||||||
export const calculateCancellationPreview = (
|
export const calculateCancellationPreview = (
|
||||||
ticketPrice: number,
|
ticketPrice: number,
|
||||||
cancellationFeePercent: number,
|
policy: CancellationFeePolicy,
|
||||||
commissionPercent: number
|
commissionPercent: number,
|
||||||
|
eventStartsAt: string | Date,
|
||||||
|
cancelledAt: Date = new Date()
|
||||||
): CancellationPreview => {
|
): CancellationPreview => {
|
||||||
const platformCommission = Math.floor(ticketPrice * (commissionPercent / 100))
|
const cancellationFeePercent = resolveCancellationFeePercent(policy, eventStartsAt, cancelledAt)
|
||||||
const hostCancellationFee = Math.floor(ticketPrice * (cancellationFeePercent / 100))
|
const grossCancellationFee = Math.floor(ticketPrice * (cancellationFeePercent / 100))
|
||||||
const refundToGuest = ticketPrice - platformCommission - hostCancellationFee
|
const platformCommission = Math.floor(grossCancellationFee * (commissionPercent / 100))
|
||||||
|
const hostCancellationFee = grossCancellationFee - platformCommission
|
||||||
|
const refundToGuest = ticketPrice - grossCancellationFee
|
||||||
|
|
||||||
return { ticketPrice, platformCommission, commissionPercent, hostCancellationFee, refundToGuest }
|
return { ticketPrice, platformCommission, commissionPercent, cancellationFeePercent, hostCancellationFee, refundToGuest }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gross base for guest-cancel preview — paid amount after discount, else list price. */
|
/** Gross base for guest-cancel preview — paid amount after discount, else list price. */
|
||||||
|
|||||||
@ -52,6 +52,8 @@ export interface DiscoveryEvent {
|
|||||||
bookedCount: number
|
bookedCount: number
|
||||||
reservedCapacity: number
|
reservedCapacity: number
|
||||||
cancellationFeePercent: number
|
cancellationFeePercent: number
|
||||||
|
cancellationFeePercent12To24Hours: number
|
||||||
|
cancellationFeePercentMoreThan24Hours: number
|
||||||
commissionPercent: number | null
|
commissionPercent: number | null
|
||||||
effectiveCommissionPercent: number
|
effectiveCommissionPercent: number
|
||||||
status: EventStatus
|
status: EventStatus
|
||||||
|
|||||||
@ -243,6 +243,8 @@ export interface EventRevisionPayload {
|
|||||||
genderRestriction?: string
|
genderRestriction?: string
|
||||||
ageRestriction?: string
|
ageRestriction?: string
|
||||||
cancellationFeePercent?: number
|
cancellationFeePercent?: number
|
||||||
|
cancellationFeePercent12To24Hours?: number
|
||||||
|
cancellationFeePercentMoreThan24Hours?: number
|
||||||
settings?: CreateEventPayload['settings']
|
settings?: CreateEventPayload['settings']
|
||||||
media: CreateEventMediaPayload[]
|
media: CreateEventMediaPayload[]
|
||||||
faqs?: CreateEventFaqPayload[]
|
faqs?: CreateEventFaqPayload[]
|
||||||
|
|||||||
@ -276,7 +276,9 @@ export const events = {
|
|||||||
peopleRestrictionLabel: 'محدودیتافراد',
|
peopleRestrictionLabel: 'محدودیتافراد',
|
||||||
ageRestrictionLabel: 'محدودیت سنی',
|
ageRestrictionLabel: 'محدودیت سنی',
|
||||||
cancellationSectionDescription: 'شرایط لغو را پیش از رزرو به مهمان نشان میدهیم تا تصمیم شفافی بگیرد.',
|
cancellationSectionDescription: 'شرایط لغو را پیش از رزرو به مهمان نشان میدهیم تا تصمیم شفافی بگیرد.',
|
||||||
cancellationFeeLabel: 'درصد جریمه لغو (۰–۱۰۰)',
|
cancellationFeeLabel: 'لغو لحظه آخری — ۱ دقیقه تا ۱۲ ساعت مانده',
|
||||||
|
cancellationFee12To24HoursLabel: 'لغو در آستانه برگزاری — ۱۲ تا ۲۴ ساعت مانده',
|
||||||
|
cancellationFeeMoreThan24HoursLabel: 'لغو زودهنگام — بیش از ۲۴ ساعت مانده',
|
||||||
advancedSettingsHint: 'پیشنهاد میکنیم مقادیر پیشفرض را نگه داری',
|
advancedSettingsHint: 'پیشنهاد میکنیم مقادیر پیشفرض را نگه داری',
|
||||||
discoverableLabel: 'نمایش در جستجوی عمومی',
|
discoverableLabel: 'نمایش در جستجوی عمومی',
|
||||||
autoCreateGroupLabel: 'ساخت خودکار گروه چت پس از رویداد',
|
autoCreateGroupLabel: 'ساخت خودکار گروه چت پس از رویداد',
|
||||||
|
|||||||
@ -65,6 +65,7 @@ export const validation = {
|
|||||||
cancelPenaltyRequired: 'درصد جریمه را وارد کنید',
|
cancelPenaltyRequired: 'درصد جریمه را وارد کنید',
|
||||||
cancelPenaltyNonNegative: 'درصد جریمه نمیتواند منفی باشد',
|
cancelPenaltyNonNegative: 'درصد جریمه نمیتواند منفی باشد',
|
||||||
cancelPenaltyMax: 'درصد جریمه حداکثر ۱۰۰ است',
|
cancelPenaltyMax: 'درصد جریمه حداکثر ۱۰۰ است',
|
||||||
|
cancelPenaltyOrder: 'درصد جریمه باید با نزدیکشدن به زمان رویداد بیشتر یا مساوی شود',
|
||||||
paidPricePositive: 'برای رویداد پولی، قیمت باید بیشتر از صفر باشد',
|
paidPricePositive: 'برای رویداد پولی، قیمت باید بیشتر از صفر باشد',
|
||||||
reservedExceedsCapacity: 'ظرفیت رزرو شده نمیتواند از ظرفیت کل بیشتر باشد',
|
reservedExceedsCapacity: 'ظرفیت رزرو شده نمیتواند از ظرفیت کل بیشتر باشد',
|
||||||
faqQuestionRequired: 'سوال را وارد کنید',
|
faqQuestionRequired: 'سوال را وارد کنید',
|
||||||
|
|||||||
@ -119,6 +119,8 @@ describe('event wizard validation', () => {
|
|||||||
genderRestriction: 'open',
|
genderRestriction: 'open',
|
||||||
ageRestriction: 'open',
|
ageRestriction: 'open',
|
||||||
cancellationFeePercent: 0,
|
cancellationFeePercent: 0,
|
||||||
|
cancellationFeePercent12To24Hours: 0,
|
||||||
|
cancellationFeePercentMoreThan24Hours: 0,
|
||||||
isDiscoverable: true,
|
isDiscoverable: true,
|
||||||
autoCreateGroup: true,
|
autoCreateGroup: true,
|
||||||
waitlistAutoOffer: true,
|
waitlistAutoOffer: true,
|
||||||
@ -135,6 +137,8 @@ describe('event wizard validation', () => {
|
|||||||
genderRestriction: 'open',
|
genderRestriction: 'open',
|
||||||
ageRestriction: 'open',
|
ageRestriction: 'open',
|
||||||
cancellationFeePercent: 0,
|
cancellationFeePercent: 0,
|
||||||
|
cancellationFeePercent12To24Hours: 0,
|
||||||
|
cancellationFeePercentMoreThan24Hours: 0,
|
||||||
isDiscoverable: true,
|
isDiscoverable: true,
|
||||||
autoCreateGroup: true,
|
autoCreateGroup: true,
|
||||||
waitlistAutoOffer: true,
|
waitlistAutoOffer: true,
|
||||||
@ -151,6 +155,8 @@ describe('event wizard validation', () => {
|
|||||||
genderRestriction: 'open',
|
genderRestriction: 'open',
|
||||||
ageRestriction: 'open',
|
ageRestriction: 'open',
|
||||||
cancellationFeePercent: 0,
|
cancellationFeePercent: 0,
|
||||||
|
cancellationFeePercent12To24Hours: 0,
|
||||||
|
cancellationFeePercentMoreThan24Hours: 0,
|
||||||
isDiscoverable: true,
|
isDiscoverable: true,
|
||||||
autoCreateGroup: true,
|
autoCreateGroup: true,
|
||||||
waitlistAutoOffer: true,
|
waitlistAutoOffer: true,
|
||||||
@ -168,6 +174,8 @@ describe('event wizard validation', () => {
|
|||||||
genderRestriction: 'open',
|
genderRestriction: 'open',
|
||||||
ageRestriction: 'open',
|
ageRestriction: 'open',
|
||||||
cancellationFeePercent: 0,
|
cancellationFeePercent: 0,
|
||||||
|
cancellationFeePercent12To24Hours: 0,
|
||||||
|
cancellationFeePercentMoreThan24Hours: 0,
|
||||||
isDiscoverable: true,
|
isDiscoverable: true,
|
||||||
autoCreateGroup: true,
|
autoCreateGroup: true,
|
||||||
waitlistAutoOffer: true,
|
waitlistAutoOffer: true,
|
||||||
@ -179,6 +187,40 @@ describe('event wizard validation', () => {
|
|||||||
expect(EventWizardStep3Validation.safeParse({ ...base, ageRestriction: 'age_15_19' }).success).toBe(true)
|
expect(EventWizardStep3Validation.safeParse({ ...base, ageRestriction: 'age_15_19' }).success).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('requires cancellation penalties to decrease as cancellation gets earlier', () => {
|
||||||
|
const base = {
|
||||||
|
isFree: false,
|
||||||
|
price: 100_000,
|
||||||
|
capacity: 20,
|
||||||
|
reservedCapacity: 0,
|
||||||
|
genderRestriction: 'open',
|
||||||
|
ageRestriction: 'open',
|
||||||
|
cancellationFeePercent: 30,
|
||||||
|
cancellationFeePercent12To24Hours: 20,
|
||||||
|
cancellationFeePercentMoreThan24Hours: 10,
|
||||||
|
isDiscoverable: true,
|
||||||
|
autoCreateGroup: true,
|
||||||
|
waitlistAutoOffer: true,
|
||||||
|
sendReviewRequestSms: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(EventWizardStep3Validation.safeParse(base).success).toBe(true)
|
||||||
|
expect(
|
||||||
|
EventWizardStep3Validation.safeParse({
|
||||||
|
...base,
|
||||||
|
cancellationFeePercentMoreThan24Hours: 25,
|
||||||
|
}).success
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
EventWizardStep3Validation.safeParse({
|
||||||
|
...base,
|
||||||
|
cancellationFeePercent: 0,
|
||||||
|
cancellationFeePercent12To24Hours: 0,
|
||||||
|
cancellationFeePercentMoreThan24Hours: 0,
|
||||||
|
}).success
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('requires both question and answer for FAQ rows', () => {
|
it('requires both question and answer for FAQ rows', () => {
|
||||||
expect(EventWizardFaqFormValidation.safeParse({ question: '', answer: 'پاسخ' }).success).toBe(false)
|
expect(EventWizardFaqFormValidation.safeParse({ question: '', answer: 'پاسخ' }).success).toBe(false)
|
||||||
expect(EventWizardFaqFormValidation.parse({ question: 'سوال؟', answer: 'پاسخ' })).toEqual({
|
expect(EventWizardFaqFormValidation.parse({ question: 'سوال؟', answer: 'پاسخ' })).toEqual({
|
||||||
|
|||||||
@ -108,6 +108,14 @@ export const EventWizardStep3Validation = z
|
|||||||
genderRestriction: z.enum(['open', 'female_only', 'male_only']),
|
genderRestriction: z.enum(['open', 'female_only', 'male_only']),
|
||||||
ageRestriction: z.enum(['open', 'age_15_19', 'age_20_24', 'age_25_plus']),
|
ageRestriction: z.enum(['open', 'age_15_19', 'age_20_24', 'age_25_plus']),
|
||||||
cancellationFeePercent: z.number({ error: v.cancelPenaltyRequired }).min(0, v.cancelPenaltyNonNegative).max(100, v.cancelPenaltyMax),
|
cancellationFeePercent: z.number({ error: v.cancelPenaltyRequired }).min(0, v.cancelPenaltyNonNegative).max(100, v.cancelPenaltyMax),
|
||||||
|
cancellationFeePercent12To24Hours: z
|
||||||
|
.number({ error: v.cancelPenaltyRequired })
|
||||||
|
.min(0, v.cancelPenaltyNonNegative)
|
||||||
|
.max(100, v.cancelPenaltyMax),
|
||||||
|
cancellationFeePercentMoreThan24Hours: z
|
||||||
|
.number({ error: v.cancelPenaltyRequired })
|
||||||
|
.min(0, v.cancelPenaltyNonNegative)
|
||||||
|
.max(100, v.cancelPenaltyMax),
|
||||||
isDiscoverable: z.boolean(),
|
isDiscoverable: z.boolean(),
|
||||||
autoCreateGroup: z.boolean(),
|
autoCreateGroup: z.boolean(),
|
||||||
waitlistAutoOffer: z.boolean(),
|
waitlistAutoOffer: z.boolean(),
|
||||||
@ -128,6 +136,16 @@ export const EventWizardStep3Validation = z
|
|||||||
path: ['reservedCapacity'],
|
path: ['reservedCapacity'],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
values.cancellationFeePercent < values.cancellationFeePercent12To24Hours ||
|
||||||
|
values.cancellationFeePercent12To24Hours < values.cancellationFeePercentMoreThan24Hours
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: v.cancelPenaltyOrder,
|
||||||
|
path: ['cancellationFeePercent12To24Hours'],
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export type EventWizardStep3Values = z.infer<typeof EventWizardStep3Validation>
|
export type EventWizardStep3Values = z.infer<typeof EventWizardStep3Validation>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user