feat(events): add admin UI to create events for verified hosts

Wire manage-events/new with host picker and create flow against the new
admin create API, including entry points from the events list and user detail.
This commit is contained in:
alisaza 2026-09-09 11:22:00 +03:30
parent aff6eeace6
commit 1612e3575d
11 changed files with 1726 additions and 2896 deletions

View File

@ -0,0 +1,12 @@
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
}

View File

@ -0,0 +1,41 @@
'use client'
import dynamic from 'next/dynamic'
import { useSearchParams } from 'next/navigation'
import { Suspense } from 'react'
import PageNavbar from '@/components/layouts/PageNavbar'
import { DetailSkeleton } from '@/components/feedback/LoadingState'
import { APP_ROUTES } from '@/constants/routes'
import { texts } from '@/texts'
const EventCreateForm = dynamic(() => import('@/features/events/create/EventCreateForm'), {
ssr: false,
loading: () => <DetailSkeleton />,
})
const AdminEventCreatePageInner = () => {
const searchParams = useSearchParams()
const initialOrganizerId = searchParams.get('organizerId')
return (
<section className="h-full w-full">
<PageNavbar pageTitle={texts.events.createEventTitle} />
<div className="admin-page-container">
<EventCreateForm
cancelHref={APP_ROUTES.MANAGE_EVENTS}
initialOrganizerId={initialOrganizerId}
successHrefFor={APP_ROUTES.MANAGE_EVENT_DETAIL}
/>
</div>
</section>
)
}
const AdminEventCreatePage = () => (
<Suspense fallback={<DetailSkeleton />}>
<AdminEventCreatePageInner />
</Suspense>
)
export default AdminEventCreatePage

View File

@ -1,6 +1,7 @@
'use client' 'use client'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import type { PaginationListColumnType } from '@/types' import type { PaginationListColumnType } from '@/types'
import PaginatedList from '@/components/PaginatedList' import PaginatedList from '@/components/PaginatedList'
@ -77,6 +78,7 @@ const getOrganizer = (row: EventRow): EventOrganizer => {
} }
const EventsPage = () => { const EventsPage = () => {
const router = useRouter()
const [categoryFilterItems, setCategoryFilterItems] = useState<FilterOption[]>([]) const [categoryFilterItems, setCategoryFilterItems] = useState<FilterOption[]>([])
const [cityFilterItems, setCityFilterItems] = useState<FilterOption[]>([]) const [cityFilterItems, setCityFilterItems] = useState<FilterOption[]>([])
@ -185,8 +187,13 @@ const EventsPage = () => {
<PageNavbar pageTitle="رویدادها" /> <PageNavbar pageTitle="رویدادها" />
<div className="admin-page-container"> <div className="admin-page-container">
<PaginatedList <PaginatedList
hasDynamicButton
columns={columns} columns={columns}
dynamicButtonText="رویداد جدید"
url={API_ROUTES.EVENTS.ADMIN_LIST} url={API_ROUTES.EVENTS.ADMIN_LIST}
onDynamicButtonClick={() => {
router.push(APP_ROUTES.MANAGE_EVENT_NEW)
}}
> >
{{ {{
status: (_row, cellValue) => <StatusChip {...getEventStatus(coerceToString(cellValue))} />, status: (_row, cellValue) => <StatusChip {...getEventStatus(coerceToString(cellValue))} />,

View File

@ -215,7 +215,15 @@ const UserDetailPage = () => {
key="hosted-events" key="hosted-events"
title="رویدادهای میزبانی‌شده" title="رویدادهای میزبانی‌شده"
> >
<div className="flex justify-end pb-2"> <div className="flex justify-end gap-2 pb-2">
{detail.identityStatus === 'verified' ? (
<Button
size="sm"
to={APP_ROUTES.MANAGE_EVENT_NEW_FOR_HOST(userId)}
>
ساخت رویداد برای این میزبان
</Button>
) : null}
<Button <Button
size="sm" size="sm"
to={`${APP_ROUTES.MANAGE_EVENTS}?filters[organizerId]=${userId}`} to={`${APP_ROUTES.MANAGE_EVENTS}?filters[organizerId]=${userId}`}

View File

@ -5,6 +5,8 @@ import { APP_ROUTES, CONSUMER_ROUTES, SEO_ROUTES } from '@/constants/routes'
describe('event route ownership', () => { describe('event route ownership', () => {
it('keeps admin event workflows under /manage-events', () => { it('keeps admin event workflows under /manage-events', () => {
expect(APP_ROUTES.MANAGE_EVENTS).toBe('/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_NEW_FOR_HOST('host/id')).toBe('/manage-events/new?organizerId=host%2Fid')
expect(APP_ROUTES.MANAGE_EVENT_DETAIL('event/id')).toBe('/manage-events/event%2Fid') 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') expect(APP_ROUTES.MANAGE_EVENT_EDIT('event/id')).toBe('/manage-events/event%2Fid/edit')
}) })

View File

@ -3,6 +3,8 @@ export const APP_ROUTES = {
USERS: '/users', USERS: '/users',
USER_DETAIL: (id: string) => `/users/${id}`, USER_DETAIL: (id: string) => `/users/${id}`,
MANAGE_EVENTS: '/manage-events', MANAGE_EVENTS: '/manage-events',
MANAGE_EVENT_NEW: '/manage-events/new',
MANAGE_EVENT_NEW_FOR_HOST: (organizerId: string) => `/manage-events/new?organizerId=${encodeURIComponent(organizerId)}`,
MANAGE_EVENT_DETAIL: (id: string) => `/manage-events/${encodeURIComponent(id)}`, MANAGE_EVENT_DETAIL: (id: string) => `/manage-events/${encodeURIComponent(id)}`,
MANAGE_EVENT_DISCOUNTS: (id: string) => `/manage-events/${encodeURIComponent(id)}?tab=discounts`, MANAGE_EVENT_DISCOUNTS: (id: string) => `/manage-events/${encodeURIComponent(id)}?tab=discounts`,
MANAGE_EVENT_EDIT: (id: string) => `/manage-events/${encodeURIComponent(id)}/edit`, MANAGE_EVENT_EDIT: (id: string) => `/manage-events/${encodeURIComponent(id)}/edit`,

View File

@ -0,0 +1,607 @@
'use client'
import { useEffect, useState } from 'react'
import { FormProvider, useForm } from 'react-hook-form'
import { useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { texts } from '@/texts'
import type { AdminCreateEventPayload } from '@/services/events'
import Button from '@/components/formElements/Button'
import Input from '@/components/formElements/Input'
import CategoryTreeSelect from '@/components/events/create/CategoryTreeSelect'
import EventMediaGalleryUploader from '@/components/events/create/EventMediaGalleryUploader'
import FaqEditor from '@/components/events/create/FaqEditor'
import MapLocationPicker from '@/components/events/create/MapLocationPicker'
import { WizardFormFullWidth, WizardFormGrid } from '@/components/events/create/WizardFormLayout'
import type { FaqItem, StagedMediaItem } from '@/components/events/create/types'
import { combineDateAndMinutes, hasRequiredPosters } from '@/components/events/create/types'
import { DetailSkeleton } from '@/components/feedback/LoadingState'
import InlineNotice from '@/components/feedback/InlineNotice'
import { AdminFormActions, AdminFormSection } from '@/components/forms/AdminFormLayout'
import { useEventScheduleConstraints } from '@/components/events/schedule/eventScheduleConstraints'
import VerifiedHostPicker, { type VerifiedHostOption } from '@/features/events/create/VerifiedHostPicker'
import { slugifyEventTitle, shouldAutogenerateEventSlug } from '@/features/events/eventSlug'
import { createEventAsAdmin, createEventFaq, fetchEventCategories, type EventCategory } from '@/services/events'
import { GET_ADMIN_USER_DETAIL } from '@/services/adminUserDetail'
import { fetchAllCities, type City } from '@/services/geography'
import { extractServerErrorDetail } from '@/services/errorHandler'
import { addToast } from '@/lib/toast'
import { showFormValidationToast } from '@/lib/formValidationToast'
import { AGE_RESTRICTION_OPTIONS, GENDER_RESTRICTION_OPTIONS } from '@/lib/eventAudience'
const TextEditor = dynamic(() => import('@/components/formElements/TextEditor'), { ssr: false })
interface EventCreateFormValues {
title: string
slug: string
categoryId: string
shortDescription: string
description: string
startDate: string
startTime: number
endDate: string
endTime: number
cityId: string
address: string
lat: number
lng: number
isFree: boolean
price: number
capacity: number
reservedCapacity: number
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'
generalArea: string
waitlistAutoOffer: boolean
sendReviewRequestSms: boolean
}
const ADDRESS_VISIBILITY_OPTIONS = [
{ name: texts.events.addressVisibilityPublic, selectKey: 'public' },
{ name: texts.events.addressVisibilityAttendeesOnly, selectKey: 'attendees_only' },
]
const DEFAULT_VALUES: EventCreateFormValues = {
title: '',
slug: '',
categoryId: '',
shortDescription: '',
description: '',
startDate: '',
startTime: 600,
endDate: '',
endTime: 720,
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,
isDiscoverable: true,
autoCreateGroup: true,
addressVisibility: 'public',
generalArea: '',
waitlistAutoOffer: true,
sendReviewRequestSms: true,
}
interface EventCreateFormProps {
cancelHref: string
successHrefFor: (eventId: string) => string
initialOrganizerId?: string | null
}
async function loadVerifiedHost(userId: string): Promise<VerifiedHostOption | null> {
const result = await GET_ADMIN_USER_DETAIL(userId)
if (!result.ok) return null
if (result.data.identityStatus !== 'verified') return null
return {
id: result.data.id,
firstName: result.data.firstName,
lastName: result.data.lastName,
mobile: result.data.mobile,
}
}
const EventCreateForm = ({ cancelHref, successHrefFor, initialOrganizerId }: EventCreateFormProps) => {
const router = useRouter()
const FormActions = AdminFormActions
const FormSection = AdminFormSection
const [categories, setCategories] = useState<EventCategory[]>([])
const [cities, setCities] = useState<City[]>([])
const [media, setMedia] = useState<StagedMediaItem[]>([])
const [faqs, setFaqs] = useState<FaqItem[]>([])
const [host, setHost] = useState<VerifiedHostOption | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
const form = useForm<EventCreateFormValues>({ defaultValues: DEFAULT_VALUES })
const title = form.watch('title')
const slug = form.watch('slug')
const isFree = form.watch('isFree')
const lat = form.watch('lat')
const lng = form.watch('lng')
const addressVisibility = form.watch('addressVisibility')
const startDate = form.watch('startDate')
const startTime = form.watch('startTime')
const endDate = form.watch('endDate')
const { todayIso, startMinimumTime, endMinimumTime } = useEventScheduleConstraints(startDate, startTime, endDate)
useEffect(() => {
if (!shouldAutogenerateEventSlug(slug, title)) return
const nextSlug = slugifyEventTitle(title)
if (nextSlug && nextSlug !== slug) {
form.setValue('slug', nextSlug, { shouldDirty: false })
}
}, [form, slug, title])
useEffect(() => {
let cancelled = false
const load = async () => {
try {
setIsLoading(true)
setLoadError(null)
const [cats, cityList] = await Promise.all([fetchEventCategories(), fetchAllCities()])
if (cancelled) return
setCategories(cats)
setCities(cityList)
if (initialOrganizerId) {
const preselected = await loadVerifiedHost(initialOrganizerId)
if (!cancelled && preselected) setHost(preselected)
}
} catch {
if (!cancelled) setLoadError(texts.events.categoriesListFailed)
} finally {
if (!cancelled) setIsLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [initialOrganizerId])
const handleSubmit = form.handleSubmit(async (values) => {
if (!host) {
addToast({ title: texts.events.createEventHostRequired, color: 'danger' })
return
}
try {
const startsAt = combineDateAndMinutes(values.startDate, values.startTime)
const endsAt = combineDateAndMinutes(values.endDate, values.endTime)
if (new Date(endsAt) <= new Date(startsAt)) {
addToast({ title: texts.validation.eventWizard.endAfterStart, color: 'danger' })
return
}
if (!hasRequiredPosters(media)) {
addToast({
title: texts.events.postersRequiredTitle,
description: texts.events.postersRequiredDescription,
color: 'danger',
})
return
}
const selectedCity = cities.find((city) => String(city.id) === values.cityId)
if (!selectedCity) {
addToast({ title: texts.validation.cityRequired, color: 'danger' })
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: AdminCreateEventPayload = {
organizerId: host.id,
title: values.title.trim(),
slug: values.slug.trim(),
categoryId: Number(values.categoryId),
shortDescription: values.shortDescription.trim() || undefined,
description: values.description || undefined,
startsAt,
endsAt,
provinceId: selectedCity.provinceId,
cityId: Number(values.cityId),
address: values.address.trim(),
lat: values.lat,
lng: values.lng,
isFree: values.isFree,
price,
capacity: Number(values.capacity),
reservedCapacity: Number(values.reservedCapacity) || 0,
genderRestriction: values.genderRestriction,
ageRestriction: values.ageRestriction,
cancellationFeePercent: values.cancellationFeePercent,
cancellationFeePercent12To24Hours: values.cancellationFeePercent12To24Hours,
cancellationFeePercentMoreThan24Hours: values.cancellationFeePercentMoreThan24Hours,
settings: {
isDiscoverable: values.isDiscoverable,
autoCreateGroup: values.autoCreateGroup,
addressVisibility: values.addressVisibility,
generalArea: values.addressVisibility === 'attendees_only' ? values.generalArea.trim() : undefined,
waitlistAutoOffer: values.waitlistAutoOffer,
sendReviewRequestSms: values.sendReviewRequestSms,
},
media: media.map((item, index) => ({
mediaType: 'image' as const,
url: item.url,
sortOrder: item.sortOrder ?? index,
isPoster: item.isPoster,
isSquarePoster: item.isSquarePoster,
})),
}
setIsSaving(true)
const created = await createEventAsAdmin(payload)
for (const [index, faq] of faqs.entries()) {
const question = faq.question.trim()
const answer = faq.answer.trim()
if (!question || !answer) continue
await createEventFaq(created.id, {
question,
answer,
sortOrder: index,
})
}
addToast({ title: texts.events.createEventSuccess, color: 'success' })
router.push(successHrefFor(created.id))
} catch (err) {
const detail = extractServerErrorDetail((err as { response?: { data?: unknown } })?.response?.data)
addToast({
title: texts.events.createEventFailed,
description: detail ?? undefined,
color: 'danger',
})
} finally {
setIsSaving(false)
}
}, showFormValidationToast)
if (isLoading) return <DetailSkeleton />
if (loadError) return <p className="text-sm text-fourth-900">{loadError}</p>
return (
<FormProvider {...form}>
<form
className="mx-auto flex max-w-5xl flex-col gap-5"
onSubmit={(e) => void handleSubmit(e)}
>
<InlineNotice>{texts.events.createEventApprovedHint}</InlineNotice>
<FormSection
description={texts.events.createEventHostSectionDescription}
title={texts.events.createEventHostSectionTitle}
>
<VerifiedHostPicker
selected={host}
onSelect={setHost}
/>
</FormSection>
<FormSection
description={texts.events.basicInfoCardDescription}
title={texts.events.basicInfoCardTitle}
>
<WizardFormGrid>
<Input
required
generalType="input"
label={texts.events.title}
name="title"
/>
<Input
required
description={texts.events.slugFormatHint}
direction="ltr"
generalType="input"
label={texts.events.slugFieldLabel}
name="slug"
/>
<WizardFormFullWidth>
<CategoryTreeSelect
required
categories={categories}
label={texts.common.category}
name="categoryId"
/>
</WizardFormFullWidth>
<WizardFormFullWidth>
<Input
generalType="textarea"
label={texts.events.shortDescriptionLabel}
name="shortDescription"
textAreaMinRows={2}
/>
</WizardFormFullWidth>
<WizardFormFullWidth>
<p className="labelClass mb-2">{texts.common.description}</p>
<TextEditor
value={form.watch('description')}
variant="default"
onChange={(value) => {
form.setValue('description', value, { shouldDirty: true })
}}
/>
</WizardFormFullWidth>
</WizardFormGrid>
</FormSection>
<FormSection
description={texts.events.mediaSectionDescription}
title={texts.events.editMediaCardTitle}
>
<EventMediaGalleryUploader
items={media}
onChange={setMedia}
/>
</FormSection>
<FormSection
description={texts.events.wizardStep2Hint}
title={texts.events.wizardStep2Label}
>
<WizardFormGrid>
<Input
required
generalType="eventDatePicker"
label={texts.events.startsAtDateLabel}
minDate={todayIso}
name="startDate"
/>
<Input
required
generalType="eventTimePicker"
label={texts.events.startsAtTimeLabel}
minValue={startMinimumTime}
name="startTime"
/>
<Input
required
generalType="eventDatePicker"
label={texts.events.endsAtDateLabel}
minDate={startDate || todayIso}
name="endDate"
/>
<Input
required
generalType="eventTimePicker"
label={texts.events.endsAtTimeLabel}
minValue={endMinimumTime}
name="endTime"
/>
<Input
required
generalType="select"
label={texts.common.city}
name="cityId"
selectKey="id"
selectOptions={cities}
selectValue="name"
/>
<WizardFormFullWidth>
<Input
required
generalType="textarea"
label={texts.events.address}
name="address"
textAreaMinRows={3}
/>
</WizardFormFullWidth>
<WizardFormFullWidth>
<MapLocationPicker
lat={lat}
lng={lng}
onAddressResolved={(address) => {
form.setValue('address', address, { shouldDirty: true })
}}
onChange={({ lat: nextLat, lng: nextLng }) => {
form.setValue('lat', nextLat, { shouldDirty: true })
form.setValue('lng', nextLng, { shouldDirty: true })
}}
/>
</WizardFormFullWidth>
<WizardFormFullWidth>
<Input
generalType="radio"
label={texts.events.addressVisibilityLabel}
name="addressVisibility"
radioOptions={ADDRESS_VISIBILITY_OPTIONS}
selectKey="selectKey"
/>
</WizardFormFullWidth>
{addressVisibility === 'attendees_only' && (
<WizardFormFullWidth>
<Input
required
generalType="textarea"
label={texts.events.generalAreaLabel}
name="generalArea"
placeholder={texts.events.generalAreaPlaceholder}
textAreaMinRows={2}
/>
</WizardFormFullWidth>
)}
</WizardFormGrid>
</FormSection>
<FormSection
description={texts.events.wizardStep3Hint}
title={texts.events.pricingCardTitle}
>
<WizardFormGrid>
<div className="flex items-end pb-1">
<Input
generalType="switch"
label={texts.events.isFreeLabel}
name="isFree"
/>
</div>
<Input
disabled={isFree}
formatOptions={{ style: 'decimal' }}
generalType="numberInput"
label={texts.events.priceTomanLabel}
minValue={0}
name="price"
required={!isFree}
/>
<Input
required
generalType="numberInput"
label={texts.events.capacity}
minValue={1}
name="capacity"
/>
<Input
generalType="numberInput"
label={texts.events.reservedCapacityLabel}
minValue={0}
name="reservedCapacity"
/>
<WizardFormFullWidth>
<Input
generalType="radio"
label={texts.events.genderRestrictionLabel}
name="genderRestriction"
radioOptions={[...GENDER_RESTRICTION_OPTIONS]}
selectKey="selectKey"
/>
</WizardFormFullWidth>
<WizardFormFullWidth>
<Input
generalType="radio"
label={texts.events.ageRestrictionLabel}
name="ageRestriction"
radioOptions={[...AGE_RESTRICTION_OPTIONS]}
selectKey="selectKey"
/>
</WizardFormFullWidth>
<Input
generalType="numberInput"
label={texts.events.cancellationFeeMoreThan24HoursLabel}
maxValue={100}
minValue={0}
name="cancellationFeePercentMoreThan24Hours"
/>
<Input
generalType="numberInput"
label={texts.events.cancellationFee12To24HoursLabel}
maxValue={100}
minValue={0}
name="cancellationFeePercent12To24Hours"
/>
<Input
generalType="numberInput"
label={texts.events.cancellationFeeLabel}
maxValue={100}
minValue={0}
name="cancellationFeePercent"
/>
<div className="flex items-end pb-1">
<Input
generalType="switch"
label={texts.events.discoverableLabel}
name="isDiscoverable"
/>
</div>
<div className="flex items-end pb-1">
<Input
generalType="switch"
label={texts.events.autoCreateGroupAfterEndLabel}
name="autoCreateGroup"
/>
</div>
<div className="flex items-end pb-1">
<Input
generalType="switch"
label={texts.events.waitlistAutoOfferLabel}
name="waitlistAutoOffer"
/>
</div>
<div className="flex items-end pb-1">
<Input
generalType="switch"
label={texts.events.sendReviewSmsAfterEndLabel}
name="sendReviewRequestSms"
/>
</div>
</WizardFormGrid>
</FormSection>
<FormSection
description={texts.events.faqSectionDescription}
title={texts.events.editFaqCardTitle}
>
<FaqEditor
items={faqs}
onChange={setFaqs}
/>
</FormSection>
<FormActions className="sticky bottom-3 z-20">
<Button
isLoading={isSaving}
type="submit"
>
{texts.events.createEventSubmit}
</Button>
<Button
variant="flat"
onClick={() => {
router.push(cancelHref)
}}
>
{texts.common.cancel}
</Button>
</FormActions>
</form>
</FormProvider>
)
}
export default EventCreateForm

View File

@ -0,0 +1,172 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import Button from '@/components/formElements/Button'
import Input from '@/components/formElements/Input'
import Modal from '@/components/modals/Modal'
import { formatPersonName } from '@/helpers'
import { formatIranianMobile } from '@/lib/formatters'
import { getAdminUsers } from '@/api/generated/admin-users/admin-users'
import type { AdminUserListItemDto } from '@/api/generated/models'
import { unwrapApiPayload } from '@/helpers/listResponse'
import { texts } from '@/texts'
export interface VerifiedHostOption {
id: string
firstName: string | null
lastName: string | null
mobile: string
}
interface VerifiedHostPickerProps {
selected: VerifiedHostOption | null
onSelect: (host: VerifiedHostOption) => void
}
const adminUsersApi = getAdminUsers()
function toHostOption(row: AdminUserListItemDto): VerifiedHostOption {
return {
id: row.id,
firstName: row.firstName,
lastName: row.lastName,
mobile: row.mobile,
}
}
async function searchVerifiedHosts(query: string): Promise<VerifiedHostOption[]> {
const trimmed = query.trim()
const filters: Record<string, string> = { userType: 'host', identityStatus: 'verified' }
if (trimmed) {
if (/^\d+$/.test(trimmed)) {
filters.mobile = trimmed
} else if (trimmed.includes(' ')) {
const [first, ...rest] = trimmed.split(/\s+/)
filters.firstName = first
filters.lastName = rest.join(' ')
} else {
filters.firstName = trimmed
}
}
const response = await adminUsersApi.adminUsersControllerList({
page: 1,
pageSize: 20,
sort: '-createdAt',
filters,
})
const payload = unwrapApiPayload<{ items?: AdminUserListItemDto[] }>(response.data)
const items = Array.isArray(payload.items) ? payload.items : []
return items.map(toHostOption)
}
const VerifiedHostPicker = ({ selected, onSelect }: VerifiedHostPickerProps) => {
const [isOpen, setIsOpen] = useState(false)
const [query, setQuery] = useState('')
const [results, setResults] = useState<VerifiedHostOption[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async (nextQuery: string) => {
try {
setIsLoading(true)
setError(null)
setResults(await searchVerifiedHosts(nextQuery))
} catch {
setResults([])
setError(texts.events.createEventHostSearchFailed)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
if (!isOpen) return
const timer = window.setTimeout(() => {
void load(query)
}, 250)
return () => {
window.clearTimeout(timer)
}
}, [isOpen, load, query])
const selectedLabel = selected
? `${formatPersonName(selected.firstName, selected.lastName)} · ${formatIranianMobile(selected.mobile)}`
: texts.events.createEventHostPickerPlaceholder
return (
<>
<Button
fullWidth
className="justify-start"
variant="bordered"
onClick={() => {
setIsOpen(true)
}}
>
{selectedLabel}
</Button>
<Modal
acceptBtnText={texts.common.close}
isOpen={isOpen}
size="2xl"
title={texts.events.createEventHostPickerLabel}
onAccept={() => {
setIsOpen(false)
}}
onOpenChange={setIsOpen}
>
<div className="grid gap-3">
<Input
generalType="input"
label={texts.events.createEventHostSearchPlaceholder}
name="verifiedHostSearch"
value={query}
onValueChange={(value) => {
setQuery(typeof value === 'string' ? value : '')
}}
/>
{error ? <p className="text-sm text-fourth-900">{error}</p> : null}
{isLoading ? <p className="text-sm text-secondary-20">{texts.common.loadingList}</p> : null}
{!isLoading && !error && results.length === 0 ? (
<p className="text-sm text-secondary-20">{texts.events.createEventHostSearchEmpty}</p>
) : null}
<ul className="max-h-80 divide-y divide-secondary-40 overflow-y-auto rounded-xl border border-secondary-40">
{results.map((host) => (
<li key={host.id}>
<Button
className="h-auto w-full flex-col items-stretch gap-1 rounded-none p-3 text-right"
variant="light"
onClick={() => {
onSelect(host)
setIsOpen(false)
}}
>
<span className="text-sm font-semibold text-secondary-10">{formatPersonName(host.firstName, host.lastName)}</span>
<span
className="text-xs text-secondary-20"
dir="ltr"
>
{formatIranianMobile(host.mobile)}
</span>
</Button>
</li>
))}
</ul>
</div>
</Modal>
</>
)
}
export default VerifiedHostPicker

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ import type {
CreateEventMediaDto, CreateEventMediaDto,
EventCategoryResponseDto, EventCategoryResponseDto,
EventResponseDto, EventResponseDto,
AdminCreateEventDto,
} from '@/api/generated/models' } from '@/api/generated/models'
import { getAdminEventCategories } from '@/api/generated/admin-event-categories/admin-event-categories' import { getAdminEventCategories } from '@/api/generated/admin-event-categories/admin-event-categories'
import { getAdminEvents } from '@/api/generated/admin-events/admin-events' import { getAdminEvents } from '@/api/generated/admin-events/admin-events'
@ -14,6 +15,7 @@ import { API_ROUTES } from '@/services/config'
export type EventCategory = EventCategoryResponseDto export type EventCategory = EventCategoryResponseDto
export type CreateEventPayload = CreateEventDto export type CreateEventPayload = CreateEventDto
export type AdminCreateEventPayload = AdminCreateEventDto
export type CreatedEvent = EventResponseDto export type CreatedEvent = EventResponseDto
export type CreateEventMediaPayload = CreateEventMediaDto export type CreateEventMediaPayload = CreateEventMediaDto
export type CreateEventFaqPayload = CreateEventFaqDto export type CreateEventFaqPayload = CreateEventFaqDto
@ -45,6 +47,16 @@ export async function fetchEventCategories(): Promise<EventCategory[]> {
return items return items
} }
/**
* Create a pre-approved draft for a verified host. Host still publishes;
* backend notifies the host via `event_created_by_admin`.
*/
export async function createEventAsAdmin(payload: AdminCreateEventPayload): Promise<CreatedEvent> {
const response = await adminEventsApi.adminEventsControllerCreate(payload)
return unwrapApiPayload<CreatedEvent>(response.data)
}
/** Admin approval is one half of the two-key publication workflow. */ /** Admin approval is one half of the two-key publication workflow. */
export async function approveEventAsAdmin(eventId: string): Promise<CreatedEvent> { export async function approveEventAsAdmin(eventId: string): Promise<CreatedEvent> {
const response = await adminEventsApi.adminEventsControllerApprove(eventId) const response = await adminEventsApi.adminEventsControllerApprove(eventId)

View File

@ -87,6 +87,21 @@ export const events = {
eventLoadFailed: 'بارگذاری رویداد ناموفق بود', eventLoadFailed: 'بارگذاری رویداد ناموفق بود',
eventUpdated: 'رویداد به‌روزرسانی شد', eventUpdated: 'رویداد به‌روزرسانی شد',
eventUpdateFailed: 'ذخیره تغییرات ناموفق بود', eventUpdateFailed: 'ذخیره تغییرات ناموفق بود',
createEventTitle: 'رویداد جدید',
createEventSuccess: 'رویداد ساخته شد؛ میزبان باید آن را منتشر کند',
createEventFailed: 'ساخت رویداد ناموفق بود',
createEventHostRequired: 'یک میزبان تأییدشده انتخاب کنید',
createEventHostSectionTitle: 'میزبان رویداد',
createEventHostSectionDescription:
'رویداد به نام این میزبان ساخته می‌شود، از قبل تأیید می‌شود و پیامک اطلاع‌رسانی برای او ارسال می‌گردد. انتشار با خود میزبان است.',
createEventHostPickerLabel: 'انتخاب میزبان تأییدشده',
createEventHostPickerPlaceholder: 'میزبان را انتخاب کنید',
createEventHostSearchPlaceholder: 'جستجو با موبایل، نام یا نام خانوادگی',
createEventHostSearchEmpty: 'میزبان تأییدشده‌ای پیدا نشد',
createEventHostSearchFailed: 'دریافت لیست میزبان‌ها ناموفق بود',
reservedCapacityLabel: 'ظرفیت رزروشده (خارج از قبیله)',
createEventSubmit: 'ساخت رویداد تأییدشده',
createEventApprovedHint: 'پس از ساخت، وضعیت پیش‌نویس تأییدشده است و فقط میزبان می‌تواند آن را منتشر کند.',
editStatusBlocked: 'این رویداد در وضعیت «{status}» دیگر قابل ویرایش نیست.', editStatusBlocked: 'این رویداد در وضعیت «{status}» دیگر قابل ویرایش نیست.',
capacityBelowOccupied: 'ظرفیت نمی‌تواند کمتر از صندلی‌های اشغال‌شده ({min}) باشد', capacityBelowOccupied: 'ظرفیت نمی‌تواند کمتر از صندلی‌های اشغال‌شده ({min}) باشد',
basicInfoCardTitle: 'اطلاعات پایه', basicInfoCardTitle: 'اطلاعات پایه',