From 82593dc18c85d5463d32386293199f96655bb516 Mon Sep 17 00:00:00 2001
From: alisaza
Date: Mon, 7 Sep 2026 18:09:55 +0330
Subject: [PATCH] refactor(events): update event management components and
remove unused files
- Replaced `LIST_PUBLIC_CATEGORIES` with `LIST_ADMIN_CATEGORIES_FLAT` in `ArticleFormModal.tsx`.
- Removed the "Add Event" button from the dashboard page.
- Simplified the `EventsPage` by removing the button and adjusting the layout.
- Cleaned up the `AdminEventEditPage` by removing unnecessary props.
- Deleted unused layout and page files related to event creation.
- Updated `AdminAuthContent` to use `useAdminCitiesQuery` instead of `useDiscoveryCitiesQuery`.
- Refactored `EventGuestListAccessPanel` to remove the `accessMode` prop and adjust API calls accordingly.
- Removed several unused components and tests related to event creation, enhancing project maintainability.
---
.../_components/ArticleFormModal.tsx | 4 +-
app/(dashboard)/dashboard/page.tsx | 8 -
.../manage-events/[id]/edit/page.tsx | 2 -
app/(dashboard)/manage-events/new/layout.tsx | 12 -
app/(dashboard)/manage-events/new/page.tsx | 5 -
app/(dashboard)/manage-events/page.tsx | 13 +-
components/auth/AdminAuthContent.tsx | 4 +-
.../events/EventGuestListAccessPanel.tsx | 15 +-
.../create/AudienceRestrictionSelector.tsx | 98 -----
.../create/CancellationPolicyEditor.tsx | 81 ----
.../events/create/CreateEventPreviewCard.tsx | 72 ---
.../events/create/EventCreateWizard.tsx | 414 ------------------
.../events/create/TicketTypeSelector.tsx | 48 --
components/events/create/WizardStepper.tsx | 144 ------
.../events/create/cloneFromEvent.test.ts | 109 -----
components/events/create/cloneFromEvent.ts | 72 ---
.../events/create/steps/Step1BasicInfo.tsx | 184 --------
.../create/steps/Step2ScheduleLocation.tsx | 244 -----------
.../create/steps/Step3PricingCapacity.tsx | 203 ---------
.../events/create/steps/Step4FaqPreview.tsx | 91 ----
.../events/create/steps/Step5GuestList.tsx | 161 -------
components/events/create/types.ts | 99 +----
constants/routes.test.ts | 1 -
constants/routes.ts | 1 -
context/AuthContext.tsx | 38 +-
e2e/admin/event-management.spec.ts | 68 +--
features/events/create/AdminEventCreate.tsx | 27 --
features/events/detail/AdminEventDetail.tsx | 6 +-
.../AdminEventLifecycleActions.tsx | 7 -
.../AdminEventReviewsTab.tsx | 5 +-
features/events/edit/EventEditForm.test.tsx | 20 +-
features/events/edit/EventEditForm.tsx | 71 +--
.../events/management/EventReviewsPanel.tsx | 186 ++------
helpers/index.ts | 2 +-
queries/admin/adminKeys.ts | 2 +-
queries/admin/useAdminCitiesQuery.ts | 17 +
queries/consumer/useDiscoveryCitiesQuery.ts | 15 -
queries/consumer/usePublicCategoriesQuery.ts | 22 -
queries/consumerKeys.ts | 11 -
services/adminWallet.ts | 4 +-
services/bookings.test.ts | 57 ---
services/bookings.ts | 373 ----------------
services/config.ts | 13 -
services/discountCodes.ts | 14 -
services/discovery.ts | 230 ----------
services/eventCategories.ts | 25 +-
services/eventDetail.ts | 7 +-
services/eventGuestListAccess.ts | 22 +-
services/eventManagement.ts | 80 +---
services/events.ts | 176 ++------
services/push.ts | 86 +---
services/reviews.ts | 181 +-------
services/supportTickets.ts | 31 +-
services/users.ts | 70 +--
services/wallet.ts | 144 ------
texts/auth.ts | 28 --
texts/blog.ts | 29 --
texts/bookings.ts | 100 -----
texts/chats.ts | 68 ---
texts/common.ts | 34 --
texts/discovery.ts | 121 -----
texts/events.ts | 408 -----------------
texts/identity.ts | 36 --
texts/index.ts | 8 -
texts/notifications.ts | 21 -
texts/profile.ts | 143 ------
texts/reviews.ts | 43 --
texts/seo.ts | 367 ----------------
texts/status.ts | 1 -
texts/support.ts | 29 +-
texts/validation.ts | 37 --
texts/wallet.ts | 71 ---
validation/consumerEventWizardStep2.ts | 89 ----
validation/eventWizard.test.ts | 224 +---------
validation/eventWizard.ts | 146 ------
75 files changed, 144 insertions(+), 5954 deletions(-)
delete mode 100644 app/(dashboard)/manage-events/new/layout.tsx
delete mode 100644 app/(dashboard)/manage-events/new/page.tsx
delete mode 100644 components/events/create/AudienceRestrictionSelector.tsx
delete mode 100644 components/events/create/CancellationPolicyEditor.tsx
delete mode 100644 components/events/create/CreateEventPreviewCard.tsx
delete mode 100644 components/events/create/EventCreateWizard.tsx
delete mode 100644 components/events/create/TicketTypeSelector.tsx
delete mode 100644 components/events/create/WizardStepper.tsx
delete mode 100644 components/events/create/cloneFromEvent.test.ts
delete mode 100644 components/events/create/cloneFromEvent.ts
delete mode 100644 components/events/create/steps/Step1BasicInfo.tsx
delete mode 100644 components/events/create/steps/Step2ScheduleLocation.tsx
delete mode 100644 components/events/create/steps/Step3PricingCapacity.tsx
delete mode 100644 components/events/create/steps/Step4FaqPreview.tsx
delete mode 100644 components/events/create/steps/Step5GuestList.tsx
delete mode 100644 features/events/create/AdminEventCreate.tsx
create mode 100644 queries/admin/useAdminCitiesQuery.ts
delete mode 100644 queries/consumer/useDiscoveryCitiesQuery.ts
delete mode 100644 queries/consumer/usePublicCategoriesQuery.ts
delete mode 100644 queries/consumerKeys.ts
delete mode 100644 services/bookings.test.ts
delete mode 100644 services/bookings.ts
delete mode 100644 services/discovery.ts
delete mode 100644 services/wallet.ts
delete mode 100644 texts/bookings.ts
delete mode 100644 texts/identity.ts
delete mode 100644 texts/notifications.ts
delete mode 100644 texts/wallet.ts
delete mode 100644 validation/consumerEventWizardStep2.ts
diff --git a/app/(dashboard)/blog-articles/_components/ArticleFormModal.tsx b/app/(dashboard)/blog-articles/_components/ArticleFormModal.tsx
index fd4df50..341aad6 100644
--- a/app/(dashboard)/blog-articles/_components/ArticleFormModal.tsx
+++ b/app/(dashboard)/blog-articles/_components/ArticleFormModal.tsx
@@ -17,7 +17,7 @@ import UnsavedChangesIndicator from '@/components/forms/UnsavedChangesIndicator'
import useAdminCrudFormModal from '@/hooks/useAdminCrudFormModal'
import { type BlogArticle, CREATE_ARTICLE, UPDATE_ARTICLE } from '@/services/blogArticles'
import { fetchAllCities, type City } from '@/services/geography'
-import { LIST_PUBLIC_CATEGORIES } from '@/services/eventCategories'
+import { LIST_ADMIN_CATEGORIES_FLAT } from '@/services/eventCategories'
import {
analyzeArticleBody,
ARTICLE_EXCERPT_MAX,
@@ -246,7 +246,7 @@ const ArticleFormModal = ({ isOpen, onOpenChange, article, onSuccess }: ArticleF
.then(setCities)
.catch(() => addToast({ title: 'بارگذاری فهرست شهرها ناموفق بود', color: 'danger' }))
- void LIST_PUBLIC_CATEGORIES().then((result) => {
+ void LIST_ADMIN_CATEGORIES_FLAT().then((result) => {
if (result.ok) setEventCategories(result.data.items)
})
}, [])
diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx
index d5b56fe..c53d9ac 100644
--- a/app/(dashboard)/dashboard/page.tsx
+++ b/app/(dashboard)/dashboard/page.tsx
@@ -18,7 +18,6 @@ import BookmarkIcon from '@/components/icons/BookmarkIcon'
import UserIdCardIcon from '@/components/icons/UserIdCardIcon'
import CoinDollarIcon from '@/components/icons/CoinDollarIcon'
import AngleLeftIcon from '@/components/icons/AngleLeftIcon'
-import PlusIcon from '@/components/icons/PlusIcon'
interface StatItem {
label: string
@@ -237,13 +236,6 @@ const DashboardPage = () => {
مهمترین شاخصهای کاربران، رویدادها، رزروها و عملیات مالی را در یک نگاه بررسی کنید.
-
-
- ایجاد رویداد
-
{
diff --git a/app/(dashboard)/manage-events/new/layout.tsx b/app/(dashboard)/manage-events/new/layout.tsx
deleted file mode 100644
index 16ea666..0000000
--- a/app/(dashboard)/manage-events/new/layout.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import type { Metadata } from 'next'
-import type { ReactNode } from 'react'
-
-import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles'
-
-export const metadata: Metadata = {
- title: ADMIN_PAGE_TITLES.manageEventNew,
-}
-
-export default function Layout({ children }: { children: ReactNode }) {
- return children
-}
diff --git a/app/(dashboard)/manage-events/new/page.tsx b/app/(dashboard)/manage-events/new/page.tsx
deleted file mode 100644
index e6a7dd8..0000000
--- a/app/(dashboard)/manage-events/new/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import AdminEventCreate from '@/features/events/create/AdminEventCreate'
-
-const AdminEventCreatePage = () =>
-
-export default AdminEventCreatePage
diff --git a/app/(dashboard)/manage-events/page.tsx b/app/(dashboard)/manage-events/page.tsx
index 5f1286a..0f80527 100644
--- a/app/(dashboard)/manage-events/page.tsx
+++ b/app/(dashboard)/manage-events/page.tsx
@@ -3,7 +3,6 @@
import { useEffect, useMemo, useState } from 'react'
import type { PaginationListColumnType } from '@/types'
-import Button from '@/components/formElements/Button'
import PaginatedList from '@/components/PaginatedList'
import PageNavbar from '@/components/layouts/PageNavbar'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
@@ -183,17 +182,7 @@ const EventsPage = () => {
return (
-
- افزودن رویداد
-
- }
- pageTitle="رویدادها"
- />
+
({ id: String(city.id), name: city.name }))
const title = flow.step === 'otp' ? texts.auth.otpTitle : flow.step === 'profile' ? texts.auth.profileTitle : null
diff --git a/components/events/EventGuestListAccessPanel.tsx b/components/events/EventGuestListAccessPanel.tsx
index 781b396..399ff86 100644
--- a/components/events/EventGuestListAccessPanel.tsx
+++ b/components/events/EventGuestListAccessPanel.tsx
@@ -8,19 +8,16 @@ import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
import {
fetchEventGuestListLinks,
fetchEventInvitedGuests,
- type EventGuestListAccessMode,
type EventGuestListLink,
type EventInvitedGuest,
} from '@/services/eventGuestListAccess'
import { format, texts } from '@/texts'
interface EventGuestListAccessPanelProps {
- accessMode: EventGuestListAccessMode
eventId: string
- surface?: 'admin' | 'consumer'
}
-const EventGuestListAccessPanel = ({ accessMode, eventId, surface = 'consumer' }: EventGuestListAccessPanelProps) => {
+const EventGuestListAccessPanel = ({ eventId }: EventGuestListAccessPanelProps) => {
const [links, setLinks] = useState([])
const [invitedGuests, setInvitedGuests] = useState([])
const [isLoading, setIsLoading] = useState(true)
@@ -34,8 +31,8 @@ const EventGuestListAccessPanel = ({ accessMode, eventId, surface = 'consumer' }
setIsLoading(true)
setError(null)
const [nextLinks, nextInvitedGuests] = await Promise.all([
- fetchEventGuestListLinks(eventId, accessMode),
- fetchEventInvitedGuests(eventId, accessMode),
+ fetchEventGuestListLinks(eventId),
+ fetchEventInvitedGuests(eventId),
])
if (!cancelled) {
@@ -54,12 +51,10 @@ const EventGuestListAccessPanel = ({ accessMode, eventId, surface = 'consumer' }
return () => {
cancelled = true
}
- }, [accessMode, eventId])
-
- const surfaceClass = surface === 'admin' ? 'admin-surface' : 'consumer-surface'
+ }, [eventId])
return (
-
+
{texts.events.guestAccessTitle}
diff --git a/components/events/create/AudienceRestrictionSelector.tsx b/components/events/create/AudienceRestrictionSelector.tsx
deleted file mode 100644
index 66334fc..0000000
--- a/components/events/create/AudienceRestrictionSelector.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-'use client'
-
-import { Button } from '@heroui/react'
-
-import type { EventAgeRestriction, EventGenderRestriction } from '@/lib/eventAudience'
-import { AGE_RESTRICTION_OPTIONS, GENDER_RESTRICTION_OPTIONS } from '@/lib/eventAudience'
-import { cn } from '@/lib/cn'
-import { texts } from '@/texts'
-
-interface AudienceRestrictionSelectorProps {
- ageRestriction: EventAgeRestriction
- genderRestriction: EventGenderRestriction
- disabled?: boolean
- onAgeRestrictionChange: (value: EventAgeRestriction) => void
- onGenderRestrictionChange: (value: EventGenderRestriction) => void
-}
-
-/** Content-width button groups for age and gender limits shown to event guests. */
-export default function AudienceRestrictionSelector({
- ageRestriction,
- genderRestriction,
- disabled = false,
- onAgeRestrictionChange,
- onGenderRestrictionChange,
-}: AudienceRestrictionSelectorProps) {
- return (
-
- {
- onGenderRestrictionChange(value as EventGenderRestriction)
- }}
- />
- {
- onAgeRestrictionChange(value as EventAgeRestriction)
- }}
- />
-
- )
-}
-
-function RestrictionGroup({
- ariaLabel,
- disabled = false,
- options,
- value,
- onChange,
-}: {
- ariaLabel: string
- disabled?: boolean
- options: readonly { name: string; selectKey: string }[]
- value: string
- onChange: (value: string) => void
-}) {
- return (
-
-
- {options.map((option) => {
- const active = option.selectKey === value
-
- return (
-
- )
- })}
-
-
- )
-}
diff --git a/components/events/create/CancellationPolicyEditor.tsx b/components/events/create/CancellationPolicyEditor.tsx
deleted file mode 100644
index 254c0f3..0000000
--- a/components/events/create/CancellationPolicyEditor.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-'use client'
-
-import Input from '@/components/formElements/Input'
-import { texts } from '@/texts'
-
-interface CancellationPolicyEditorProps {
- disabled?: boolean
- cancellationFeePercent: number
- cancellationFeePercent12To24Hours: number
- cancellationFeePercentMoreThan24Hours: number
- onChange: (
- field: 'cancellationFeePercent' | 'cancellationFeePercent12To24Hours' | 'cancellationFeePercentMoreThan24Hours',
- value: number
- ) => void
-}
-
-/**
- * Makes all three time-based cancellation fee tiers explicit to hosts.
- */
-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 (
-
- {tiers.map((tier) => {
- const normalizedValue = Number.isFinite(tier.value) ? Math.min(100, Math.max(0, tier.value)) : 0
-
- 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/CreateEventPreviewCard.tsx b/components/events/create/CreateEventPreviewCard.tsx
deleted file mode 100644
index a42eb60..0000000
--- a/components/events/create/CreateEventPreviewCard.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-'use client'
-
-import type { EventWizardFormData } from '@/components/events/create/types'
-import { formatToman } from '@/components/events/create/types'
-import { cn } from '@/lib/cn'
-import { texts } from '@/texts'
-
-interface CreateEventPreviewCardProps {
- categoryName?: string
- cityName?: string
- data: EventWizardFormData
- endsAt: string
- startsAt: string
-}
-
-interface PreviewField {
- full?: boolean
- label: string
- value: string
-}
-
-/** Read-only event card that mirrors the information guests see before booking. */
-export default function CreateEventPreviewCard({ categoryName, cityName, data, endsAt, startsAt }: CreateEventPreviewCardProps) {
- const fields = [
- { label: texts.common.category, value: categoryName || texts.status.empty },
- { label: texts.events.start, value: startsAt || texts.status.empty },
- { label: texts.events.end, value: endsAt || texts.status.empty },
- { label: texts.events.location, value: cityName || texts.status.empty },
- { label: texts.events.price, value: data.isFree ? texts.events.free : formatToman(data.price) },
- { label: texts.events.capacity, value: String(data.capacity) },
- { full: true, label: texts.events.address, value: data.address || texts.status.empty },
- ] satisfies PreviewField[]
- const poster = [...data.media].sort((a, b) => a.sortOrder - b.sortOrder).find((item) => item.isPoster) ?? data.media[0]
-
- return (
-
-
-
-
{categoryName || texts.events.eventCategoryFallback}
-
{data.title || texts.events.eventTitleLabel}
- {data.shortDescription ?
{data.shortDescription}
: null}
-
-
-
-
- {fields.map((field) => (
-
-
{field.label}
-
{field.value}
-
- ))}
-
-
- )
-}
diff --git a/components/events/create/EventCreateWizard.tsx b/components/events/create/EventCreateWizard.tsx
deleted file mode 100644
index d1dffac..0000000
--- a/components/events/create/EventCreateWizard.tsx
+++ /dev/null
@@ -1,414 +0,0 @@
-'use client'
-
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import dynamic from 'next/dynamic'
-import { usePathname, useRouter, useSearchParams } from 'next/navigation'
-
-import { texts } from '@/texts'
-import type { PreviousAttendee } from '@/services/events'
-import { addToast } from '@/lib/toast'
-import WizardStepper from '@/components/events/create/WizardStepper'
-import Button from '@/components/formElements/Button'
-import { mapEventToWizardCloneData } from '@/components/events/create/cloneFromEvent'
-import { DetailSkeleton } from '@/components/feedback/LoadingState'
-import {
- combineDateAndMinutes,
- hasRequiredPosters,
- INITIAL_WIZARD_DATA,
- WIZARD_STEPS,
- type EventWizardFormData,
-} from '@/components/events/create/types'
-import { APP_ROUTES } from '@/constants/routes'
-import { createEvent, createEventFaq, fetchEventForEdit } from '@/services/events'
-import { fetchEventFaqs, fetchEventMedia } from '@/services/eventDetail'
-import { fetchAllCities, type City } from '@/services/geography'
-import { extractServerErrorDetail } from '@/services/errorHandler'
-import { GET_ME } from '@/services/users'
-import useAuth from '@/hooks/useAuth'
-import { clearEventDraft, loadEventDraft, saveEventDraft } from '@/features/events/eventDraftStore'
-import { usePublicCategoriesQuery } from '@/queries/consumer/usePublicCategoriesQuery'
-import { ANALYTICS_EVENTS, trackAnalyticsEvent } from '@/lib/analytics'
-
-const Step1BasicInfo = dynamic(() => import('@/components/events/create/steps/Step1BasicInfo'), {
- ssr: false,
- loading: () => ,
-})
-const Step2ScheduleLocation = dynamic(() => import('@/components/events/create/steps/Step2ScheduleLocation'), {
- ssr: false,
- loading: () => ,
-})
-const Step3PricingCapacity = dynamic(() => import('@/components/events/create/steps/Step3PricingCapacity'), {
- ssr: false,
- loading: () => ,
-})
-const Step4FaqPreview = dynamic(() => import('@/components/events/create/steps/Step4FaqPreview'), {
- ssr: false,
- loading: () => ,
-})
-const Step5GuestList = dynamic(() => import('@/components/events/create/steps/Step5GuestList'), {
- ssr: false,
-})
-
-interface EventCreateWizardProps {
- finishRoute?: string
-}
-
-export default function EventCreateWizard({ finishRoute = APP_ROUTES.MANAGE_EVENTS }: EventCreateWizardProps = {}) {
- const router = useRouter()
- const pathname = usePathname()
- const searchParams = useSearchParams()
- const cloneFromIdRef = useRef(searchParams.get('cloneFrom'))
- const hasTrackedStartRef = useRef(false)
- const { user } = useAuth()
- const userId = user?.userId
- const [step, setStep] = useState(1)
- const [data, setData] = useState(INITIAL_WIZARD_DATA)
- const [isDraftHydrated, setIsDraftHydrated] = useState(false)
- const [wasDraftRestored, setWasDraftRestored] = useState(false)
- const [wasCloneLoaded, setWasCloneLoaded] = useState(false)
- const [isSaving, setIsSaving] = useState(false)
- const [createdEventId, setCreatedEventId] = useState(null)
- const [previousAttendees, setPreviousAttendees] = useState([])
- const [showPostCreateInvite, setShowPostCreateInvite] = useState(false)
-
- useEffect(() => {
- if (!userId || hasTrackedStartRef.current) return
-
- hasTrackedStartRef.current = true
- trackAnalyticsEvent(ANALYTICS_EVENTS.EVENT_CREATE_STARTED, {
- creation_source: 'admin',
- is_clone: Boolean(cloneFromIdRef.current),
- })
- }, [userId])
-
- // Shared hook — same key/staleTime as Step1BasicInfo + CategoryBrowse.
- const categoriesQuery = usePublicCategoriesQuery()
- const categories = useMemo(() => categoriesQuery.data?.items ?? [], [categoriesQuery.data])
- const [cities, setCities] = useState([])
-
- useEffect(() => {
- if (!userId) return
-
- let cancelled = false
- const cloneFromId = cloneFromIdRef.current
-
- const hydrate = async () => {
- if (cloneFromId) {
- try {
- const [event, media, faqs] = await Promise.all([
- fetchEventForEdit(cloneFromId),
- fetchEventMedia(cloneFromId).catch(() => []),
- fetchEventFaqs(cloneFromId).catch(() => []),
- ])
-
- if (cancelled) return
-
- clearEventDraft(userId)
- setData(mapEventToWizardCloneData(event, media, faqs))
- setStep(1)
- setWasCloneLoaded(true)
- setWasDraftRestored(false)
- setIsDraftHydrated(true)
- router.replace(pathname)
-
- return
- } catch {
- if (cancelled) return
- addToast({ title: texts.events.wizardCloneLoadFailed, color: 'danger' })
- }
- }
-
- const stored = loadEventDraft(userId)
-
- if (stored) {
- setData({ ...INITIAL_WIZARD_DATA, ...stored.data })
- setStep(stored.step)
- setWasDraftRestored(true)
- setIsDraftHydrated(true)
-
- return
- }
-
- // Fresh create: prefill host default venue from profile contact info.
- try {
- const [meResult, cityRows] = await Promise.all([GET_ME(), fetchAllCities()])
-
- if (cancelled) return
-
- setCities(cityRows)
-
- if (meResult.ok) {
- const city = meResult.data.cityId ? cityRows.find((row) => row.id === meResult.data.cityId) : undefined
-
- setData({
- ...INITIAL_WIZARD_DATA,
- ...(meResult.data.defaultAddress ? { address: meResult.data.defaultAddress } : {}),
- ...(city ? { cityId: String(city.id), provinceId: String(city.provinceId) } : {}),
- })
- }
- } catch {
- // Prefill is best-effort; wizard still opens with empty location.
- }
-
- setIsDraftHydrated(true)
- }
-
- void hydrate()
-
- return () => {
- cancelled = true
- }
- // Hydrate once per userId. pathname/router are read from the render that starts hydration.
- // eslint-disable-next-line react-hooks/exhaustive-deps -- avoid re-cloning after URL cleanup
- }, [userId])
-
- useEffect(() => {
- if (!userId || !isDraftHydrated || createdEventId) return
-
- const timeout = window.setTimeout(() => {
- saveEventDraft(userId, data, step)
- }, 250)
-
- return () => {
- window.clearTimeout(timeout)
- }
- }, [createdEventId, data, isDraftHydrated, step, userId])
-
- useEffect(() => {
- // دریافت استانها فعلاً غیرفعال است؛ نام شهر مستقیماً از فهرست شهرها خوانده میشود.
- // void fetchProvinces().then(setProvinces)
- void fetchAllCities().then(setCities)
- }, [])
-
- const patchData = useCallback((patch: Partial) => {
- setData((prev) => ({ ...prev, ...patch }))
- }, [])
-
- const categoryName = useMemo(() => categories.find((item) => String(item.id) === data.categoryId)?.name, [categories, data.categoryId])
-
- // نام استان فعلاً در پیشنمایش رویداد نمایش داده نمیشود.
- // const provinceName = useMemo(() => provinces.find((item) => String(item.id) === data.provinceId)?.name, [provinces, data.provinceId])
-
- const cityName = useMemo(() => cities.find((item) => String(item.id) === data.cityId)?.name, [cities, data.cityId])
-
- const currentStepTitle = WIZARD_STEPS.find((item) => item.key === step)?.label
-
- const handleSaveDraft = async () => {
- if (!hasRequiredPosters(data.media)) {
- addToast({
- title: texts.events.postersRequiredTitle,
- description: texts.events.postersRequiredDescription,
- color: 'danger',
- })
- setStep(1)
-
- return
- }
-
- setIsSaving(true)
-
- let eventId: string | null = null
-
- try {
- const startsAt = combineDateAndMinutes(data.startDate, data.startTime)
- const endsAt = combineDateAndMinutes(data.endDate, data.endTime)
-
- if (new Date(endsAt) <= new Date(startsAt)) {
- addToast({ title: texts.validation.eventWizard.endAfterStart, color: 'danger' })
-
- return
- }
-
- const event = await createEvent({
- title: data.title,
- slug: data.slug,
- categoryId: Number(data.categoryId),
- shortDescription: data.shortDescription || undefined,
- description: data.description || undefined,
- startsAt,
- endsAt,
- provinceId: Number(data.provinceId),
- cityId: Number(data.cityId),
- address: data.address,
- lat: data.lat,
- lng: data.lng,
- isFree: data.isFree,
- price: data.isFree ? 0 : data.price,
- capacity: data.capacity,
- reservedCapacity: data.reservedCapacity,
- genderRestriction: data.genderRestriction,
- ageRestriction: data.ageRestriction,
- cancellationFeePercent: data.cancellationFeePercent,
- cancellationFeePercent12To24Hours: data.cancellationFeePercent12To24Hours,
- cancellationFeePercentMoreThan24Hours: data.cancellationFeePercentMoreThan24Hours,
- settings: {
- isDiscoverable: data.isDiscoverable,
- autoCreateGroup: data.autoCreateGroup,
- waitlistAutoOffer: data.waitlistAutoOffer,
- sendReviewRequestSms: data.sendReviewRequestSms,
- addressVisibility: data.addressVisibility,
- generalArea: data.addressVisibility === 'attendees_only' ? data.generalArea : undefined,
- },
- media: data.media.map((item) => ({
- mediaType: 'image' as const,
- url: item.url,
- sortOrder: item.sortOrder,
- isPoster: item.isPoster,
- isSquarePoster: item.isSquarePoster,
- })),
- })
-
- eventId = event.id
- setCreatedEventId(event.id)
- if (userId) clearEventDraft(userId)
-
- for (let index = 0; index < data.faqs.length; index += 1) {
- const faq = data.faqs[index]
-
- await createEventFaq(event.id, {
- question: faq.question,
- answer: faq.answer,
- sortOrder: index,
- })
- }
-
- addToast({ title: texts.events.draftSaved, color: 'success' })
-
- const { loadPreviousAttendeesForStep5 } = await import('@/components/events/create/steps/Step5GuestList')
- const attendees = await loadPreviousAttendeesForStep5()
-
- if (attendees.length === 0) {
- router.push(finishRoute)
-
- return
- }
-
- setPreviousAttendees(attendees)
- setShowPostCreateInvite(true)
- } catch (error) {
- const detail = extractServerErrorDetail((error as { response?: { data?: unknown } })?.response?.data)
-
- if (eventId) {
- addToast({
- title: texts.events.createdPartialTitle,
- description: detail ?? texts.events.createdPartialDescription,
- color: 'warning',
- })
- router.push(APP_ROUTES.MANAGE_EVENT_DETAIL(eventId))
-
- return
- }
-
- addToast({
- title: texts.events.draftSaveFailed,
- description: detail ?? undefined,
- color: 'danger',
- })
- } finally {
- setIsSaving(false)
- }
- }
-
- if (!userId || !isDraftHydrated) return
-
- return (
-
-
-
- {wasCloneLoaded
- ? texts.events.wizardCloneHint
- : wasDraftRestored
- ? texts.events.wizardDraftRestoredHint
- : texts.events.wizardAutosaveHint}
-
-
-
-
-
-
- {currentStepTitle ?
{currentStepTitle}
: null}
- {step === 1 && (
- {
- patchData(patch)
- setStep(2)
- }}
- />
- )}
-
- {step === 2 && (
- {
- setStep(1)
- }}
- onChange={patchData}
- onNext={(patch) => {
- patchData(patch)
- setStep(3)
- }}
- />
- )}
-
- {step === 3 && (
- {
- setStep(2)
- }}
- onChange={patchData}
- onNext={(patch) => {
- patchData(patch)
- setStep(4)
- }}
- />
- )}
-
- {step === 4 && (
- {
- setStep(3)
- }}
- onChange={patchData}
- onSaveDraft={() => void handleSaveDraft()}
- />
- )}
-
-
- {createdEventId ? (
-
{
- router.push(finishRoute)
- }}
- onOpenChange={setShowPostCreateInvite}
- />
- ) : null}
-
- )
-}
diff --git a/components/events/create/TicketTypeSelector.tsx b/components/events/create/TicketTypeSelector.tsx
deleted file mode 100644
index 5f2dca9..0000000
--- a/components/events/create/TicketTypeSelector.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-'use client'
-
-import { Tabs } from '@heroui/react'
-
-import { texts } from '@/texts'
-
-interface TicketTypeSelectorProps {
- isFree: boolean
- disabled?: boolean
- onChange: (isFree: boolean) => void
-}
-
-const TICKET_TYPE_TABS = [
- ['free', texts.events.free],
- ['paid', texts.events.ticketPaid],
-] as const
-
-/** Segmented free/paid ticket selection used by the consumer event wizard. */
-export default function TicketTypeSelector({ isFree, disabled = false, onChange }: TicketTypeSelectorProps) {
- return (
- {
- if (disabled) return
- onChange(key === 'free')
- }}
- >
- {/* HeroUI tab default is h-8; list has p-1 → use !h-[46px] so total track = 54px */}
-
-
- {TICKET_TYPE_TABS.map(([tab, label]) => (
-
- {label}
-
-
- ))}
-
-
-
- )
-}
diff --git a/components/events/create/WizardStepper.tsx b/components/events/create/WizardStepper.tsx
deleted file mode 100644
index 189d49f..0000000
--- a/components/events/create/WizardStepper.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-'use client'
-
-import { cn } from '@/lib/cn'
-import { WIZARD_STEPS } from '@/components/events/create/types'
-import { texts, format } from '@/texts'
-
-interface WizardStepperProps {
- currentStep: number
- className?: string
-}
-
-function StepCircle({ step, isActive, isDone }: { step: number; isActive: boolean; isDone: boolean }) {
- return (
-
- {isDone && !isActive ? (
-
- ) : (
- step
- )}
-
- )
-}
-
-export default function WizardStepper({ currentStep, className }: WizardStepperProps) {
- const current = WIZARD_STEPS.find((step) => step.key === currentStep)
- const progress = Math.round((currentStep / WIZARD_STEPS.length) * 100)
-
- return (
-
- )
-}
diff --git a/components/events/create/cloneFromEvent.test.ts b/components/events/create/cloneFromEvent.test.ts
deleted file mode 100644
index a908197..0000000
--- a/components/events/create/cloneFromEvent.test.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { describe, expect, it, vi } from 'vitest'
-
-import type { CreatedEvent } from '@/services/events'
-import type { EventFaq, EventMedia } from '@/services/eventDetail'
-import { mapEventToWizardCloneData } from '@/components/events/create/cloneFromEvent'
-
-vi.stubGlobal('crypto', {
- randomUUID: () => 'test-uuid',
-})
-
-const baseEvent = {
- id: 'evt-1',
- organizerId: 'user-1',
- categoryId: 3,
- clonedFromEventId: null,
- title: 'دورهمی هفتگی',
- slug: 'weekly-meetup',
- shortDescription: 'خلاصه',
- description: 'توضیح
',
- startsAt: '2030-06-01T10:00:00.000Z',
- endsAt: '2030-06-01T12:30:00.000Z',
- provinceId: 8,
- cityId: 301,
- address: 'تهران',
- lat: 35.7,
- lng: 51.4,
- isFree: false,
- price: 150000,
- capacity: 40,
- reservedCapacity: 3,
- bookedCount: 5,
- cancellationFeePercent: 10,
- cancellationFeePercent12To24Hours: 10,
- cancellationFeePercentMoreThan24Hours: 10,
- commissionPercent: null,
- effectiveCommissionPercent: 7,
- status: 'published',
- isFeatured: false,
- settings: {
- isDiscoverable: true,
- autoCreateGroup: true,
- addressVisibility: 'public',
- generalArea: null,
- waitlistAutoOffer: true,
- sendReviewRequestSms: false,
- },
- discoveryPlacements: [],
- adminApprovedAt: null,
- rejectionReason: null,
- avgRating: null,
- reviewsCount: 0,
- publishedAt: '2030-01-01T00:00:00.000Z',
- createdAt: '2030-01-01T00:00:00.000Z',
- updatedAt: '2030-01-01T00:00:00.000Z',
- posterUrl: null,
-} as unknown as CreatedEvent
-
-describe('mapEventToWizardCloneData', () => {
- it('prefills wizard fields from the source event', () => {
- const media: EventMedia[] = [
- {
- id: 'm1',
- eventId: 'evt-1',
- mediaType: 'image',
- url: 'https://cdn.example/poster.jpg',
- thumbnailUrl: null,
- sortOrder: 0,
- isPoster: true,
- isSquarePoster: false,
- createdAt: '2030-01-01T00:00:00.000Z',
- },
- {
- id: 'm2',
- eventId: 'evt-1',
- mediaType: 'image',
- url: 'https://cdn.example/square.jpg',
- thumbnailUrl: null,
- sortOrder: 1,
- isPoster: false,
- isSquarePoster: true,
- createdAt: '2030-01-01T00:00:00.000Z',
- },
- ]
- const faqs: EventFaq[] = [
- {
- id: 'f1',
- eventId: 'evt-1',
- question: 'چه زمانی؟',
- answer: 'شنبه',
- sortOrder: 0,
- createdAt: '2030-01-01T00:00:00.000Z',
- updatedAt: '2030-01-01T00:00:00.000Z',
- },
- ]
-
- const result = mapEventToWizardCloneData(baseEvent, media, faqs)
-
- expect(result.title).toBe('دورهمی هفتگی (کپی)')
- expect(result.slug).toBe('weekly-meetup')
- expect(result.categoryId).toBe('3')
- expect(result.cityId).toBe('301')
- expect(result.capacity).toBe(40)
- expect(result.reservedCapacity).toBe(3)
- expect(result.media).toHaveLength(2)
- expect(result.media[0]?.url).toBe('https://cdn.example/poster.jpg')
- expect(result.media[1]?.url).toBe('https://cdn.example/square.jpg')
- expect(result.faqs).toEqual([{ id: 'test-uuid', question: 'چه زمانی؟', answer: 'شنبه' }])
- })
-})
diff --git a/components/events/create/cloneFromEvent.ts b/components/events/create/cloneFromEvent.ts
deleted file mode 100644
index a4548e1..0000000
--- a/components/events/create/cloneFromEvent.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import type { EventWizardFormData } from '@/components/events/create/types'
-import { createClientId } from '@/lib/createClientId'
-import type { CreatedEvent } from '@/services/events'
-import type { EventFaq, EventMedia } from '@/services/eventDetail'
-import { texts, format } from '@/texts'
-
-function splitIsoToDateAndMinutes(iso: string): { date: string; minutes: number } {
- const d = new Date(iso)
-
- return {
- date: d.toISOString(),
- minutes: d.getHours() * 60 + d.getMinutes(),
- }
-}
-
-/**
- * Maps an existing event (plus media/FAQs) into create-wizard form values
- * so the host can tweak and submit a new event.
- */
-export function mapEventToWizardCloneData(event: CreatedEvent, media: EventMedia[], faqs: EventFaq[]): EventWizardFormData {
- const start = splitIsoToDateAndMinutes(String(event.startsAt))
- const end = splitIsoToDateAndMinutes(String(event.endsAt))
-
- return {
- title: format(texts.events.cloneTitleSuffix, { title: event.title }),
- slug: event.slug,
- categoryId: String(event.categoryId),
- shortDescription: event.shortDescription ?? '',
- description: event.description ?? '',
- media: media
- .filter((item) => item.mediaType === 'image')
- .map((item, index) => ({
- id: createClientId(),
- url: item.url,
- isPoster: item.isPoster,
- isSquarePoster: Boolean(item.isSquarePoster),
- sortOrder: item.sortOrder ?? index,
- })),
- startDate: start.date,
- startTime: start.minutes,
- endDate: end.date,
- endTime: end.minutes,
- provinceId: String(event.provinceId),
- cityId: String(event.cityId),
- // Cloning always starts from the organizer's own event, so the backend
- // never masks address/lat/lng here (see EventsService.applyAddressVisibility)
- // -- the `?? ` fallbacks only satisfy the DTO's general nullable type.
- address: event.address ?? '',
- lat: event.lat ?? 35.6892,
- lng: event.lng ?? 51.389,
- isFree: event.isFree,
- price: event.price,
- capacity: event.capacity,
- reservedCapacity: event.reservedCapacity ?? 0,
- 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,
- generalArea: event.settings.generalArea ?? '',
- waitlistAutoOffer: event.settings.waitlistAutoOffer,
- sendReviewRequestSms: event.settings.sendReviewRequestSms ?? true,
- faqs: faqs.map((faq) => ({
- id: createClientId(),
- question: faq.question,
- answer: faq.answer,
- })),
- }
-}
diff --git a/components/events/create/steps/Step1BasicInfo.tsx b/components/events/create/steps/Step1BasicInfo.tsx
deleted file mode 100644
index 3a7ca2b..0000000
--- a/components/events/create/steps/Step1BasicInfo.tsx
+++ /dev/null
@@ -1,184 +0,0 @@
-'use client'
-
-import { zodResolver } from '@hookform/resolvers/zod'
-import { useEffect, useRef } from 'react'
-import dynamic from 'next/dynamic'
-import { FormProvider, useForm } from 'react-hook-form'
-
-import { texts, format } from '@/texts'
-import type { EventWizardFormData } from '@/components/events/create/types'
-import { hasRequiredPosters } from '@/components/events/create/types'
-import { addToast } from '@/lib/toast'
-import Input from '@/components/formElements/Input'
-import EventMediaGalleryUploader from '@/components/events/create/EventMediaGalleryUploader'
-import CategoryTreeSelect from '@/components/events/create/CategoryTreeSelect'
-import { WizardFormFullWidth, WizardFormGrid, WizardSectionTitle, WizardStepFooter } from '@/components/events/create/WizardFormLayout'
-import { EventWizardStep1Validation, type EventWizardStep1Values } from '@/validation/eventWizard'
-import { shouldAutogenerateEventSlug, slugifyEventTitle } from '@/features/events/eventSlug'
-import { usePublicCategoriesQuery } from '@/queries/consumer/usePublicCategoriesQuery'
-import { showFormValidationToast } from '@/lib/formValidationToast'
-
-const TextEditor = dynamic(() => import('@/components/formElements/TextEditor'), { ssr: false })
-
-interface Step1BasicInfoProps {
- data: EventWizardFormData
- onChange: (patch: Partial) => void
- onNext: (patch: Partial) => void
-}
-
-export default function Step1BasicInfo({ data, onChange, onNext }: Step1BasicInfoProps) {
- const categoriesQuery = usePublicCategoriesQuery()
- const categories = categoriesQuery.data?.items ?? []
- const form = useForm({
- resolver: zodResolver(EventWizardStep1Validation),
- defaultValues: {
- title: data.title,
- slug: data.slug,
- categoryId: data.categoryId,
- shortDescription: data.shortDescription,
- description: data.description,
- },
- })
-
- const followTitleRef = useRef(shouldAutogenerateEventSlug(data.slug, data.title))
- const lastAutoSlugRef = useRef(data.slug || slugifyEventTitle(data.title))
- const title = form.watch('title')
- const slug = form.watch('slug')
-
- useEffect(() => {
- if (categoriesQuery.isError) {
- addToast({ title: texts.events.categoriesLoadFailed, color: 'danger' })
- }
- }, [categoriesQuery.isError])
-
- useEffect(() => {
- if (!followTitleRef.current) return
-
- const next = slugifyEventTitle(title ?? '')
-
- lastAutoSlugRef.current = next
- if (form.getValues('slug') !== next) {
- form.setValue('slug', next)
- }
- }, [form, title])
-
- useEffect(() => {
- const subscription = form.watch((values, info) => {
- if (info.name === 'slug' && (values.slug ?? '') !== lastAutoSlugRef.current) {
- followTitleRef.current = false
- }
-
- onChange({
- title: values.title ?? '',
- slug: values.slug ?? '',
- categoryId: values.categoryId ?? '',
- shortDescription: values.shortDescription ?? '',
- description: values.description ?? '',
- })
- })
-
- return () => {
- subscription.unsubscribe()
- }
- }, [form, onChange])
-
- const handleSubmit = form.handleSubmit((values) => {
- if (!hasRequiredPosters(data.media)) {
- addToast({
- title: texts.events.postersRequiredTitle,
- description: texts.events.postersRequiredShort,
- color: 'danger',
- })
-
- return
- }
-
- onNext({
- title: values.title,
- slug: values.slug,
- categoryId: values.categoryId,
- shortDescription: values.shortDescription ?? '',
- description: values.description ?? '',
- media: data.media,
- })
- }, showFormValidationToast)
-
- return (
-
-
-
- )
-}
diff --git a/components/events/create/steps/Step2ScheduleLocation.tsx b/components/events/create/steps/Step2ScheduleLocation.tsx
deleted file mode 100644
index e45ef5d..0000000
--- a/components/events/create/steps/Step2ScheduleLocation.tsx
+++ /dev/null
@@ -1,244 +0,0 @@
-'use client'
-
-import { zodResolver } from '@hookform/resolvers/zod'
-import { useEffect, useRef, useState } from 'react'
-import { FormProvider, useForm, useWatch } from 'react-hook-form'
-
-import { texts } from '@/texts'
-import type { EventWizardFormData } from '@/components/events/create/types'
-import Input from '@/components/formElements/Input'
-import MapLocationPicker from '@/components/events/create/MapLocationPicker'
-import { WizardFormFullWidth, WizardFormGrid, WizardSectionTitle, WizardStepFooter } from '@/components/events/create/WizardFormLayout'
-import { INITIAL_WIZARD_DATA } from '@/components/events/create/types'
-import { useEventScheduleConstraints } from '@/components/events/schedule/eventScheduleConstraints'
-import { addToast } from '@/lib/toast'
-import { fetchAllCities, type City } from '@/services/geography'
-import { EventWizardStep2Validation, type EventWizardStep2Values } from '@/validation/eventWizard'
-import { showFormValidationToast } from '@/lib/formValidationToast'
-
-const ADDRESS_VISIBILITY_OPTIONS = [
- { name: texts.events.addressVisibilityPublic, selectKey: 'public' },
- { name: texts.events.addressVisibilityAttendeesOnly, selectKey: 'attendees_only' },
-]
-
-interface Step2ScheduleLocationProps {
- data: EventWizardFormData
- onBack: () => void
- onChange: (patch: Partial) => void
- onNext: (patch: Partial) => void
-}
-
-export default function Step2ScheduleLocation({ data, onBack, onChange, onNext }: Step2ScheduleLocationProps) {
- const [cities, setCities] = useState([])
-
- const form = useForm({
- resolver: zodResolver(EventWizardStep2Validation),
- defaultValues: {
- startDate: data.startDate,
- startTime: data.startTime,
- endDate: data.endDate,
- endTime: data.endTime,
- provinceId: data.provinceId,
- cityId: data.cityId,
- address: data.address,
- addressVisibility: data.addressVisibility,
- generalArea: data.generalArea,
- },
- })
-
- const addressVisibility = form.watch('addressVisibility')
- const addressValue = useWatch({ control: form.control, name: 'address' })
-
- // اگر کاربر قبلاً موقعیت را روی نقشه دستی مشخص کرده (مثلاً هنگام کپی یا ویرایش رویداد)،
- // با تغییر شهر نباید آن پین بازنویسی شود.
- const hasManualLocationRef = useRef(data.lat !== INITIAL_WIZARD_DATA.lat || data.lng !== INITIAL_WIZARD_DATA.lng)
- const selectedCityId = useWatch({ control: form.control, name: 'cityId' })
- const startDate = useWatch({ control: form.control, name: 'startDate' })
- const startTime = useWatch({ control: form.control, name: 'startTime' })
- const endDate = useWatch({ control: form.control, name: 'endDate' })
- const { todayIso, startMinimumTime, endMinimumTime } = useEventScheduleConstraints(startDate, startTime, endDate)
-
- useEffect(() => {
- fetchAllCities()
- .then(setCities)
- .catch(() => addToast({ title: texts.events.citiesLoadFailed, color: 'danger' }))
- }, [])
-
- useEffect(() => {
- if (hasManualLocationRef.current) return
-
- const selectedCity = cities.find((city) => String(city.id) === selectedCityId)
-
- if (selectedCity?.lat != null && selectedCity?.lng != null) {
- onChange({ lat: selectedCity.lat, lng: selectedCity.lng })
- }
- }, [cities, onChange, selectedCityId])
-
- useEffect(() => {
- const subscription = form.watch((values) => {
- onChange({
- startDate: values.startDate ?? '',
- startTime: values.startTime ?? data.startTime,
- endDate: values.endDate ?? '',
- endTime: values.endTime ?? data.endTime,
- provinceId: values.provinceId ?? '',
- cityId: values.cityId ?? '',
- address: values.address ?? '',
- addressVisibility: values.addressVisibility ?? 'public',
- generalArea: values.generalArea ?? '',
- })
- })
-
- return () => {
- subscription.unsubscribe()
- }
- }, [data.endTime, data.startTime, form, onChange])
-
- const handleSubmit = form.handleSubmit((values) => {
- const selectedCity = cities.find((city) => String(city.id) === values.cityId)
-
- if (!selectedCity) {
- form.setError('cityId', { message: texts.validation.cityRequired })
- addToast({ title: texts.common.formIncomplete, description: texts.validation.cityRequired, color: 'danger' })
-
- return
- }
-
- onNext({
- startDate: values.startDate,
- startTime: values.startTime,
- endDate: values.endDate,
- endTime: values.endTime,
- provinceId: String(selectedCity.provinceId),
- cityId: values.cityId,
- address: values.address,
- lat: data.lat,
- lng: data.lng,
- addressVisibility: values.addressVisibility,
- generalArea: values.addressVisibility === 'attendees_only' ? (values.generalArea?.trim() ?? '') : '',
- })
- }, showFormValidationToast)
-
- return (
-
-
-
- )
-}
diff --git a/components/events/create/steps/Step3PricingCapacity.tsx b/components/events/create/steps/Step3PricingCapacity.tsx
deleted file mode 100644
index d41ad15..0000000
--- a/components/events/create/steps/Step3PricingCapacity.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-'use client'
-
-import { zodResolver } from '@hookform/resolvers/zod'
-import { useEffect } from 'react'
-import { FormProvider, useForm } from 'react-hook-form'
-
-import { texts } from '@/texts'
-import type { EventWizardFormData } from '@/components/events/create/types'
-import AudienceRestrictionSelector from '@/components/events/create/AudienceRestrictionSelector'
-import CancellationPolicyEditor from '@/components/events/create/CancellationPolicyEditor'
-import TicketTypeSelector from '@/components/events/create/TicketTypeSelector'
-import Input from '@/components/formElements/Input'
-import { WizardFormFullWidth, WizardFormGrid, WizardSectionTitle, WizardStepFooter } from '@/components/events/create/WizardFormLayout'
-import { EventWizardStep3Validation, type EventWizardStep3Values } from '@/validation/eventWizard'
-import { showFormValidationToast } from '@/lib/formValidationToast'
-
-interface Step3PricingCapacityProps {
- data: EventWizardFormData
- onBack: () => void
- onChange: (patch: Partial) => void
- onNext: (patch: Partial) => void
-}
-
-export default function Step3PricingCapacity({ data, onBack, onChange, onNext }: Step3PricingCapacityProps) {
- const form = useForm({
- resolver: zodResolver(EventWizardStep3Validation),
- defaultValues: {
- isFree: data.isFree,
- price: data.price,
- capacity: data.capacity,
- reservedCapacity: data.reservedCapacity,
- genderRestriction: data.genderRestriction,
- ageRestriction: data.ageRestriction,
- cancellationFeePercent: data.cancellationFeePercent,
- cancellationFeePercent12To24Hours: data.cancellationFeePercent12To24Hours,
- cancellationFeePercentMoreThan24Hours: data.cancellationFeePercentMoreThan24Hours,
- isDiscoverable: data.isDiscoverable,
- autoCreateGroup: data.autoCreateGroup,
- waitlistAutoOffer: data.waitlistAutoOffer,
- sendReviewRequestSms: data.sendReviewRequestSms,
- },
- })
-
- const isFree = form.watch('isFree')
-
- useEffect(() => {
- const subscription = form.watch((values) => {
- onChange({
- isFree: values.isFree ?? false,
- price: Number(values.price ?? 0),
- capacity: Number(values.capacity ?? 0),
- reservedCapacity: Number(values.reservedCapacity ?? 0),
- 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,
- sendReviewRequestSms: values.sendReviewRequestSms ?? false,
- })
- })
-
- return () => {
- subscription.unsubscribe()
- }
- }, [form, onChange])
-
- const handleSubmit = form.handleSubmit((values) => {
- onNext({
- isFree: values.isFree,
- price: values.isFree ? 0 : values.price,
- capacity: values.capacity,
- reservedCapacity: values.reservedCapacity,
- genderRestriction: values.genderRestriction,
- ageRestriction: values.ageRestriction,
- cancellationFeePercent: values.cancellationFeePercent,
- cancellationFeePercent12To24Hours: values.cancellationFeePercent12To24Hours,
- cancellationFeePercentMoreThan24Hours: values.cancellationFeePercentMoreThan24Hours,
- isDiscoverable: values.isDiscoverable,
- autoCreateGroup: values.autoCreateGroup,
- waitlistAutoOffer: values.waitlistAutoOffer,
- sendReviewRequestSms: values.sendReviewRequestSms,
- })
- }, showFormValidationToast)
-
- return (
-
-
-
- )
-}
diff --git a/components/events/create/steps/Step4FaqPreview.tsx b/components/events/create/steps/Step4FaqPreview.tsx
deleted file mode 100644
index b7da1db..0000000
--- a/components/events/create/steps/Step4FaqPreview.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-'use client'
-
-import { texts } from '@/texts'
-import CreateEventPreviewCard from '@/components/events/create/CreateEventPreviewCard'
-import FaqEditor from '@/components/events/create/FaqEditor'
-import { WizardFormFullWidth, WizardFormGrid, WizardSectionTitle, WizardStepFooter } from '@/components/events/create/WizardFormLayout'
-import { combineDateAndMinutes, formatWizardDate, type EventWizardFormData } from '@/components/events/create/types'
-
-interface Step4FaqPreviewProps {
- data: EventWizardFormData
- categoryName?: string
- // استان فعلاً در پیشنمایش نمایش داده نمیشود.
- // provinceName?: string
- cityName?: string
- isSaving: boolean
- onBack: () => void
- onChange: (patch: Partial) => void
- onSaveDraft: () => void
-}
-
-export default function Step4FaqPreview({
- data,
- categoryName,
- // provinceName,
- cityName,
- isSaving,
- onBack,
- onChange,
- onSaveDraft,
-}: Step4FaqPreviewProps) {
- let startsAtPreview = '—'
- let endsAtPreview = '—'
-
- try {
- if (data.startDate) {
- startsAtPreview = formatWizardDate(combineDateAndMinutes(data.startDate, data.startTime))
- }
- if (data.endDate) {
- endsAtPreview = formatWizardDate(combineDateAndMinutes(data.endDate, data.endTime))
- }
- } catch {
- // preview only
- }
-
- return (
-
- {texts.events.previewEventTitle}
-
-
-
-
-
-
-
-
{texts.events.readyToSave}
-
- - ✓ {texts.events.checklistIntroDone}
- - ✓ {texts.events.checklistScheduleDone}
- - ✓ {texts.events.checklistPricingDone}
- - ✓ {texts.events.checklistPosterDone}
-
-
-
-
- {texts.events.faqs}
-
-
- {
- onChange({ faqs })
- }}
- />
-
-
-
-
- )
-}
diff --git a/components/events/create/steps/Step5GuestList.tsx b/components/events/create/steps/Step5GuestList.tsx
deleted file mode 100644
index d905451..0000000
--- a/components/events/create/steps/Step5GuestList.tsx
+++ /dev/null
@@ -1,161 +0,0 @@
-'use client'
-
-import { useEffect, useMemo, useState } from 'react'
-
-import Modal from '@/components/modals/Modal'
-import Input from '@/components/formElements/Input'
-import SearchAltIcon from '@/components/icons/SearchAltIcon'
-import { coerceToString, formatPersonName } from '@/helpers'
-import { addToast } from '@/lib/toast'
-import { fetchPreviousAttendees, setPreviousAttendeeGuests, type PreviousAttendee } from '@/services/events'
-import { texts } from '@/texts'
-
-interface Step5GuestListProps {
- isOpen: boolean
- onOpenChange: (open: boolean) => void
- eventId: string
- attendees: PreviousAttendee[]
- onFinish: () => void
-}
-
-export default function Step5GuestList({ isOpen, onOpenChange, eventId, attendees, onFinish }: Step5GuestListProps) {
- const [search, setSearch] = useState('')
- const [selectedIds, setSelectedIds] = useState([])
- const [isLinking, setIsLinking] = useState(false)
-
- useEffect(() => {
- if (!isOpen) return
-
- setSearch('')
- setSelectedIds([])
- }, [isOpen])
-
- const filteredAttendees = useMemo(() => {
- const query = search.trim().toLowerCase()
-
- if (!query) return attendees
-
- return attendees.filter((attendee) => {
- const name = formatPersonName(attendee.firstName, attendee.lastName, '').toLowerCase()
-
- return name.includes(query)
- })
- }, [attendees, search])
-
- const allFilteredSelected = filteredAttendees.length > 0 && filteredAttendees.every((attendee) => selectedIds.includes(attendee.userId))
-
- const toggleAttendee = (userId: string, checked: boolean) => {
- setSelectedIds((prev) => (checked ? [...prev, userId] : prev.filter((id) => id !== userId)))
- }
-
- const toggleSelectAll = (checked: boolean) => {
- const filteredIds = filteredAttendees.map((attendee) => attendee.userId)
-
- if (checked) {
- setSelectedIds((prev) => Array.from(new Set([...prev, ...filteredIds])))
-
- return
- }
-
- setSelectedIds((prev) => prev.filter((id) => !filteredIds.includes(id)))
- }
-
- const handleSendInvitations = async () => {
- if (selectedIds.length === 0) {
- onOpenChange(false)
- onFinish()
-
- return
- }
-
- setIsLinking(true)
-
- try {
- await setPreviousAttendeeGuests(eventId, selectedIds)
- addToast({ title: texts.events.guestsSaved, color: 'success' })
- onOpenChange(false)
- onFinish()
- } catch {
- addToast({ title: texts.events.guestsSaveFailed, color: 'danger' })
- } finally {
- setIsLinking(false)
- }
- }
-
- return (
- void handleSendInvitations()}
- onClose={onFinish}
- onOpenChange={onOpenChange}
- >
-
-
{texts.events.invitePreviousGuestsDescription}
-
-
}
- name="guestSearch"
- placeholder={texts.events.guestSearchPlaceholder}
- value={search}
- onClear={() => {
- setSearch('')
- }}
- onValueChange={(next) => {
- setSearch(coerceToString(next))
- }}
- />
-
-
{
- toggleSelectAll(Boolean(checked))
- }}
- />
-
-
- {filteredAttendees.length === 0 ? (
-
{texts.common.emptyChoice}
- ) : (
- filteredAttendees.map((attendee) => (
-
- {
- toggleAttendee(attendee.userId, Boolean(checked))
- }}
- />
-
- ))
- )}
-
-
-
- )
-}
-
-export async function loadPreviousAttendeesForStep5(): Promise {
- try {
- return await fetchPreviousAttendees()
- } catch {
- return []
- }
-}
diff --git a/components/events/create/types.ts b/components/events/create/types.ts
index 38ca830..714a5d6 100644
--- a/components/events/create/types.ts
+++ b/components/events/create/types.ts
@@ -1,7 +1,4 @@
-import { formatNumber } from '@/helpers'
-import { formatPersianDate } from '@/lib/formatters'
-import type { EventAgeRestriction, EventGenderRestriction } from '@/lib/eventAudience'
-import { texts, format } from '@/texts'
+import { texts } from '@/texts'
export interface StagedMediaItem {
id: string
@@ -21,85 +18,6 @@ export interface FaqItem {
answer: string
}
-export interface EventWizardFormData {
- title: string
- slug: string
- categoryId: string
- shortDescription: string
- description: string
- media: StagedMediaItem[]
- startDate: string
- startTime: number
- endDate: string
- endTime: number
- provinceId: string
- cityId: string
- address: string
- lat: number
- lng: number
- isFree: boolean
- price: number
- capacity: number
- reservedCapacity: number
- genderRestriction: EventGenderRestriction
- ageRestriction: EventAgeRestriction
- cancellationFeePercent: number
- cancellationFeePercent12To24Hours: number
- cancellationFeePercentMoreThan24Hours: number
- isDiscoverable: boolean
- autoCreateGroup: boolean
- addressVisibility: 'public' | 'attendees_only'
- generalArea: string
- waitlistAutoOffer: boolean
- sendReviewRequestSms: boolean
- faqs: FaqItem[]
-}
-
-export const INITIAL_WIZARD_DATA: EventWizardFormData = {
- title: '',
- slug: '',
- categoryId: '',
- shortDescription: '',
- description: '',
- media: [],
- startDate: '',
- startTime: 600,
- endDate: '',
- endTime: 720,
- provinceId: '',
- cityId: '',
- address: '',
- lat: 35.6892,
- lng: 51.389,
- isFree: false,
- price: 0,
- capacity: 20,
- reservedCapacity: 0,
- genderRestriction: 'open',
- ageRestriction: 'open',
- 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
- // wants it visible once published. Explicitly overridable below.
- isDiscoverable: true,
- autoCreateGroup: true,
- addressVisibility: 'public',
- generalArea: '',
- waitlistAutoOffer: true,
- sendReviewRequestSms: true,
- faqs: [],
-}
-
-export const WIZARD_STEPS = [
- { key: 1, label: texts.events.wizardStep1Label },
- { key: 2, label: texts.events.wizardStep2Label },
- { key: 3, label: texts.events.wizardStep3Label },
- { key: 4, label: texts.events.wizardStep4Label },
-] as const
-
export function combineDateAndMinutes(dateIso: string, minutes: number): string {
const date = new Date(dateIso)
@@ -114,18 +32,3 @@ export function combineDateAndMinutes(dateIso: string, minutes: number): string
return date.toISOString()
}
-
-export function formatWizardDate(value: string): string {
- return formatPersianDate(value)
-}
-
-export function formatToman(value: number): string {
- return format(texts.events.formatToman, { value: formatNumber(value) })
-}
-
-export function formatMinutesAsTime(minutes: number): string {
- const hours = Math.floor(minutes / 60)
- const mins = minutes % 60
-
- return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`
-}
diff --git a/constants/routes.test.ts b/constants/routes.test.ts
index 62a27ed..1413adf 100644
--- a/constants/routes.test.ts
+++ b/constants/routes.test.ts
@@ -5,7 +5,6 @@ import { APP_ROUTES, CONSUMER_ROUTES, SEO_ROUTES } from '@/constants/routes'
describe('event route ownership', () => {
it('keeps admin event workflows under /manage-events', () => {
expect(APP_ROUTES.MANAGE_EVENTS).toBe('/manage-events')
- expect(APP_ROUTES.MANAGE_EVENT_NEW).toBe('/manage-events/new')
expect(APP_ROUTES.MANAGE_EVENT_DETAIL('event/id')).toBe('/manage-events/event%2Fid')
expect(APP_ROUTES.MANAGE_EVENT_EDIT('event/id')).toBe('/manage-events/event%2Fid/edit')
})
diff --git a/constants/routes.ts b/constants/routes.ts
index fc07d6f..e0a3fb4 100644
--- a/constants/routes.ts
+++ b/constants/routes.ts
@@ -3,7 +3,6 @@ export const APP_ROUTES = {
USERS: '/users',
USER_DETAIL: (id: string) => `/users/${id}`,
MANAGE_EVENTS: '/manage-events',
- MANAGE_EVENT_NEW: '/manage-events/new',
MANAGE_EVENT_DETAIL: (id: string) => `/manage-events/${encodeURIComponent(id)}`,
MANAGE_EVENT_DISCOUNTS: (id: string) => `/manage-events/${encodeURIComponent(id)}?tab=discounts`,
MANAGE_EVENT_EDIT: (id: string) => `/manage-events/${encodeURIComponent(id)}/edit`,
diff --git a/context/AuthContext.tsx b/context/AuthContext.tsx
index a3611fd..7805fc8 100644
--- a/context/AuthContext.tsx
+++ b/context/AuthContext.tsx
@@ -1,8 +1,7 @@
'use client'
-import { hashKey } from '@tanstack/react-query'
-import { jwtDecode } from 'jwt-decode'
import { createContext, type ReactNode, useCallback, useEffect, useState } from 'react'
+import { jwtDecode } from 'jwt-decode'
import Cookies from 'js-cookie'
import type {
@@ -21,13 +20,12 @@ import { parseRefreshExpiry, REFRESH_COOKIE_DAYS } from '@/lib/refreshTokenExpir
import { convertPersianToEnglish } from '@/helpers'
import { texts } from '@/texts'
import { REQUEST_OTP, VERIFY_OTP } from '@/services/auth'
-import { COMPLETE_PROFILE, type UserProfile } from '@/services/users'
+import { COMPLETE_PROFILE } from '@/services/users'
import { unsubscribeCurrentDevicePush } from '@/services/push'
import axiosInstance, { refreshAccessTokenProactively } from '@/config/axios'
import useLoading from '@/hooks/useLoading'
import { clearAsyncResourceCache } from '@/hooks/useAsyncResource'
import { clearPersistedQueryCache, getQueryClient } from '@/lib/queryClient'
-import { consumerKeys } from '@/queries/consumerKeys'
import { setAccountSuspended } from '@/lib/accountSuspension'
export const AuthContext = createContext(undefined)
@@ -334,38 +332,6 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
})
}, [])
- /**
- * `user` (this context) and the `consumerKeys.me()` query cache are two
- * copies of the same profile fields — one bootstraps synchronously from
- * localStorage, the other is the fresh server copy consumer pages read via
- * `useMeQuery`. Rather than every mutation site remembering to patch both
- * (easy to forget — profile/contact/page.tsx didn't, before this), mirror
- * `me()` into `user` in one place whenever the query cache changes, however
- * it changed (setQueryData, a refetch after invalidate, background refetch).
- */
- useEffect(() => {
- const queryClient = getQueryClient()
- const meQueryHash = hashKey(consumerKeys.me())
-
- return queryClient.getQueryCache().subscribe((event) => {
- if (event.type !== 'updated' || event.query.queryHash !== meQueryHash) return
-
- const profile = event.query.state.data as UserProfile | undefined
-
- if (!profile) return
-
- updateUserFromOutside({
- firstName: profile.firstName,
- lastName: profile.lastName,
- gender: profile.gender,
- cityId: profile.cityId,
- avatarUrl: profile.avatarUrl,
- bio: profile.bio,
- defaultAddress: profile.defaultAddress,
- })
- })
- }, [updateUserFromOutside])
-
return (
{
@@ -164,62 +157,3 @@ test('edits discoverability and publishes a draft event', async ({ page }) => {
await expect(page.getByText('منتشرشده', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: 'انتشار فوری' })).toHaveCount(0)
})
-
-test('creates a complete event draft through the multi-step wizard', async ({ page }) => {
- let createPayload: Record | undefined
-
- await page.route('**/api/v1/event-categories**', (route) =>
- route.fulfill({
- json: {
- success: true,
- data: [{ id: 10, parentId: null, name: 'ورزشی', slug: 'sports', sortOrder: 1 }],
- },
- })
- )
- await page.route('**/api/v1/geography/cities**', (route) =>
- route.fulfill({ json: { success: true, data: [{ id: 2, provinceId: 1, name: 'تهران', slug: 'tehran', lat: 35.7, lng: 51.4 }] } })
- )
- await page.route('**/api/v1/events', async (route) => {
- if (route.request().method() !== 'POST') {
- await route.fulfill({ json: { success: true, data: { events: [], response: { totalItemsCount: 0 } } } })
-
- return
- }
- createPayload = route.request().postDataJSON() as Record
- await route.fulfill({ json: { success: true, data: { id: 'created-event', title: createPayload.title, status: 'draft' } } })
- })
- await page.route('**/api/v1/organizer-guest-lists?**', (route) => route.fulfill({ json: { success: true, data: { items: [] } } }))
-
- await page.goto('/manage-events/new')
- await page.getByLabel('عنوان رویداد').fill('دورهمی تست تولید')
- await page.getByRole('button', { name: 'دستهبندی' }).click()
- await page.getByRole('option', { name: 'ورزشی' }).click()
- await page.getByRole('button', { name: 'مرحله بعد' }).click()
-
- await confirmEventScheduleField(page, 'تاریخ شروع')
- await confirmEventScheduleField(page, 'تاریخ پایان')
- await page.getByRole('button', { name: 'شهر' }).click()
- await page.getByRole('option', { name: 'تهران' }).click()
- await page.getByRole('textbox', { name: 'آدرس *' }).fill('تهران، میدان آزادی')
- await page.getByRole('button', { name: 'مرحله بعد' }).click()
-
- await page.getByRole('button', { name: 'رایگان' }).click()
- await page.getByRole('textbox', { name: 'ظرفیت *', exact: true }).fill('50')
- await page.getByRole('button', { name: 'مرحله بعد' }).click()
- await page.getByRole('button', { name: 'ذخیره پیشنویس' }).click()
-
- await expect(page).toHaveURL(/\/manage-events$/)
- expect(createPayload).toMatchObject({
- title: 'دورهمی تست تولید',
- slug: expect.stringMatching(/^event-/),
- categoryId: 10,
- provinceId: 1,
- cityId: 2,
- address: 'تهران، میدان آزادی',
- isFree: true,
- price: 0,
- capacity: 50,
- settings: { isDiscoverable: true, autoCreateGroup: true, waitlistAutoOffer: true, addressVisibility: 'public' },
- })
- expect(new Date(String(createPayload?.endsAt)).getTime()).toBeGreaterThan(new Date(String(createPayload?.startsAt)).getTime())
-})
diff --git a/features/events/create/AdminEventCreate.tsx b/features/events/create/AdminEventCreate.tsx
deleted file mode 100644
index c4b77f5..0000000
--- a/features/events/create/AdminEventCreate.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-'use client'
-
-import dynamic from 'next/dynamic'
-import { Suspense } from 'react'
-
-import PageNavbar from '@/components/layouts/PageNavbar'
-import { DetailSkeleton } from '@/components/feedback/LoadingState'
-
-const EventCreateWizard = dynamic(() => import('@/components/events/create/EventCreateWizard'), {
- ssr: false,
- loading: () => ,
-})
-
-const AdminEventCreate = () => {
- return (
-
- )
-}
-
-export default AdminEventCreate
diff --git a/features/events/detail/AdminEventDetail.tsx b/features/events/detail/AdminEventDetail.tsx
index ccb3848..0300ead 100644
--- a/features/events/detail/AdminEventDetail.tsx
+++ b/features/events/detail/AdminEventDetail.tsx
@@ -438,11 +438,7 @@ const AdminEventDetail = () => {
key="guest-lists"
title="مهمانان ویژه"
>
-
+
diff --git a/features/events/detail/admin-event-detail/AdminEventLifecycleActions.tsx b/features/events/detail/admin-event-detail/AdminEventLifecycleActions.tsx
index 8259350..1806b68 100644
--- a/features/events/detail/admin-event-detail/AdminEventLifecycleActions.tsx
+++ b/features/events/detail/admin-event-detail/AdminEventLifecycleActions.tsx
@@ -36,13 +36,6 @@ const AdminEventLifecycleActions = ({
ویرایش
) : null}
-
{event.status === 'draft' && !event.adminApprovedAt ? (
-
+
)
diff --git a/features/events/edit/EventEditForm.test.tsx b/features/events/edit/EventEditForm.test.tsx
index 3b79c08..00824fa 100644
--- a/features/events/edit/EventEditForm.test.tsx
+++ b/features/events/edit/EventEditForm.test.tsx
@@ -11,7 +11,6 @@ const fetchEventCategories = vi.fn()
const fetchAllCities = vi.fn()
const fetchEventMedia = vi.fn()
const fetchEventFaqs = vi.fn()
-const updateEvent = vi.fn()
const updateEventAsAdmin = vi.fn()
vi.mock('next/navigation', () => ({ useRouter: () => ({ push }) }))
@@ -19,11 +18,9 @@ vi.mock('next/dynamic', () => ({
default: () => () => null,
}))
vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) }))
-vi.mock('@/hooks/useAuth', () => ({ default: () => ({ user: { userId: 'user-id' } }) }))
vi.mock('@/services/events', () => ({
fetchEventForEdit: (...args: unknown[]) => fetchEventForEdit(...args),
fetchEventCategories: (...args: unknown[]) => fetchEventCategories(...args),
- updateEvent: (...args: unknown[]) => updateEvent(...args),
updateEventAsAdmin: (...args: unknown[]) => updateEventAsAdmin(...args),
}))
vi.mock('@/services/eventDetail', () => ({
@@ -62,17 +59,13 @@ vi.mock('@/components/events/create/MapLocationPicker', () => ({
default: () => ,
}))
vi.mock('@/components/forms/UnsavedChangesIndicator', () => ({ default: () => null }))
-vi.mock('@/components/forms/ConsumerFormLayout', () => ({
- ConsumerFormSection: ({ title, children }: { title: string; children: React.ReactNode }) => (
+vi.mock('@/components/forms/AdminFormLayout', () => ({
+ AdminFormSection: ({ title, children }: { title: string; children: React.ReactNode }) => (
),
- ConsumerFormActions: ({ children }: { children: React.ReactNode }) => {children}
,
-}))
-vi.mock('@/components/forms/AdminFormLayout', () => ({
- AdminFormSection: ({ children }: { children: React.ReactNode }) => {children}
,
AdminFormActions: ({ children }: { children: React.ReactNode }) => {children}
,
}))
vi.mock('@/components/formElements/Button', () => ({
@@ -194,7 +187,7 @@ describe('EventEditForm', () => {
fetchAllCities.mockResolvedValue([{ id: 2, name: 'تهران', provinceId: 1 }])
fetchEventMedia.mockResolvedValue(requiredMedia)
fetchEventFaqs.mockResolvedValue([])
- updateEvent.mockImplementation(async (_id: string, payload: Record) => ({
+ updateEventAsAdmin.mockImplementation(async (_id: string, payload: Record) => ({
...baseEvent,
...payload,
title: typeof payload.title === 'string' ? payload.title : baseEvent.title,
@@ -260,9 +253,9 @@ describe('EventEditForm', () => {
fireEvent.click(screen.getByRole('button', { name: 'ذخیره تغییرات' }))
await waitFor(() => {
- expect(updateEvent).toHaveBeenCalledOnce()
+ expect(updateEventAsAdmin).toHaveBeenCalledOnce()
})
- const payload = updateEvent.mock.calls[0][1] as Record
+ const payload = updateEventAsAdmin.mock.calls[0][1] as Record
expect(payload.title).toBe('عنوان جدید')
expect(payload).toHaveProperty('slug')
@@ -276,7 +269,6 @@ describe('EventEditForm', () => {
expect(payload).not.toHaveProperty('cancellationFeePercentMoreThan24Hours')
expect(payload).not.toHaveProperty('genderRestriction')
expect(payload).not.toHaveProperty('ageRestriction')
- expect(updateEventAsAdmin).not.toHaveBeenCalled()
expect(push).toHaveBeenCalledWith('/my-events')
})
@@ -304,6 +296,6 @@ describe('EventEditForm', () => {
})
)
})
- expect(updateEvent).not.toHaveBeenCalled()
+ expect(updateEventAsAdmin).not.toHaveBeenCalled()
})
})
diff --git a/features/events/edit/EventEditForm.tsx b/features/events/edit/EventEditForm.tsx
index 4d5c0fb..d2b5b19 100644
--- a/features/events/edit/EventEditForm.tsx
+++ b/features/events/edit/EventEditForm.tsx
@@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from 'react'
import { FormProvider, useForm } from 'react-hook-form'
import { useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
-import clsx from 'clsx'
import { texts, format } from '@/texts'
import type { CreateEventPayload, CreatedEvent } from '@/services/events'
@@ -20,7 +19,6 @@ import { combineDateAndMinutes, hasRequiredPosters } from '@/components/events/c
import { DetailSkeleton } from '@/components/feedback/LoadingState'
import InlineNotice from '@/components/feedback/InlineNotice'
import { AdminFormActions, AdminFormSection } from '@/components/forms/AdminFormLayout'
-import { ConsumerFormActions, ConsumerFormSection } from '@/components/forms/ConsumerFormLayout'
import UnsavedChangesIndicator from '@/components/forms/UnsavedChangesIndicator'
import { useEventScheduleConstraints } from '@/components/events/schedule/eventScheduleConstraints'
import { isEventSlugLocked, isFieldLocked, resolveEventEditPolicy } from '@/features/events/eventEditPolicy'
@@ -28,15 +26,12 @@ import { syncEventFaqs, syncEventMedia } from '@/features/events/edit/syncEventE
import {
fetchEventCategories,
fetchEventForEdit,
- updateEvent,
updateEventAsAdmin,
type EventCategory,
- type EventEditAccessMode,
} from '@/services/events'
import { fetchEventFaqs, fetchEventMedia, type EventFaq, type EventMedia } from '@/services/eventDetail'
import { fetchAllCities, type City } from '@/services/geography'
import { extractServerErrorDetail } from '@/services/errorHandler'
-import useAuth from '@/hooks/useAuth'
import { addToast } from '@/lib/toast'
import { showFormValidationToast } from '@/lib/formValidationToast'
import { AGE_RESTRICTION_OPTIONS, GENDER_RESTRICTION_OPTIONS } from '@/lib/eventAudience'
@@ -80,10 +75,6 @@ const ADDRESS_VISIBILITY_OPTIONS = [
interface EventEditFormProps {
eventId: string
- /** Selects host vs admin mutation APIs; load uses a single for-edit endpoint. */
- accessMode?: EventEditAccessMode
- /** Visual treatment is independent from API/permission mode. */
- layout?: 'admin' | 'consumer'
cancelHref: string
successHref: string
}
@@ -135,13 +126,10 @@ function eventToFormValues(event: CreatedEvent): EventEditFormValues {
}
}
-const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', cancelHref, successHref }: EventEditFormProps) => {
+const EventEditForm = ({ eventId, cancelHref, successHref }: EventEditFormProps) => {
const router = useRouter()
- const hasAdminAccess = accessMode === 'admin'
- const adminLayout = layout === 'admin'
- const FormActions = adminLayout ? AdminFormActions : ConsumerFormActions
- const FormSection = adminLayout ? AdminFormSection : ConsumerFormSection
- const { user } = useAuth()
+ const FormActions = AdminFormActions
+ const FormSection = AdminFormSection
const [event, setEvent] = useState(null)
const [categories, setCategories] = useState([])
const [cities, setCities] = useState([])
@@ -161,9 +149,7 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
const startDate = form.watch('startDate')
const startTime = form.watch('startTime')
const endDate = form.watch('endDate')
- const cityId = form.watch('cityId')
const { todayIso, startMinimumTime, endMinimumTime } = useEventScheduleConstraints(startDate, startTime, endDate)
- const hostCityDisplayName = useMemo(() => cities.find((city) => String(city.id) === cityId)?.name ?? texts.status.empty, [cities, cityId])
const policy = useMemo(
() => (event ? resolveEventEditPolicy(event.status, event.bookedCount, event.reservedCapacity ?? 0) : null),
@@ -338,8 +324,7 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
}
setIsSaving(true)
- const save = hasAdminAccess ? updateEventAsAdmin : updateEvent
- const updated = await save(eventId, payload)
+ const updated = await updateEventAsAdmin(eventId, payload)
await syncEventMedia(eventId, initialMedia, media)
await syncEventFaqs(eventId, initialFaqs, faqs)
@@ -365,10 +350,6 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
return {loadError ?? texts.events.eventNotFound}
}
- if (!hasAdminAccess && user?.userId && event.organizerId !== user.userId) {
- return {texts.events.editOwnerOnly}
- }
-
if (!policy.canEdit) {
return (
@@ -389,7 +370,7 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
return (