diff --git a/components/events/create/CancellationPolicyEditor.tsx b/components/events/create/CancellationPolicyEditor.tsx index 8644062..254c0f3 100644 --- a/components/events/create/CancellationPolicyEditor.tsx +++ b/components/events/create/CancellationPolicyEditor.tsx @@ -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 ( -
-
-
-

{texts.events.cancellationFeeLabel}

-

{texts.events.cancellationSectionDescription}

-
-
- { - const next = Number(nextValue) +
+ {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. */} - ٪ -
-
+ return ( +
+
+

{tier.label}

+
+ { + const next = Number(nextValue) + + onChange(tier.field, Number.isFinite(next) ? Math.min(100, Math.max(0, next)) : 0) + }} + /> + ٪ +
+
+
+ ) + })}
) } diff --git a/components/events/create/EventCreateWizard.tsx b/components/events/create/EventCreateWizard.tsx index a5c7290..d1dffac 100644 --- a/components/events/create/EventCreateWizard.tsx +++ b/components/events/create/EventCreateWizard.tsx @@ -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, diff --git a/components/events/create/cloneFromEvent.test.ts b/components/events/create/cloneFromEvent.test.ts index 4220927..a908197 100644 --- a/components/events/create/cloneFromEvent.test.ts +++ b/components/events/create/cloneFromEvent.test.ts @@ -30,6 +30,8 @@ const baseEvent = { reservedCapacity: 3, bookedCount: 5, cancellationFeePercent: 10, + cancellationFeePercent12To24Hours: 10, + cancellationFeePercentMoreThan24Hours: 10, commissionPercent: null, effectiveCommissionPercent: 7, status: 'published', diff --git a/components/events/create/cloneFromEvent.ts b/components/events/create/cloneFromEvent.ts index 39dab49..a4548e1 100644 --- a/components/events/create/cloneFromEvent.ts +++ b/components/events/create/cloneFromEvent.ts @@ -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, diff --git a/components/events/create/steps/Step3PricingCapacity.tsx b/components/events/create/steps/Step3PricingCapacity.tsx index 5d642fa..d41ad15 100644 --- a/components/events/create/steps/Step3PricingCapacity.tsx +++ b/components/events/create/steps/Step3PricingCapacity.tsx @@ -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 }: { - 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 }) }} /> diff --git a/components/events/create/types.ts b/components/events/create/types.ts index 062b052..38ca830 100644 --- a/components/events/create/types.ts +++ b/components/events/create/types.ts @@ -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 diff --git a/e2e/fixtures/hostEvent.ts b/e2e/fixtures/hostEvent.ts index 2c834ef..5e4af34 100644 --- a/e2e/fixtures/hostEvent.ts +++ b/e2e/fixtures/hostEvent.ts @@ -70,6 +70,8 @@ export const createHostEvent = >(overrides: T) cityId: 2, provinceId: 1, cancellationFeePercent: 0, + cancellationFeePercent12To24Hours: 0, + cancellationFeePercentMoreThan24Hours: 0, discoveryPlacements: [], createdAt: '2029-12-01T10:00:00.000Z', publishedAt: null, diff --git a/features/events/detail/admin-event-detail/AdminEventDetailsSection.tsx b/features/events/detail/admin-event-detail/AdminEventDetailsSection.tsx index e236021..07ee5d4 100644 --- a/features/events/detail/admin-event-detail/AdminEventDetailsSection.tsx +++ b/features/events/detail/admin-event-detail/AdminEventDetailsSection.tsx @@ -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 = ({
- {number(event.cancellationFeePercent)}٪ + + {number(event.cancellationFeePercentMoreThan24Hours)}٪ + + {number(event.cancellationFeePercent12To24Hours)}٪ + {number(event.cancellationFeePercent)}٪ {restrictionStatus(event.genderRestriction, genderRestrictionLabel(event.genderRestriction))} diff --git a/features/events/edit/EventEditForm.test.tsx b/features/events/edit/EventEditForm.test.tsx index 9627b8c..3b79c08 100644 --- a/features/events/edit/EventEditForm.test.tsx +++ b/features/events/edit/EventEditForm.test.tsx @@ -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() diff --git a/features/events/edit/EventEditForm.tsx b/features/events/edit/EventEditForm.tsx index 29db94f..4d5c0fb 100644 --- a/features/events/edit/EventEditForm.tsx +++ b/features/events/edit/EventEditForm.tsx @@ -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 = { 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" /> + + { + 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, }) }) diff --git a/services/bookings.ts b/services/bookings.ts index c87865f..e7ada1c 100644 --- a/services/bookings.ts +++ b/services/bookings.ts @@ -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. */ diff --git a/services/discovery.ts b/services/discovery.ts index 9f2df96..0fcc7d6 100644 --- a/services/discovery.ts +++ b/services/discovery.ts @@ -52,6 +52,8 @@ export interface DiscoveryEvent { bookedCount: number reservedCapacity: number cancellationFeePercent: number + cancellationFeePercent12To24Hours: number + cancellationFeePercentMoreThan24Hours: number commissionPercent: number | null effectiveCommissionPercent: number status: EventStatus diff --git a/services/events.ts b/services/events.ts index 41739fc..a861578 100644 --- a/services/events.ts +++ b/services/events.ts @@ -243,6 +243,8 @@ export interface EventRevisionPayload { genderRestriction?: string ageRestriction?: string cancellationFeePercent?: number + cancellationFeePercent12To24Hours?: number + cancellationFeePercentMoreThan24Hours?: number settings?: CreateEventPayload['settings'] media: CreateEventMediaPayload[] faqs?: CreateEventFaqPayload[] diff --git a/texts/events.ts b/texts/events.ts index 3466512..8cb2589 100644 --- a/texts/events.ts +++ b/texts/events.ts @@ -276,7 +276,9 @@ export const events = { peopleRestrictionLabel: 'محدودیت‌افراد', ageRestrictionLabel: 'محدودیت سنی', cancellationSectionDescription: 'شرایط لغو را پیش از رزرو به مهمان نشان می‌دهیم تا تصمیم شفافی بگیرد.', - cancellationFeeLabel: 'درصد جریمه لغو (۰–۱۰۰)', + cancellationFeeLabel: 'لغو لحظه آخری — ۱ دقیقه تا ۱۲ ساعت مانده', + cancellationFee12To24HoursLabel: 'لغو در آستانه برگزاری — ۱۲ تا ۲۴ ساعت مانده', + cancellationFeeMoreThan24HoursLabel: 'لغو زودهنگام — بیش از ۲۴ ساعت مانده', advancedSettingsHint: 'پیشنهاد می‌کنیم مقادیر پیش‌فرض را نگه داری', discoverableLabel: 'نمایش در جستجوی عمومی', autoCreateGroupLabel: 'ساخت خودکار گروه چت پس از رویداد', diff --git a/texts/validation.ts b/texts/validation.ts index 272567a..5b5cf9f 100644 --- a/texts/validation.ts +++ b/texts/validation.ts @@ -65,6 +65,7 @@ export const validation = { cancelPenaltyRequired: 'درصد جریمه را وارد کنید', cancelPenaltyNonNegative: 'درصد جریمه نمی‌تواند منفی باشد', cancelPenaltyMax: 'درصد جریمه حداکثر ۱۰۰ است', + cancelPenaltyOrder: 'درصد جریمه باید با نزدیک‌شدن به زمان رویداد بیشتر یا مساوی شود', paidPricePositive: 'برای رویداد پولی، قیمت باید بیشتر از صفر باشد', reservedExceedsCapacity: 'ظرفیت رزرو شده نمی‌تواند از ظرفیت کل بیشتر باشد', faqQuestionRequired: 'سوال را وارد کنید', diff --git a/validation/eventWizard.test.ts b/validation/eventWizard.test.ts index e9126ab..2a82de0 100644 --- a/validation/eventWizard.test.ts +++ b/validation/eventWizard.test.ts @@ -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({ diff --git a/validation/eventWizard.ts b/validation/eventWizard.ts index 1e72235..19ecd58 100644 --- a/validation/eventWizard.ts +++ b/validation/eventWizard.ts @@ -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