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:
parent
aff6eeace6
commit
1612e3575d
12
app/(dashboard)/manage-events/new/layout.tsx
Normal file
12
app/(dashboard)/manage-events/new/layout.tsx
Normal 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
|
||||||
|
}
|
||||||
41
app/(dashboard)/manage-events/new/page.tsx
Normal file
41
app/(dashboard)/manage-events/new/page.tsx
Normal 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
|
||||||
@ -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))} />,
|
||||||
|
|||||||
@ -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}`}
|
||||||
|
|||||||
@ -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')
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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`,
|
||||||
|
|||||||
607
features/events/create/EventCreateForm.tsx
Normal file
607
features/events/create/EventCreateForm.tsx
Normal 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
|
||||||
172
features/events/create/VerifiedHostPicker.tsx
Normal file
172
features/events/create/VerifiedHostPicker.tsx
Normal 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
|
||||||
3742
openapi.json
3742
openapi.json
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||||
|
|||||||
@ -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: 'اطلاعات پایه',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user