feat(events): manage tiered cancellation policies

This commit is contained in:
alisaza 2026-09-07 14:37:58 +03:30
parent 0421b4f6ee
commit 98e6482c0c
19 changed files with 262 additions and 58 deletions

View File

@ -5,44 +5,77 @@ import { texts } from '@/texts'
interface CancellationPolicyEditorProps {
disabled?: boolean
value: number
onChange: (value: number) => void
cancellationFeePercent: number
cancellationFeePercent12To24Hours: number
cancellationFeePercentMoreThan24Hours: number
onChange: (
field: 'cancellationFeePercent' | 'cancellationFeePercent12To24Hours' | 'cancellationFeePercentMoreThan24Hours',
value: number
) => void
}
/**
* Makes the cancellation fee explicit to hosts while keeping the persisted
* event contract to the existing `cancellationFeePercent` field.
* Makes all three time-based cancellation fee tiers explicit to hosts.
*/
export default function CancellationPolicyEditor({ disabled = false, value, onChange }: CancellationPolicyEditorProps) {
const normalizedValue = Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 0
export default function CancellationPolicyEditor({
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 (
<div className="rounded-consumer-control bg-[#E2E2E280] p-3">
<div className="flex items-start justify-between gap-3">
<div>
<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)
<div className="flex flex-col gap-3">
{tiers.map((tier) => {
const normalizedValue = Number.isFinite(tier.value) ? Math.min(100, Math.max(0, tier.value)) : 0
onChange(Number.isFinite(next) ? Math.min(100, Math.max(0, next)) : 0)
}}
/>
{/* This marker belongs to the compact numeric field, not to its value. */}
<span className="pointer-events-none absolute inset-y-0 start-1 flex items-center text-xs text-secondary-20">٪</span>
</div>
</div>
return (
<div
key={tier.field}
className="rounded-consumer-control bg-[#E2E2E280] p-3"
>
<div className="flex items-center justify-between gap-3">
<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>
)
}

View File

@ -239,6 +239,8 @@ export default function EventCreateWizard({ finishRoute = APP_ROUTES.MANAGE_EVEN
genderRestriction: data.genderRestriction,
ageRestriction: data.ageRestriction,
cancellationFeePercent: data.cancellationFeePercent,
cancellationFeePercent12To24Hours: data.cancellationFeePercent12To24Hours,
cancellationFeePercentMoreThan24Hours: data.cancellationFeePercentMoreThan24Hours,
settings: {
isDiscoverable: data.isDiscoverable,
autoCreateGroup: data.autoCreateGroup,

View File

@ -30,6 +30,8 @@ const baseEvent = {
reservedCapacity: 3,
bookedCount: 5,
cancellationFeePercent: 10,
cancellationFeePercent12To24Hours: 10,
cancellationFeePercentMoreThan24Hours: 10,
commissionPercent: null,
effectiveCommissionPercent: 7,
status: 'published',

View File

@ -55,6 +55,8 @@ export function mapEventToWizardCloneData(event: CreatedEvent, media: EventMedia
genderRestriction: event.genderRestriction ?? 'open',
ageRestriction: event.ageRestriction ?? 'open',
cancellationFeePercent: event.cancellationFeePercent,
cancellationFeePercent12To24Hours: event.cancellationFeePercent12To24Hours,
cancellationFeePercentMoreThan24Hours: event.cancellationFeePercentMoreThan24Hours,
isDiscoverable: event.settings.isDiscoverable,
autoCreateGroup: event.settings.autoCreateGroup,
addressVisibility: event.settings.addressVisibility,

View File

@ -32,6 +32,8 @@ export default function Step3PricingCapacity({ data, onBack, onChange, onNext }:
genderRestriction: data.genderRestriction,
ageRestriction: data.ageRestriction,
cancellationFeePercent: data.cancellationFeePercent,
cancellationFeePercent12To24Hours: data.cancellationFeePercent12To24Hours,
cancellationFeePercentMoreThan24Hours: data.cancellationFeePercentMoreThan24Hours,
isDiscoverable: data.isDiscoverable,
autoCreateGroup: data.autoCreateGroup,
waitlistAutoOffer: data.waitlistAutoOffer,
@ -51,6 +53,8 @@ export default function Step3PricingCapacity({ data, onBack, onChange, onNext }:
genderRestriction: values.genderRestriction ?? 'open',
ageRestriction: values.ageRestriction ?? 'open',
cancellationFeePercent: Number(values.cancellationFeePercent ?? 0),
cancellationFeePercent12To24Hours: Number(values.cancellationFeePercent12To24Hours ?? 0),
cancellationFeePercentMoreThan24Hours: Number(values.cancellationFeePercentMoreThan24Hours ?? 0),
isDiscoverable: values.isDiscoverable ?? false,
autoCreateGroup: values.autoCreateGroup ?? true,
waitlistAutoOffer: values.waitlistAutoOffer ?? true,
@ -72,6 +76,8 @@ export default function Step3PricingCapacity({ data, onBack, onChange, onNext }:
genderRestriction: values.genderRestriction,
ageRestriction: values.ageRestriction,
cancellationFeePercent: values.cancellationFeePercent,
cancellationFeePercent12To24Hours: values.cancellationFeePercent12To24Hours,
cancellationFeePercentMoreThan24Hours: values.cancellationFeePercentMoreThan24Hours,
isDiscoverable: values.isDiscoverable,
autoCreateGroup: values.autoCreateGroup,
waitlistAutoOffer: values.waitlistAutoOffer,
@ -145,9 +151,11 @@ export default function Step3PricingCapacity({ data, onBack, onChange, onNext }:
<WizardFormFullWidth>
<CancellationPolicyEditor
value={form.watch('cancellationFeePercent')}
onChange={(cancellationFeePercent) => {
form.setValue('cancellationFeePercent', cancellationFeePercent, { shouldDirty: true, shouldValidate: true })
cancellationFeePercent={form.watch('cancellationFeePercent')}
cancellationFeePercent12To24Hours={form.watch('cancellationFeePercent12To24Hours')}
cancellationFeePercentMoreThan24Hours={form.watch('cancellationFeePercentMoreThan24Hours')}
onChange={(field, value) => {
form.setValue(field, value, { shouldDirty: true, shouldValidate: true })
}}
/>
</WizardFormFullWidth>

View File

@ -44,6 +44,8 @@ export interface EventWizardFormData {
genderRestriction: EventGenderRestriction
ageRestriction: EventAgeRestriction
cancellationFeePercent: number
cancellationFeePercent12To24Hours: number
cancellationFeePercentMoreThan24Hours: number
isDiscoverable: boolean
autoCreateGroup: boolean
addressVisibility: 'public' | 'attendees_only'
@ -75,7 +77,9 @@ export const INITIAL_WIZARD_DATA: EventWizardFormData = {
reservedCapacity: 0,
genderRestriction: 'open',
ageRestriction: 'open',
cancellationFeePercent: 0,
cancellationFeePercent: 30,
cancellationFeePercent12To24Hours: 20,
cancellationFeePercentMoreThan24Hours: 10,
// Defaults to true here in the wizard, unlike the backend's own
// default of false -- since this is the only place that currently
// sets it, and an organizer/admin creating an event almost always

View File

@ -70,6 +70,8 @@ export const createHostEvent = <T extends Record<string, unknown>>(overrides: T)
cityId: 2,
provinceId: 1,
cancellationFeePercent: 0,
cancellationFeePercent12To24Hours: 0,
cancellationFeePercentMoreThan24Hours: 0,
discoveryPlacements: [],
createdAt: '2029-12-01T10:00:00.000Z',
publishedAt: null,

View File

@ -18,6 +18,7 @@ import { formatNumber, formatPersonName } from '@/helpers'
import { getPublicMapApiKey } from '@/helpers/mapir'
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
import { ageRestrictionLabel, genderRestrictionLabel } from '@/lib/eventAudience'
import { texts } from '@/texts'
import { DetailItem } from './AdminEventDetailUi'
@ -141,7 +142,11 @@ const AdminEventDetailsSection = ({
</Button>
</div>
</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="محدودیت جنسیتی">
{restrictionStatus(event.genderRestriction, genderRestrictionLabel(event.genderRestriction))}
</DetailItem>

View File

@ -152,6 +152,8 @@ const baseEvent = {
ageRestriction: 'open' as const,
bookedCount: 3,
cancellationFeePercent: 10,
cancellationFeePercent12To24Hours: 10,
cancellationFeePercentMoreThan24Hours: 10,
settings: {
isDiscoverable: 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('عنوان')).not.toBeDisabled()
@ -268,6 +272,8 @@ describe('EventEditForm', () => {
expect(payload).not.toHaveProperty('price')
expect(payload).not.toHaveProperty('isFree')
expect(payload).not.toHaveProperty('cancellationFeePercent')
expect(payload).not.toHaveProperty('cancellationFeePercent12To24Hours')
expect(payload).not.toHaveProperty('cancellationFeePercentMoreThan24Hours')
expect(payload).not.toHaveProperty('genderRestriction')
expect(payload).not.toHaveProperty('ageRestriction')
expect(updateEventAsAdmin).not.toHaveBeenCalled()

View File

@ -63,6 +63,8 @@ interface EventEditFormValues {
genderRestriction: 'open' | 'female_only' | 'male_only'
ageRestriction: 'open' | 'age_15_19' | 'age_20_24' | 'age_25_plus'
cancellationFeePercent: number
cancellationFeePercent12To24Hours: number
cancellationFeePercentMoreThan24Hours: number
isDiscoverable: boolean
autoCreateGroup: boolean
addressVisibility: 'public' | 'attendees_only'
@ -122,6 +124,8 @@ function eventToFormValues(event: CreatedEvent): EventEditFormValues {
genderRestriction: event.genderRestriction ?? 'open',
ageRestriction: event.ageRestriction ?? 'open',
cancellationFeePercent: event.cancellationFeePercent,
cancellationFeePercent12To24Hours: event.cancellationFeePercent12To24Hours,
cancellationFeePercentMoreThan24Hours: event.cancellationFeePercentMoreThan24Hours,
isDiscoverable: event.settings.isDiscoverable,
autoCreateGroup: event.settings.autoCreateGroup,
addressVisibility: event.settings.addressVisibility,
@ -268,6 +272,15 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
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 payload: Partial<CreateEventPayload> = {
title: values.title.trim(),
@ -315,6 +328,8 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
if (!cancellationFeeLocked) {
payload.cancellationFeePercent = Number(values.cancellationFeePercent ?? 0)
payload.cancellationFeePercent12To24Hours = Number(values.cancellationFeePercent12To24Hours ?? 0)
payload.cancellationFeePercentMoreThan24Hours = Number(values.cancellationFeePercentMoreThan24Hours ?? 0)
}
if (!audienceLocked) {
@ -599,6 +614,22 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
selectKey="selectKey"
/>
</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
disabled={cancellationFeeLocked}
generalType="numberInput"

View File

@ -14,6 +14,8 @@ export const EVENT_SENSITIVE_FIELDS = [
'isFree',
'price',
'cancellationFeePercent',
'cancellationFeePercent12To24Hours',
'cancellationFeePercentMoreThan24Hours',
'genderRestriction',
'ageRestriction',
] as const

View File

@ -3,38 +3,50 @@ import { describe, expect, it } from 'vitest'
import { calculateCancellationPreview, resolveCancellationTicketPrice } from '@/services/bookings'
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', () => {
// 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.
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,
platformCommission: 7_070,
cancellationFeePercent: 10,
platformCommission: 707,
commissionPercent: 7,
hostCancellationFee: 10_100,
refundToGuest: 83_830,
hostCancellationFee: 9_393,
refundToGuest: 90_900,
})
})
it('does not produce fractional Toman values', () => {
// A different rate here (5%) than the test above (7%) is deliberate — it
// 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,
platformCommission: 49,
cancellationFeePercent: 7,
platformCommission: 3,
commissionPercent: 5,
hostCancellationFee: 69,
refundToGuest: 881,
hostCancellationFee: 66,
refundToGuest: 930,
})
})
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
expect(calculateCancellationPreview(8_000, 10, 7)).toEqual({
// List 10_000, discount 2_000 → paid 8_000; 10% penalty → refund 7_200.
expect(calculateCancellationPreview(8_000, policy, 7, startsAt, new Date('2026-09-08T11:00:00Z'))).toEqual({
ticketPrice: 8_000,
platformCommission: 560,
cancellationFeePercent: 10,
platformCommission: 56,
commissionPercent: 7,
hostCancellationFee: 800,
refundToGuest: 6_640,
hostCancellationFee: 744,
refundToGuest: 7_200,
})
})

View File

@ -299,7 +299,9 @@ export const CANCEL_BOOKING = async (
/**
* 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
* floor() rounding on each component) so this preview never shows a
* different number than what's actually credited.
@ -324,20 +326,46 @@ export interface CancellationPreview {
platformCommission: number
/** The rate actually applied, echoed back so callers can label the commission line without re-touching the booking. */
commissionPercent: number
cancellationFeePercent: number
hostCancellationFee: 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 = (
ticketPrice: number,
cancellationFeePercent: number,
commissionPercent: number
policy: CancellationFeePolicy,
commissionPercent: number,
eventStartsAt: string | Date,
cancelledAt: Date = new Date()
): CancellationPreview => {
const platformCommission = Math.floor(ticketPrice * (commissionPercent / 100))
const hostCancellationFee = Math.floor(ticketPrice * (cancellationFeePercent / 100))
const refundToGuest = ticketPrice - platformCommission - hostCancellationFee
const cancellationFeePercent = resolveCancellationFeePercent(policy, eventStartsAt, cancelledAt)
const grossCancellationFee = Math.floor(ticketPrice * (cancellationFeePercent / 100))
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. */

View File

@ -52,6 +52,8 @@ export interface DiscoveryEvent {
bookedCount: number
reservedCapacity: number
cancellationFeePercent: number
cancellationFeePercent12To24Hours: number
cancellationFeePercentMoreThan24Hours: number
commissionPercent: number | null
effectiveCommissionPercent: number
status: EventStatus

View File

@ -243,6 +243,8 @@ export interface EventRevisionPayload {
genderRestriction?: string
ageRestriction?: string
cancellationFeePercent?: number
cancellationFeePercent12To24Hours?: number
cancellationFeePercentMoreThan24Hours?: number
settings?: CreateEventPayload['settings']
media: CreateEventMediaPayload[]
faqs?: CreateEventFaqPayload[]

View File

@ -276,7 +276,9 @@ export const events = {
peopleRestrictionLabel: 'محدودیت‌افراد',
ageRestrictionLabel: 'محدودیت سنی',
cancellationSectionDescription: 'شرایط لغو را پیش از رزرو به مهمان نشان می‌دهیم تا تصمیم شفافی بگیرد.',
cancellationFeeLabel: 'درصد جریمه لغو (۰۱۰۰)',
cancellationFeeLabel: 'لغو لحظه آخری — ۱ دقیقه تا ۱۲ ساعت مانده',
cancellationFee12To24HoursLabel: 'لغو در آستانه برگزاری — ۱۲ تا ۲۴ ساعت مانده',
cancellationFeeMoreThan24HoursLabel: 'لغو زودهنگام — بیش از ۲۴ ساعت مانده',
advancedSettingsHint: 'پیشنهاد می‌کنیم مقادیر پیش‌فرض را نگه داری',
discoverableLabel: 'نمایش در جستجوی عمومی',
autoCreateGroupLabel: 'ساخت خودکار گروه چت پس از رویداد',

View File

@ -65,6 +65,7 @@ export const validation = {
cancelPenaltyRequired: 'درصد جریمه را وارد کنید',
cancelPenaltyNonNegative: 'درصد جریمه نمی‌تواند منفی باشد',
cancelPenaltyMax: 'درصد جریمه حداکثر ۱۰۰ است',
cancelPenaltyOrder: 'درصد جریمه باید با نزدیک‌شدن به زمان رویداد بیشتر یا مساوی شود',
paidPricePositive: 'برای رویداد پولی، قیمت باید بیشتر از صفر باشد',
reservedExceedsCapacity: 'ظرفیت رزرو شده نمی‌تواند از ظرفیت کل بیشتر باشد',
faqQuestionRequired: 'سوال را وارد کنید',

View File

@ -119,6 +119,8 @@ describe('event wizard validation', () => {
genderRestriction: 'open',
ageRestriction: 'open',
cancellationFeePercent: 0,
cancellationFeePercent12To24Hours: 0,
cancellationFeePercentMoreThan24Hours: 0,
isDiscoverable: true,
autoCreateGroup: true,
waitlistAutoOffer: true,
@ -135,6 +137,8 @@ describe('event wizard validation', () => {
genderRestriction: 'open',
ageRestriction: 'open',
cancellationFeePercent: 0,
cancellationFeePercent12To24Hours: 0,
cancellationFeePercentMoreThan24Hours: 0,
isDiscoverable: true,
autoCreateGroup: true,
waitlistAutoOffer: true,
@ -151,6 +155,8 @@ describe('event wizard validation', () => {
genderRestriction: 'open',
ageRestriction: 'open',
cancellationFeePercent: 0,
cancellationFeePercent12To24Hours: 0,
cancellationFeePercentMoreThan24Hours: 0,
isDiscoverable: true,
autoCreateGroup: true,
waitlistAutoOffer: true,
@ -168,6 +174,8 @@ describe('event wizard validation', () => {
genderRestriction: 'open',
ageRestriction: 'open',
cancellationFeePercent: 0,
cancellationFeePercent12To24Hours: 0,
cancellationFeePercentMoreThan24Hours: 0,
isDiscoverable: true,
autoCreateGroup: true,
waitlistAutoOffer: true,
@ -179,6 +187,40 @@ describe('event wizard validation', () => {
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', () => {
expect(EventWizardFaqFormValidation.safeParse({ question: '', answer: 'پاسخ' }).success).toBe(false)
expect(EventWizardFaqFormValidation.parse({ question: 'سوال؟', answer: 'پاسخ' })).toEqual({

View File

@ -108,6 +108,14 @@ export const EventWizardStep3Validation = z
genderRestriction: z.enum(['open', 'female_only', 'male_only']),
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),
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(),
autoCreateGroup: z.boolean(),
waitlistAutoOffer: z.boolean(),
@ -128,6 +136,16 @@ export const EventWizardStep3Validation = z
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>