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.
This commit is contained in:
parent
99685e058d
commit
82593dc18c
@ -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)
|
||||
})
|
||||
}, [])
|
||||
|
||||
@ -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 = () => {
|
||||
مهمترین شاخصهای کاربران، رویدادها، رزروها و عملیات مالی را در یک نگاه بررسی کنید.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Link
|
||||
className="inline-flex min-h-9 items-center gap-2 rounded-xl bg-white px-3 text-xs font-semibold text-primary-700 transition-colors hover:bg-white/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
|
||||
href={APP_ROUTES.MANAGE_EVENT_NEW}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
ایجاد رویداد
|
||||
</Link>
|
||||
<Link
|
||||
className="inline-flex min-h-9 items-center rounded-xl border border-white/20 bg-white/10 px-3 text-xs font-semibold text-white transition-colors hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
|
||||
href={`${APP_ROUTES.IDENTITY_VERIFICATIONS}?filters[status]=pending`}
|
||||
|
||||
@ -25,10 +25,8 @@ const AdminEventEditPage = () => {
|
||||
<PageNavbar pageTitle={texts.events.editEventTitle} />
|
||||
<div className="admin-page-container">
|
||||
<EventEditForm
|
||||
accessMode="admin"
|
||||
cancelHref={detailHref}
|
||||
eventId={eventId}
|
||||
layout="admin"
|
||||
successHref={detailHref}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import AdminEventCreate from '@/features/events/create/AdminEventCreate'
|
||||
|
||||
const AdminEventCreatePage = () => <AdminEventCreate />
|
||||
|
||||
export default AdminEventCreatePage
|
||||
@ -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 (
|
||||
<section className="h-full w-full text-right">
|
||||
<PageNavbar
|
||||
endSlot={
|
||||
<Button
|
||||
size="sm"
|
||||
to={APP_ROUTES.MANAGE_EVENT_NEW}
|
||||
>
|
||||
افزودن رویداد
|
||||
</Button>
|
||||
}
|
||||
pageTitle="رویدادها"
|
||||
/>
|
||||
<PageNavbar pageTitle="رویدادها" />
|
||||
<div className="admin-page-container">
|
||||
<PaginatedList
|
||||
columns={columns}
|
||||
|
||||
@ -10,7 +10,7 @@ import Input from '@/components/formElements/Input'
|
||||
import ConsumerInput from '@/components/consumer/ConsumerInput'
|
||||
import { texts, format } from '@/texts'
|
||||
import { useAuthFlow } from '@/features/auth/useAuthFlow'
|
||||
import { useDiscoveryCitiesQuery } from '@/queries/consumer/useDiscoveryCitiesQuery'
|
||||
import { useAdminCitiesQuery } from '@/queries/admin/useAdminCitiesQuery'
|
||||
import { showFormValidationToast } from '@/lib/formValidationToast'
|
||||
import { OTP_LENGTH } from '@/validation/auth'
|
||||
|
||||
@ -21,7 +21,7 @@ const GENDER_OPTIONS = [
|
||||
|
||||
export function AdminAuthContent() {
|
||||
const flow = useAuthFlow({})
|
||||
const citiesQuery = useDiscoveryCitiesQuery(flow.step === 'profile')
|
||||
const citiesQuery = useAdminCitiesQuery(flow.step === 'profile')
|
||||
const cityOptions = (citiesQuery.data ?? []).map((city) => ({ id: String(city.id), name: city.name }))
|
||||
const title = flow.step === 'otp' ? texts.auth.otpTitle : flow.step === 'profile' ? texts.auth.profileTitle : null
|
||||
|
||||
|
||||
@ -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<EventGuestListLink[]>([])
|
||||
const [invitedGuests, setInvitedGuests] = useState<EventInvitedGuest[]>([])
|
||||
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 (
|
||||
<Card className={surfaceClass}>
|
||||
<Card className="admin-surface">
|
||||
<CardHeader className="border-b border-consumer-border px-4 py-3 text-sm font-bold text-consumer-text">
|
||||
{texts.events.guestAccessTitle}
|
||||
</CardHeader>
|
||||
|
||||
@ -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 (
|
||||
<div className={cn('grid gap-4', disabled && 'pointer-events-none opacity-70')}>
|
||||
<RestrictionGroup
|
||||
ariaLabel={texts.events.genderRestrictionLabel}
|
||||
disabled={disabled}
|
||||
options={GENDER_RESTRICTION_OPTIONS}
|
||||
value={genderRestriction}
|
||||
onChange={(value) => {
|
||||
onGenderRestrictionChange(value as EventGenderRestriction)
|
||||
}}
|
||||
/>
|
||||
<RestrictionGroup
|
||||
ariaLabel={texts.events.ageRestrictionLabel}
|
||||
disabled={disabled}
|
||||
options={AGE_RESTRICTION_OPTIONS}
|
||||
value={ageRestriction}
|
||||
onChange={(value) => {
|
||||
onAgeRestrictionChange(value as EventAgeRestriction)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div
|
||||
aria-label={ariaLabel}
|
||||
className="flex w-full gap-1 rounded-xl bg-secondary-50 p-1"
|
||||
role="group"
|
||||
>
|
||||
{options.map((option) => {
|
||||
const active = option.selectKey === value
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={option.selectKey}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'flex-1 min-h-10 px-3 text-xs font-medium rounded-xl',
|
||||
active
|
||||
? '[--button-bg:var(--color-secondary-30)] [--button-bg-hover:var(--color-secondary-30)] [--button-bg-pressed:var(--color-secondary-30)] [--button-fg:#ffffff]'
|
||||
: '[--button-bg:var(--color-secondary-40)] [--button-bg-hover:var(--color-secondary-40)] [--button-bg-pressed:var(--color-secondary-40)] [--button-fg:var(--color-secondary-10)]'
|
||||
)}
|
||||
isDisabled={disabled}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={() => {
|
||||
if (disabled) return
|
||||
onChange(option.selectKey)
|
||||
}}
|
||||
>
|
||||
{option.name}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
{tiers.map((tier) => {
|
||||
const normalizedValue = Number.isFinite(tier.value) ? Math.min(100, Math.max(0, tier.value)) : 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tier.field}
|
||||
className="rounded-consumer-control bg-[#E2E2E280] p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-medium text-consumer-text">{tier.label}</p>
|
||||
<div className="relative shrink-0">
|
||||
<Input
|
||||
aria-label={tier.label}
|
||||
disabled={disabled}
|
||||
generalType="numberInput"
|
||||
inputWrapper="h-10 w-16 rounded-xl border-0 bg-white px-2 text-center text-sm font-medium text-tertiary-900 shadow-none [&_input]:text-center disabled:bg-white/70"
|
||||
maxValue={100}
|
||||
minValue={0}
|
||||
name={tier.field}
|
||||
value={normalizedValue}
|
||||
onValueChange={(nextValue) => {
|
||||
const next = Number(nextValue)
|
||||
|
||||
onChange(tier.field, Number.isFinite(next) ? Math.min(100, Math.max(0, next)) : 0)
|
||||
}}
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-y-0 start-1 flex items-center text-xs text-secondary-20">٪</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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 (
|
||||
<article className="overflow-hidden rounded-consumer-surface bg-consumer-surface">
|
||||
<div
|
||||
aria-label={poster ? texts.events.posterAria : texts.events.noPosterAria}
|
||||
className={cn(
|
||||
'flex min-h-48 items-end bg-cover bg-center p-5 sm:min-h-60',
|
||||
!poster && 'bg-gradient-to-br from-primary-100 to-primary-50'
|
||||
)}
|
||||
role="img"
|
||||
style={
|
||||
poster
|
||||
? {
|
||||
backgroundImage: `linear-gradient(to top, color-mix(in srgb, var(--color-secondary-10) 72%, transparent), transparent 62%), url("${poster.url}")`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className={poster ? 'text-white' : 'text-primary-900'}>
|
||||
<p className="mb-2 text-xs font-semibold opacity-80">{categoryName || texts.events.eventCategoryFallback}</p>
|
||||
<h3 className="text-xl font-extrabold sm:text-2xl">{data.title || texts.events.eventTitleLabel}</h3>
|
||||
{data.shortDescription ? <p className="mt-2 max-w-2xl text-sm leading-6 opacity-90">{data.shortDescription}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 grid-cols-2 mt-2">
|
||||
{fields.map((field) => (
|
||||
<div
|
||||
key={field.label}
|
||||
className={cn('rounded-2xl bg-consumer-surface-muted p-3', field.full && 'sm:col-span-2')}
|
||||
>
|
||||
<p className="mb-1 flex items-center gap-1 text-xs text-secondary-20">{field.label}</p>
|
||||
<p className="text-sm font-semibold text-text-dark">{field.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@ -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: () => <DetailSkeleton />,
|
||||
})
|
||||
const Step2ScheduleLocation = dynamic(() => import('@/components/events/create/steps/Step2ScheduleLocation'), {
|
||||
ssr: false,
|
||||
loading: () => <DetailSkeleton />,
|
||||
})
|
||||
const Step3PricingCapacity = dynamic(() => import('@/components/events/create/steps/Step3PricingCapacity'), {
|
||||
ssr: false,
|
||||
loading: () => <DetailSkeleton />,
|
||||
})
|
||||
const Step4FaqPreview = dynamic(() => import('@/components/events/create/steps/Step4FaqPreview'), {
|
||||
ssr: false,
|
||||
loading: () => <DetailSkeleton />,
|
||||
})
|
||||
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<EventWizardFormData>(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<string | null>(null)
|
||||
const [previousAttendees, setPreviousAttendees] = useState<PreviousAttendee[]>([])
|
||||
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<City[]>([])
|
||||
|
||||
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<EventWizardFormData>(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<EventWizardFormData>) => {
|
||||
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 <DetailSkeleton />
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-primary-100 bg-primary-50 px-3 py-2 text-xs text-primary sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>
|
||||
{wasCloneLoaded
|
||||
? texts.events.wizardCloneHint
|
||||
: wasDraftRestored
|
||||
? texts.events.wizardDraftRestoredHint
|
||||
: texts.events.wizardAutosaveHint}
|
||||
</span>
|
||||
<Button
|
||||
className="text-xs text-secondary-20"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={() => {
|
||||
if (userId) clearEventDraft(userId)
|
||||
setData({ ...INITIAL_WIZARD_DATA, media: [], faqs: [] })
|
||||
setStep(1)
|
||||
setWasDraftRestored(false)
|
||||
setWasCloneLoaded(false)
|
||||
setCreatedEventId(null)
|
||||
setShowPostCreateInvite(false)
|
||||
}}
|
||||
>
|
||||
{texts.events.startOver}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<WizardStepper currentStep={step} />
|
||||
<div>
|
||||
{currentStepTitle ? <h2 className="mb-6 text-lg font-semibold text-text-dark">{currentStepTitle}</h2> : null}
|
||||
{step === 1 && (
|
||||
<Step1BasicInfo
|
||||
data={data}
|
||||
onChange={patchData}
|
||||
onNext={(patch) => {
|
||||
patchData(patch)
|
||||
setStep(2)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<Step2ScheduleLocation
|
||||
data={data}
|
||||
onBack={() => {
|
||||
setStep(1)
|
||||
}}
|
||||
onChange={patchData}
|
||||
onNext={(patch) => {
|
||||
patchData(patch)
|
||||
setStep(3)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<Step3PricingCapacity
|
||||
data={data}
|
||||
onBack={() => {
|
||||
setStep(2)
|
||||
}}
|
||||
onChange={patchData}
|
||||
onNext={(patch) => {
|
||||
patchData(patch)
|
||||
setStep(4)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<Step4FaqPreview
|
||||
categoryName={categoryName}
|
||||
cityName={cityName}
|
||||
data={data}
|
||||
isSaving={isSaving}
|
||||
/* استان فعلاً در پیشنمایش نمایش داده نمیشود. */
|
||||
/* provinceName={provinceName} */
|
||||
onBack={() => {
|
||||
setStep(3)
|
||||
}}
|
||||
onChange={patchData}
|
||||
onSaveDraft={() => void handleSaveDraft()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{createdEventId ? (
|
||||
<Step5GuestList
|
||||
attendees={previousAttendees}
|
||||
eventId={createdEventId}
|
||||
isOpen={showPostCreateInvite}
|
||||
onFinish={() => {
|
||||
router.push(finishRoute)
|
||||
}}
|
||||
onOpenChange={setShowPostCreateInvite}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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 (
|
||||
<Tabs
|
||||
aria-label={texts.events.ticketTypeSectionTitle}
|
||||
className="w-full"
|
||||
isDisabled={disabled}
|
||||
selectedKey={isFree ? 'free' : 'paid'}
|
||||
onSelectionChange={(key) => {
|
||||
if (disabled) return
|
||||
onChange(key === 'free')
|
||||
}}
|
||||
>
|
||||
{/* HeroUI tab default is h-8; list has p-1 → use !h-[46px] so total track = 54px */}
|
||||
<Tabs.ListContainer className="bg-secondary-40">
|
||||
<Tabs.List aria-label={texts.events.ticketTypeSectionTitle}>
|
||||
{TICKET_TYPE_TABS.map(([tab, label]) => (
|
||||
<Tabs.Tab
|
||||
key={tab}
|
||||
className="!h-[46px] p-2 text-secondary-10 data-[selected=true]:text-white font-semibold"
|
||||
id={tab}
|
||||
>
|
||||
{label}
|
||||
<Tabs.Indicator className="bg-secondary-30" />
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs.ListContainer>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex size-10 shrink-0 items-center justify-center rounded-full border-2 text-sm font-bold transition-all duration-300',
|
||||
isActive && 'border-primary bg-primary text-white shadow-md shadow-primary/25 scale-110',
|
||||
isDone && !isActive && 'border-fifth bg-fifth text-white',
|
||||
!isActive && !isDone && 'border-default-300 bg-white text-default-400'
|
||||
)}
|
||||
>
|
||||
{isDone && !isActive ? (
|
||||
<svg
|
||||
aria-hidden
|
||||
className="size-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M5 13l4 4L19 7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
step
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<nav
|
||||
aria-label={texts.events.wizardStepsAria}
|
||||
className={cn('w-full', className)}
|
||||
>
|
||||
<div className="sm:hidden">
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-xs">
|
||||
<span className="font-bold text-consumer-text">
|
||||
مرحله {currentStep} از {WIZARD_STEPS.length} — {current?.label}
|
||||
</span>
|
||||
<span className="text-secondary-20">{progress}٪</span>
|
||||
</div>
|
||||
<div
|
||||
aria-label={format(texts.events.wizardProgressAria, { progress })}
|
||||
aria-valuemax={100}
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={progress}
|
||||
className="h-2 overflow-hidden rounded-full bg-default-200"
|
||||
role="progressbar"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol className="hidden items-start justify-between gap-2 sm:flex">
|
||||
{WIZARD_STEPS.map((step, index) => {
|
||||
const isActive = step.key === currentStep
|
||||
const isDone = step.key < currentStep
|
||||
const isLast = index === WIZARD_STEPS.length - 1
|
||||
|
||||
return (
|
||||
<li
|
||||
key={step.key}
|
||||
className={cn('flex flex-1 min-w-0', isLast && 'flex-none max-w-[4.5rem] md:max-w-none')}
|
||||
>
|
||||
<div className="flex flex-col items-center w-full min-w-0">
|
||||
<div className="flex items-center w-full">
|
||||
{/* connector before (RTL: appears on the right side visually for non-first) */}
|
||||
{index > 0 ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'h-1 flex-1 min-w-2 transition-colors duration-300',
|
||||
step.key <= currentStep ? 'bg-fifth' : 'bg-default-200'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 min-w-2" />
|
||||
)}
|
||||
|
||||
<StepCircle
|
||||
isActive={isActive}
|
||||
isDone={isDone}
|
||||
step={step.key}
|
||||
/>
|
||||
|
||||
{!isLast ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'h-1 flex-1 min-w-2 transition-colors duration-300',
|
||||
step.key < currentStep ? 'bg-fifth' : 'bg-default-200'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 min-w-2" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-center px-1 w-full min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
'text-smtext-xs font-medium leading-tight truncate',
|
||||
isActive && 'text-primary font-semibold',
|
||||
isDone && !isActive && 'text-fifth',
|
||||
!isActive && !isDone && 'text-default-400'
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</p>
|
||||
{isActive ? (
|
||||
<span className="inline-block mt-1 text-[10px] text-primary/80 bg-primary-50 px-2 py-1 rounded-full">
|
||||
{texts.events.wizardInProgress}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@ -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: '<p>توضیح</p>',
|
||||
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: 'شنبه' }])
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@ -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<EventWizardFormData>) => void
|
||||
onNext: (patch: Partial<EventWizardFormData>) => void
|
||||
}
|
||||
|
||||
export default function Step1BasicInfo({ data, onChange, onNext }: Step1BasicInfoProps) {
|
||||
const categoriesQuery = usePublicCategoriesQuery()
|
||||
const categories = categoriesQuery.data?.items ?? []
|
||||
const form = useForm<EventWizardStep1Values>({
|
||||
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 (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<WizardFormGrid>
|
||||
<WizardSectionTitle description={texts.events.mediaSectionDescription}>پوستر و تصاویر</WizardSectionTitle>
|
||||
|
||||
<WizardFormFullWidth>
|
||||
<EventMediaGalleryUploader
|
||||
items={data.media}
|
||||
onChange={(media) => {
|
||||
onChange({ media })
|
||||
}}
|
||||
/>
|
||||
</WizardFormFullWidth>
|
||||
|
||||
<WizardSectionTitle description={texts.events.basicInfoSectionDescription}>معرفی رویداد</WizardSectionTitle>
|
||||
|
||||
<Input
|
||||
required
|
||||
generalType="input"
|
||||
label={texts.events.eventTitleLabel}
|
||||
maxValue={200}
|
||||
name="title"
|
||||
/>
|
||||
<CategoryTreeSelect
|
||||
required
|
||||
categories={categories}
|
||||
label={texts.common.category}
|
||||
name="categoryId"
|
||||
/>
|
||||
|
||||
<WizardFormFullWidth>
|
||||
<Input
|
||||
generalType="textarea"
|
||||
label={texts.events.shortDescriptionLabel}
|
||||
name="shortDescription"
|
||||
textAreaMinRows={3}
|
||||
/>
|
||||
</WizardFormFullWidth>
|
||||
|
||||
<WizardFormFullWidth>
|
||||
<p className="labelClass mb-2">{texts.events.fullDescriptionLabel}</p>
|
||||
<TextEditor
|
||||
value={form.watch('description')}
|
||||
onChange={(value) => {
|
||||
form.setValue('description', value)
|
||||
}}
|
||||
/>
|
||||
</WizardFormFullWidth>
|
||||
|
||||
<WizardFormFullWidth>
|
||||
<details className="group rounded-2xl border border-consumer-border bg-consumer-surface-muted p-4">
|
||||
<summary className="cursor-pointer list-none text-sm font-bold text-consumer-text marker:hidden">
|
||||
{texts.events.configureEventLink}
|
||||
<span className="me-2 text-xs font-normal text-secondary-20">{texts.events.advanced}</span>
|
||||
</summary>
|
||||
<div className="mt-4 border-t border-consumer-border pt-4">
|
||||
<Input
|
||||
required
|
||||
description={slug ? format(texts.events.slugPublicLink, { slug }) : texts.events.slugHintFromTitle}
|
||||
direction="ltr"
|
||||
generalType="input"
|
||||
label={texts.events.slugLabel}
|
||||
maxValue={220}
|
||||
name="slug"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</WizardFormFullWidth>
|
||||
|
||||
<WizardStepFooter
|
||||
primaryLabel={texts.events.nextStep}
|
||||
onPrimary={handleSubmit}
|
||||
/>
|
||||
</WizardFormGrid>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@ -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<EventWizardFormData>) => void
|
||||
onNext: (patch: Partial<EventWizardFormData>) => void
|
||||
}
|
||||
|
||||
export default function Step2ScheduleLocation({ data, onBack, onChange, onNext }: Step2ScheduleLocationProps) {
|
||||
const [cities, setCities] = useState<City[]>([])
|
||||
|
||||
const form = useForm<EventWizardStep2Values>({
|
||||
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 (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<WizardFormGrid>
|
||||
<WizardSectionTitle description={texts.events.scheduleSectionDescription}>{texts.events.scheduleSectionTitle}</WizardSectionTitle>
|
||||
|
||||
<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"
|
||||
/>
|
||||
|
||||
<WizardSectionTitle description={texts.events.venueSectionDescription}>{texts.events.venueSectionTitle}</WizardSectionTitle>
|
||||
|
||||
{/* انتخاب استان فعلاً غیرفعال است؛ استان از روی شهر تعیین میشود.
|
||||
<Input
|
||||
required
|
||||
generalType="select"
|
||||
label={texts.common.province}
|
||||
name="provinceId"
|
||||
selectKey="id"
|
||||
selectOptions={provinces}
|
||||
selectValue="name"
|
||||
/> */}
|
||||
<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"
|
||||
placeholder={texts.events.addressPlaceholder}
|
||||
textAreaMinRows={3}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-default-500">{texts.events.addressHostContactHint}</p>
|
||||
</WizardFormFullWidth>
|
||||
|
||||
<WizardFormFullWidth>
|
||||
<MapLocationPicker
|
||||
displayAddress={addressValue}
|
||||
lat={data.lat}
|
||||
lng={data.lng}
|
||||
mode="inline"
|
||||
onAddressResolved={(address) => {
|
||||
form.setValue('address', address, { shouldDirty: true, shouldTouch: true })
|
||||
onChange({ address })
|
||||
}}
|
||||
onChange={({ lat, lng }) => {
|
||||
hasManualLocationRef.current = true
|
||||
onChange({ lat, lng })
|
||||
}}
|
||||
/>
|
||||
</WizardFormFullWidth>
|
||||
|
||||
<WizardSectionTitle description={texts.events.addressVisibilitySectionDescription}>
|
||||
{texts.events.addressVisibilitySectionTitle}
|
||||
</WizardSectionTitle>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
<WizardStepFooter
|
||||
primaryLabel={texts.events.nextStep}
|
||||
onBack={onBack}
|
||||
onPrimary={handleSubmit}
|
||||
/>
|
||||
</WizardFormGrid>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@ -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<EventWizardFormData>) => void
|
||||
onNext: (patch: Partial<EventWizardFormData>) => void
|
||||
}
|
||||
|
||||
export default function Step3PricingCapacity({ data, onBack, onChange, onNext }: Step3PricingCapacityProps) {
|
||||
const form = useForm<EventWizardStep3Values>({
|
||||
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 (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<WizardFormGrid>
|
||||
<WizardSectionTitle description={texts.events.pricingSectionDescription}>
|
||||
{texts.events.ticketTypeSectionTitle}
|
||||
</WizardSectionTitle>
|
||||
|
||||
<WizardFormFullWidth>
|
||||
<TicketTypeSelector
|
||||
isFree={isFree}
|
||||
onChange={(nextIsFree) => {
|
||||
form.setValue('isFree', nextIsFree, { shouldDirty: true, shouldValidate: true })
|
||||
}}
|
||||
/>
|
||||
</WizardFormFullWidth>
|
||||
|
||||
{!isFree ? (
|
||||
<Input
|
||||
required
|
||||
formatOptions={{ style: 'decimal' }}
|
||||
generalType="numberInput"
|
||||
label={texts.events.pricePerTicketLabel}
|
||||
minValue={0}
|
||||
name="price"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Input
|
||||
required
|
||||
generalType="numberInput"
|
||||
label={texts.events.capacity}
|
||||
minValue={1}
|
||||
name="capacity"
|
||||
/>
|
||||
<WizardFormFullWidth>
|
||||
<Input
|
||||
generalType="numberInput"
|
||||
label={texts.events.reservedCapacityLabel}
|
||||
minValue={0}
|
||||
name="reservedCapacity"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-secondary-20">{texts.events.reservedCapacityWizardHint}</p>
|
||||
</WizardFormFullWidth>
|
||||
|
||||
<WizardSectionTitle description={texts.events.audienceSectionDescription}>{texts.events.audienceSectionTitle}</WizardSectionTitle>
|
||||
|
||||
<WizardFormFullWidth>
|
||||
<AudienceRestrictionSelector
|
||||
ageRestriction={form.watch('ageRestriction')}
|
||||
genderRestriction={form.watch('genderRestriction')}
|
||||
onAgeRestrictionChange={(ageRestriction) => {
|
||||
form.setValue('ageRestriction', ageRestriction, { shouldDirty: true, shouldValidate: true })
|
||||
}}
|
||||
onGenderRestrictionChange={(genderRestriction) => {
|
||||
form.setValue('genderRestriction', genderRestriction, { shouldDirty: true, shouldValidate: true })
|
||||
}}
|
||||
/>
|
||||
</WizardFormFullWidth>
|
||||
|
||||
<WizardSectionTitle description={texts.events.cancellationSectionDescription}>
|
||||
{texts.events.cancellationSectionTitle}
|
||||
</WizardSectionTitle>
|
||||
|
||||
<WizardFormFullWidth>
|
||||
<CancellationPolicyEditor
|
||||
cancellationFeePercent={form.watch('cancellationFeePercent')}
|
||||
cancellationFeePercent12To24Hours={form.watch('cancellationFeePercent12To24Hours')}
|
||||
cancellationFeePercentMoreThan24Hours={form.watch('cancellationFeePercentMoreThan24Hours')}
|
||||
onChange={(field, value) => {
|
||||
form.setValue(field, value, { shouldDirty: true, shouldValidate: true })
|
||||
}}
|
||||
/>
|
||||
</WizardFormFullWidth>
|
||||
|
||||
<WizardFormFullWidth>
|
||||
<details className="group rounded-2xl border border-consumer-border bg-consumer-surface-muted p-4">
|
||||
<summary className="cursor-pointer list-none text-sm font-bold text-consumer-text marker:hidden">
|
||||
{texts.events.moreSettings}
|
||||
<span className="me-2 text-xs font-normal text-secondary-20">{texts.events.advancedSettingsHint}</span>
|
||||
</summary>
|
||||
<div className="mt-4 grid gap-5 border-t border-consumer-border pt-4 md:grid-cols-2">
|
||||
<Input
|
||||
generalType="switch"
|
||||
label={texts.events.discoverableLabel}
|
||||
name="isDiscoverable"
|
||||
/>
|
||||
<Input
|
||||
generalType="switch"
|
||||
label={texts.events.autoCreateGroupLabel}
|
||||
name="autoCreateGroup"
|
||||
/>
|
||||
<Input
|
||||
generalType="switch"
|
||||
label={texts.events.waitlistAutoOfferLabel}
|
||||
name="waitlistAutoOffer"
|
||||
/>
|
||||
<Input
|
||||
generalType="switch"
|
||||
label={texts.events.sendReviewSmsLabel}
|
||||
name="sendReviewRequestSms"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</WizardFormFullWidth>
|
||||
|
||||
<WizardStepFooter
|
||||
primaryLabel={texts.events.nextStep}
|
||||
onBack={onBack}
|
||||
onPrimary={handleSubmit}
|
||||
/>
|
||||
</WizardFormGrid>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@ -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<EventWizardFormData>) => 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 (
|
||||
<WizardFormGrid>
|
||||
<WizardSectionTitle description={texts.events.previewSectionDescription}>{texts.events.previewEventTitle}</WizardSectionTitle>
|
||||
|
||||
<WizardFormFullWidth>
|
||||
<CreateEventPreviewCard
|
||||
categoryName={categoryName}
|
||||
cityName={cityName}
|
||||
data={data}
|
||||
endsAt={endsAtPreview}
|
||||
startsAt={startsAtPreview}
|
||||
/>
|
||||
</WizardFormFullWidth>
|
||||
|
||||
<WizardFormFullWidth>
|
||||
<div className="rounded-2xl border border-fifth/20 bg-fifth/5 p-4">
|
||||
<p className="text-sm font-bold text-text-dark">{texts.events.readyToSave}</p>
|
||||
<ul className="mt-3 grid gap-2 text-xs text-text-muted sm:grid-cols-2">
|
||||
<li>✓ {texts.events.checklistIntroDone}</li>
|
||||
<li>✓ {texts.events.checklistScheduleDone}</li>
|
||||
<li>✓ {texts.events.checklistPricingDone}</li>
|
||||
<li>✓ {texts.events.checklistPosterDone}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</WizardFormFullWidth>
|
||||
|
||||
<WizardSectionTitle description={texts.events.faqSectionDescription}>{texts.events.faqs}</WizardSectionTitle>
|
||||
|
||||
<WizardFormFullWidth>
|
||||
<FaqEditor
|
||||
items={data.faqs}
|
||||
onChange={(faqs) => {
|
||||
onChange({ faqs })
|
||||
}}
|
||||
/>
|
||||
</WizardFormFullWidth>
|
||||
|
||||
<WizardStepFooter
|
||||
isLoading={isSaving}
|
||||
primaryLabel={texts.events.saveDraft}
|
||||
primaryType="button"
|
||||
onBack={onBack}
|
||||
onPrimary={onSaveDraft}
|
||||
/>
|
||||
</WizardFormGrid>
|
||||
)
|
||||
}
|
||||
@ -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<string[]>([])
|
||||
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 (
|
||||
<Modal
|
||||
acceptBtnDisabled={selectedIds.length === 0}
|
||||
acceptBtnText={texts.events.sendInvitation}
|
||||
bodyClassName="max-h-[min(60vh,28rem)]"
|
||||
isLoading={isLinking}
|
||||
isOpen={isOpen}
|
||||
rejectBtnText={texts.common.cancel}
|
||||
size="lg"
|
||||
title={texts.events.invitePreviousGuestsTitle}
|
||||
onAccept={() => void handleSendInvitations()}
|
||||
onClose={onFinish}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm leading-6 text-secondary-30">{texts.events.invitePreviousGuestsDescription}</p>
|
||||
|
||||
<Input
|
||||
isClearable
|
||||
ariaLabel={texts.events.guestSearchAria}
|
||||
generalType="input"
|
||||
iconStart={<SearchAltIcon className="size-5 text-secondary-30" />}
|
||||
name="guestSearch"
|
||||
placeholder={texts.events.guestSearchPlaceholder}
|
||||
value={search}
|
||||
onClear={() => {
|
||||
setSearch('')
|
||||
}}
|
||||
onValueChange={(next) => {
|
||||
setSearch(coerceToString(next))
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
generalType="checkbox"
|
||||
label={texts.events.selectAllGuests}
|
||||
name="selectAllGuests"
|
||||
value={allFilteredSelected}
|
||||
onValueChange={(checked) => {
|
||||
toggleSelectAll(Boolean(checked))
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{filteredAttendees.length === 0 ? (
|
||||
<p className="rounded-xl bg-default-100 p-4 text-sm text-secondary-30">{texts.common.emptyChoice}</p>
|
||||
) : (
|
||||
filteredAttendees.map((attendee) => (
|
||||
<div
|
||||
key={attendee.userId}
|
||||
className="rounded-xl border border-default-200 bg-white p-3"
|
||||
>
|
||||
<Input
|
||||
generalType="checkbox"
|
||||
label={formatPersonName(attendee.firstName, attendee.lastName, texts.events.guest)}
|
||||
name={`guest-${attendee.userId}`}
|
||||
value={selectedIds.includes(attendee.userId)}
|
||||
onValueChange={(checked) => {
|
||||
toggleAttendee(attendee.userId, Boolean(checked))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export async function loadPreviousAttendeesForStep5(): Promise<PreviousAttendee[]> {
|
||||
try {
|
||||
return await fetchPreviousAttendees()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
@ -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')}`
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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<AuthContextType | undefined>(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 (
|
||||
<AuthContext.Provider
|
||||
value={{ user, isSessionHydrated, sendLoginOtp, checkLoginOtp, completeProfile, logout, saveUser, updateUserFromOutside }}
|
||||
|
||||
@ -1,13 +1,8 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import { createHostEvent, hostInsights } from '@/e2e/fixtures/hostEvent'
|
||||
import { authenticateAs } from '@/e2e/fixtures/session'
|
||||
|
||||
async function confirmEventScheduleField(page: Page, fieldLabel: string) {
|
||||
await page.getByRole('button', { name: fieldLabel }).click()
|
||||
await page.getByRole('dialog', { name: fieldLabel }).getByRole('button', { name: 'تأیید' }).click()
|
||||
}
|
||||
|
||||
const event = createHostEvent({
|
||||
title: 'رویداد تست مدیریت',
|
||||
status: 'draft',
|
||||
@ -138,8 +133,6 @@ test('admin lists events with their main management fields and can open create f
|
||||
await expect(eventRow.getByText('سارا احمدی', { exact: true })).toBeVisible()
|
||||
await expect(eventRow.getByText('۴۵۰٬۰۰۰ تومان')).toBeVisible()
|
||||
expect(listRequestUrl?.pathname).toBe('/api/v1/admin/events')
|
||||
|
||||
await expect(page.getByRole('link', { name: 'افزودن رویداد' })).toHaveAttribute('href', '/manage-events/new')
|
||||
})
|
||||
|
||||
test('edits discoverability and publishes a draft event', async ({ page }) => {
|
||||
@ -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<string, unknown> | 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<string, unknown>
|
||||
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())
|
||||
})
|
||||
|
||||
@ -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: () => <DetailSkeleton />,
|
||||
})
|
||||
|
||||
const AdminEventCreate = () => {
|
||||
return (
|
||||
<section className="h-full w-full">
|
||||
<PageNavbar pageTitle="ایجاد رویداد جدید" />
|
||||
<div className="admin-page-container">
|
||||
<Suspense fallback={<DetailSkeleton />}>
|
||||
<EventCreateWizard />
|
||||
</Suspense>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminEventCreate
|
||||
@ -438,11 +438,7 @@ const AdminEventDetail = () => {
|
||||
key="guest-lists"
|
||||
title="مهمانان ویژه"
|
||||
>
|
||||
<EventGuestListAccessPanel
|
||||
accessMode="admin"
|
||||
eventId={eventId}
|
||||
surface="admin"
|
||||
/>
|
||||
<EventGuestListAccessPanel eventId={eventId} />
|
||||
</Tab>
|
||||
</AppTabs>
|
||||
|
||||
|
||||
@ -36,13 +36,6 @@ const AdminEventLifecycleActions = ({
|
||||
ویرایش
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
to={`${APP_ROUTES.MANAGE_EVENT_NEW}?cloneFrom=${encodeURIComponent(event.id)}`}
|
||||
variant="flat"
|
||||
>
|
||||
کپی
|
||||
</Button>
|
||||
{event.status === 'draft' && !event.adminApprovedAt ? (
|
||||
<Button
|
||||
isLoading={pendingId === 'approve'}
|
||||
|
||||
@ -19,10 +19,7 @@ const AdminEventReviewsTab = ({ eventId }: AdminEventReviewsTabProps) => (
|
||||
مشاهده در لیست کلی
|
||||
</Button>
|
||||
</div>
|
||||
<EventReviewsPanel
|
||||
eventId={eventId}
|
||||
mode="admin"
|
||||
/>
|
||||
<EventReviewsPanel eventId={eventId} />
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@ -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: () => <div data-testid="map-picker" />,
|
||||
}))
|
||||
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 }) => (
|
||||
<section>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
),
|
||||
ConsumerFormActions: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
vi.mock('@/components/forms/AdminFormLayout', () => ({
|
||||
AdminFormSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
AdminFormActions: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
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<string, unknown>) => ({
|
||||
updateEventAsAdmin.mockImplementation(async (_id: string, payload: Record<string, unknown>) => ({
|
||||
...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<string, unknown>
|
||||
const payload = updateEventAsAdmin.mock.calls[0][1] as Record<string, unknown>
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<CreatedEvent | null>(null)
|
||||
const [categories, setCategories] = useState<EventCategory[]>([])
|
||||
const [cities, setCities] = useState<City[]>([])
|
||||
@ -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 <p className="text-sm text-fourth-900">{loadError ?? texts.events.eventNotFound}</p>
|
||||
}
|
||||
|
||||
if (!hasAdminAccess && user?.userId && event.organizerId !== user.userId) {
|
||||
return <p className="text-sm text-fourth-900">{texts.events.editOwnerOnly}</p>
|
||||
}
|
||||
|
||||
if (!policy.canEdit) {
|
||||
return (
|
||||
<div className="grid max-w-xl gap-3">
|
||||
@ -389,7 +370,7 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
className={clsx('mx-auto flex flex-col gap-5', adminLayout ? 'max-w-5xl' : 'max-w-3xl')}
|
||||
className="mx-auto flex max-w-5xl flex-col gap-5"
|
||||
onSubmit={(e) => void handleSubmit(e)}
|
||||
>
|
||||
{policy.hasActiveBookings ? (
|
||||
@ -400,7 +381,7 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
||||
description={texts.events.basicInfoCardDescription}
|
||||
title={texts.events.basicInfoCardTitle}
|
||||
>
|
||||
<WizardFormGrid singleColumn={layout === 'consumer'}>
|
||||
<WizardFormGrid>
|
||||
<Input
|
||||
required
|
||||
generalType="input"
|
||||
@ -433,11 +414,10 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
||||
/>
|
||||
</WizardFormFullWidth>
|
||||
<WizardFormFullWidth>
|
||||
{layout === 'consumer' ? null : <p className="labelClass mb-2">{texts.common.description}</p>}
|
||||
<p className="labelClass mb-2">{texts.common.description}</p>
|
||||
<TextEditor
|
||||
label={layout === 'consumer' ? texts.common.description : undefined}
|
||||
value={form.watch('description')}
|
||||
variant={layout === 'consumer' ? 'consumer' : 'default'}
|
||||
variant="default"
|
||||
onChange={(value) => {
|
||||
form.setValue('description', value, { shouldDirty: true })
|
||||
}}
|
||||
@ -460,7 +440,7 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
||||
description={texts.events.wizardStep2Hint}
|
||||
title={texts.events.wizardStep2Label}
|
||||
>
|
||||
<WizardFormGrid singleColumn={layout === 'consumer'}>
|
||||
<WizardFormGrid>
|
||||
<Input
|
||||
required
|
||||
disabled={scheduleLocked}
|
||||
@ -493,7 +473,6 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
||||
minValue={endMinimumTime}
|
||||
name="endTime"
|
||||
/>
|
||||
{adminLayout ? (
|
||||
<Input
|
||||
required
|
||||
disabled={locationLocked}
|
||||
@ -504,14 +483,6 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
||||
selectOptions={cities}
|
||||
selectValue="name"
|
||||
/>
|
||||
) : (
|
||||
<WizardFormFullWidth>
|
||||
<p className="text-sm text-text-dark">
|
||||
<span className="font-medium text-default-500">{texts.events.hostCityLabel}: </span>
|
||||
{hostCityDisplayName}
|
||||
</p>
|
||||
</WizardFormFullWidth>
|
||||
)}
|
||||
<WizardFormFullWidth>
|
||||
<Input
|
||||
required
|
||||
@ -566,7 +537,7 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
||||
description={texts.events.wizardStep3Hint}
|
||||
title={texts.events.pricingCardTitle}
|
||||
>
|
||||
<WizardFormGrid singleColumn={layout === 'consumer'}>
|
||||
<WizardFormGrid>
|
||||
<div className="flex items-end pb-1">
|
||||
<Input
|
||||
disabled={priceLocked}
|
||||
@ -684,7 +655,7 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
||||
</FormSection>
|
||||
|
||||
<FormActions
|
||||
className={adminLayout ? 'sticky bottom-3 z-20' : undefined}
|
||||
className="sticky bottom-3 z-20"
|
||||
helper={<UnsavedChangesIndicator isDirty={form.formState.isDirty} />}
|
||||
>
|
||||
<Button
|
||||
|
||||
@ -2,65 +2,40 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { coerceToString } from '@/helpers'
|
||||
import { addToast } from '@/lib/toast'
|
||||
import Button from '@/components/formElements/Button'
|
||||
import Input from '@/components/formElements/Input'
|
||||
import AdminState from '@/components/feedback/AdminState'
|
||||
import ConsumerState from '@/components/feedback/ConsumerState'
|
||||
import ConsumerModal from '@/components/consumer/ConsumerModal'
|
||||
import ReviewCard from '@/components/reviews/ReviewCard'
|
||||
import StatusChip from '@/components/ui/StatusChip'
|
||||
import { getReviewStatus } from '@/constants/status'
|
||||
import { isWithinHostReplyWindow } from '@/lib/reviewWindow'
|
||||
import {
|
||||
EVENT_REVIEWS_PAGE_SIZE,
|
||||
HOST_REPLY_REVIEW,
|
||||
LIST_ADMIN_EVENT_REVIEWS_PAGE,
|
||||
LIST_EVENT_REVIEWS_PAGE,
|
||||
type EventReview,
|
||||
type AdminEventReview,
|
||||
} from '@/services/reviews'
|
||||
import { texts } from '@/texts'
|
||||
|
||||
interface EventReviewsPanelProps {
|
||||
eventId: string
|
||||
/**
|
||||
* Scheduled event end — host reply window is endsAt + 2 months.
|
||||
* Required for host mode; unused when mode is admin (read-only).
|
||||
*/
|
||||
endsAt?: string
|
||||
/** Host can reply; admin only views reviews + host replies. */
|
||||
mode?: 'host' | 'admin'
|
||||
}
|
||||
|
||||
const authorDisplayName = (review: EventReview) => {
|
||||
const authorDisplayName = (review: AdminEventReview) => {
|
||||
const name = [review.user?.firstName, review.user?.lastName].filter(Boolean).join(' ').trim()
|
||||
|
||||
return name || texts.events.guest
|
||||
}
|
||||
|
||||
const EventReviewsPanel = ({ eventId, endsAt, mode = 'host' }: EventReviewsPanelProps) => {
|
||||
const isAdmin = mode === 'admin'
|
||||
const [reviews, setReviews] = useState<EventReview[]>([])
|
||||
/** Read-only admin reviews for one event (includes hidden/deleted). */
|
||||
const EventReviewsPanel = ({ eventId }: EventReviewsPanelProps) => {
|
||||
const [reviews, setReviews] = useState<AdminEventReview[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalItems, setTotalItems] = useState(0)
|
||||
const [replyTarget, setReplyTarget] = useState<EventReview | null>(null)
|
||||
const [replyBody, setReplyBody] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const replyWindowOpen = !isAdmin && !!endsAt && isWithinHostReplyWindow(endsAt)
|
||||
|
||||
const fetchPage = useCallback(
|
||||
async (nextPage: number, options?: { errorMode: 'silent' }) => {
|
||||
if (isAdmin) {
|
||||
return LIST_ADMIN_EVENT_REVIEWS_PAGE(eventId, nextPage, EVENT_REVIEWS_PAGE_SIZE, options)
|
||||
}
|
||||
|
||||
return LIST_EVENT_REVIEWS_PAGE(eventId, nextPage, EVENT_REVIEWS_PAGE_SIZE, options)
|
||||
},
|
||||
[eventId, isAdmin]
|
||||
async (nextPage: number, options?: { errorMode: 'silent' }) =>
|
||||
LIST_ADMIN_EVENT_REVIEWS_PAGE(eventId, nextPage, EVENT_REVIEWS_PAGE_SIZE, options),
|
||||
[eventId]
|
||||
)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@ -87,8 +62,8 @@ const EventReviewsPanel = ({ eventId, endsAt, mode = 'host' }: EventReviewsPanel
|
||||
|
||||
return [...current, ...result.data.items.filter((review) => !seen.has(review.id))]
|
||||
})
|
||||
setPage(nextPage)
|
||||
}
|
||||
setPage(nextPage)
|
||||
setIsLoadingMore(false)
|
||||
}
|
||||
|
||||
@ -96,83 +71,27 @@ const EventReviewsPanel = ({ eventId, endsAt, mode = 'host' }: EventReviewsPanel
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const openReply = (review: EventReview) => {
|
||||
if (!replyWindowOpen) {
|
||||
addToast({ title: texts.errors.codes.HOST_REPLY_WINDOW_EXPIRED, color: 'warning' })
|
||||
|
||||
return
|
||||
}
|
||||
setReplyTarget(review)
|
||||
setReplyBody(review.hostReplyBody ?? '')
|
||||
}
|
||||
|
||||
const saveReply = async () => {
|
||||
if (!replyTarget) return
|
||||
const trimmed = replyBody.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
addToast({ title: texts.reviews.replyBodyRequired, color: 'warning' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
const result = await HOST_REPLY_REVIEW(replyTarget.id, { hostReplyBody: trimmed })
|
||||
|
||||
setIsSaving(false)
|
||||
if (!result.ok) return
|
||||
|
||||
addToast({ title: texts.reviews.replySaved, color: 'success' })
|
||||
setReplyTarget(null)
|
||||
setReviews((current) => current.map((review) => (review.id === result.data.id ? result.data : review)))
|
||||
}
|
||||
|
||||
const clearReply = async () => {
|
||||
if (!replyTarget) return
|
||||
setIsSaving(true)
|
||||
const result = await HOST_REPLY_REVIEW(replyTarget.id, { hostReplyBody: null })
|
||||
|
||||
setIsSaving(false)
|
||||
if (!result.ok) return
|
||||
|
||||
addToast({ title: texts.reviews.replyDeleted, color: 'success' })
|
||||
setReplyTarget(null)
|
||||
setReviews((current) => current.map((review) => (review.id === result.data.id ? result.data : review)))
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <p className={`mt-4 text-sm ${isAdmin ? 'text-text-muted' : 'text-secondary-20'}`}>{texts.reviews.loading}</p>
|
||||
return <p className="mt-4 text-sm text-text-muted">{texts.reviews.loading}</p>
|
||||
}
|
||||
|
||||
if (reviews.length === 0) {
|
||||
return (
|
||||
<div className="mt-4">
|
||||
{isAdmin ? (
|
||||
<AdminState
|
||||
description={texts.reviews.hostEmptyDescription}
|
||||
title={texts.reviews.hostEmptyTitle}
|
||||
variant="empty"
|
||||
/>
|
||||
) : (
|
||||
<ConsumerState
|
||||
description={texts.reviews.hostEmptyDescription}
|
||||
title={texts.reviews.hostEmptyTitle}
|
||||
variant="empty"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-3">
|
||||
{!isAdmin && !replyWindowOpen ? (
|
||||
<p className="text-sm font-semibold text-fourth-700">{texts.errors.codes.HOST_REPLY_WINDOW_EXPIRED}</p>
|
||||
) : null}
|
||||
|
||||
<ul className="space-y-3">
|
||||
{reviews.map((review) => {
|
||||
const statusPresentation = isAdmin ? getReviewStatus(review.status) : null
|
||||
const statusPresentation = getReviewStatus(review.status)
|
||||
|
||||
return (
|
||||
<ReviewCard
|
||||
@ -180,33 +99,20 @@ const EventReviewsPanel = ({ eventId, endsAt, mode = 'host' }: EventReviewsPanel
|
||||
authorAvatarUrl={review.user?.avatarUrl}
|
||||
authorName={authorDisplayName(review)}
|
||||
authorUserId={review.user?.id}
|
||||
body={review.body ?? (isAdmin ? texts.reviews.ratingOnly : null)}
|
||||
className={isAdmin ? 'admin-surface space-y-2 p-4' : 'consumer-surface space-y-2 p-4'}
|
||||
body={review.body ?? texts.reviews.ratingOnly}
|
||||
className="admin-surface space-y-2 p-4"
|
||||
createdAt={review.createdAt}
|
||||
footer={
|
||||
replyWindowOpen ? (
|
||||
<Button
|
||||
className="mt-3 min-h-11"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onClick={() => {
|
||||
openReply(review)
|
||||
}}
|
||||
>
|
||||
{review.hostReplyBody ? texts.reviews.editReply : texts.common.reply}
|
||||
</Button>
|
||||
) : statusPresentation ? (
|
||||
<div className="mt-3">
|
||||
<StatusChip
|
||||
chipColor={statusPresentation.chipColor}
|
||||
label={statusPresentation.label}
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
hostReplyBody={review.hostReplyBody}
|
||||
rating={review.rating}
|
||||
tone={isAdmin ? 'organizer' : 'consumer'}
|
||||
tone="organizer"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@ -223,48 +129,6 @@ const EventReviewsPanel = ({ eventId, endsAt, mode = 'host' }: EventReviewsPanel
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isAdmin ? (
|
||||
<ConsumerModal
|
||||
acceptBtnDisabled={!replyBody.trim() || isSaving}
|
||||
acceptBtnText={texts.reviews.saveReply}
|
||||
footerChildren={
|
||||
replyTarget?.hostReplyBody ? (
|
||||
<Button
|
||||
color="danger"
|
||||
disabled={isSaving}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onClick={() => void clearReply()}
|
||||
>
|
||||
{texts.reviews.deleteReply}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
isLoading={isSaving}
|
||||
isOpen={!!replyTarget}
|
||||
rejectBtnText={texts.common.close}
|
||||
title={texts.reviews.replyModalTitle}
|
||||
onAccept={() => void saveReply()}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setReplyTarget(null)
|
||||
}}
|
||||
onReject={() => {
|
||||
setReplyTarget(null)
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
generalType="textarea"
|
||||
label={texts.reviews.replyBodyLabel}
|
||||
name="hostReplyBody"
|
||||
textAreaMinRows={3}
|
||||
value={replyBody}
|
||||
onValueChange={(next) => {
|
||||
setReplyBody(coerceToString(next))
|
||||
}}
|
||||
/>
|
||||
</ConsumerModal>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -428,7 +428,7 @@ const SERVICE_ERROR_MESSAGES: Record<string, string> = {
|
||||
* already narrow to — `(err as { response?: { data?: unknown } })?.response?.data` — so it
|
||||
* is a drop-in replacement for the hand-copied
|
||||
* `extractServerErrorDetail((err as {...})?.response?.data)` block duplicated in
|
||||
* `EventEditForm.tsx`, `AdminEventDetail.tsx`, `EventCreateWizard.tsx`, and
|
||||
* `EventEditForm.tsx`, `AdminEventDetail.tsx`, and
|
||||
* `CreateSettlementModal.tsx`. Those call sites currently split the toast into a
|
||||
* context-specific `title` plus a `description` (server detail, falling back to custom copy
|
||||
* per call site); `getServiceErrorMessage`/`showServiceErrorToast` instead produce one
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Query-key factory for admin dashboard data.
|
||||
* Mirrors queries/consumerKeys.ts -- see frontend/docs/admin-caching.md.
|
||||
* See docs/admin-caching.md.
|
||||
*
|
||||
* `list()` covers PaginatedList's generic {url + page/pageSize/sort/filters}
|
||||
* shape. `listByUrl()` is the invalidation prefix: passing it to
|
||||
|
||||
17
queries/admin/useAdminCitiesQuery.ts
Normal file
17
queries/admin/useAdminCitiesQuery.ts
Normal file
@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { adminKeys } from '@/queries/admin/adminKeys'
|
||||
import { fetchAllCities } from '@/services/geography'
|
||||
|
||||
/** Cities for admin auth profile step. */
|
||||
const CITIES_STALE_MS = 5 * 60_000
|
||||
|
||||
export const useAdminCitiesQuery = (enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: adminKeys.cities(),
|
||||
enabled,
|
||||
queryFn: () => fetchAllCities(),
|
||||
staleTime: CITIES_STALE_MS,
|
||||
})
|
||||
@ -1,15 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { consumerKeys } from '@/queries/consumerKeys'
|
||||
import { discoveryReferenceStaleTime } from '@/services/discovery'
|
||||
import { fetchAllCities } from '@/services/geography'
|
||||
|
||||
export const useDiscoveryCitiesQuery = (enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: consumerKeys.discoveryCities(),
|
||||
enabled,
|
||||
queryFn: () => fetchAllCities(),
|
||||
staleTime: discoveryReferenceStaleTime,
|
||||
})
|
||||
@ -1,22 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { consumerKeys } from '@/queries/consumerKeys'
|
||||
import { unwrapService } from '@/queries/unwrapService'
|
||||
import { LIST_PUBLIC_CATEGORIES } from '@/services/eventCategories'
|
||||
|
||||
/**
|
||||
* Public category taxonomy is admin-managed and changes rarely.
|
||||
* Keep one shared staleTime here so every consumer of
|
||||
* `consumerKeys.publicCategories()` agrees (wizard, step 1, browse).
|
||||
*/
|
||||
export const PUBLIC_CATEGORIES_STALE_TIME_MS = 5 * 60_000
|
||||
|
||||
export const usePublicCategoriesQuery = (enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: consumerKeys.publicCategories(),
|
||||
enabled,
|
||||
queryFn: async () => unwrapService(await LIST_PUBLIC_CATEGORIES({ errorMode: 'parent' })),
|
||||
staleTime: PUBLIC_CATEGORIES_STALE_TIME_MS,
|
||||
})
|
||||
@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Query-key factory for shared cache keys still used by the admin app
|
||||
* (auth me-cache, public categories, discovery cities for profile).
|
||||
*/
|
||||
export const consumerKeys = {
|
||||
all: ['consumer'] as const,
|
||||
|
||||
me: () => [...consumerKeys.all, 'me'] as const,
|
||||
discoveryCities: () => [...consumerKeys.all, 'discovery', 'cities'] as const,
|
||||
publicCategories: () => [...consumerKeys.all, 'categories', 'public'] as const,
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import type { WalletTransaction } from '@/services/wallet'
|
||||
import type { WalletTransactionResponseDto } from '@/api/generated/models'
|
||||
import axiosInstance from '@/config/axios'
|
||||
import { API_ROUTES } from '@/services/config'
|
||||
import {
|
||||
@ -12,6 +12,8 @@ import {
|
||||
} from '@/services/errorHandler'
|
||||
import { unwrapApiData, type ApiResponse } from '@/services/apiResponse'
|
||||
|
||||
export type WalletTransaction = WalletTransactionResponseDto
|
||||
|
||||
export interface AdminCreditWalletPayload {
|
||||
/** Amount in Toman (integer). */
|
||||
amount: number
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { calculateCancellationPreview, resolveCancellationTicketPrice } from '@/services/bookings'
|
||||
|
||||
describe('booking cancellation preview', () => {
|
||||
const startsAt = '2026-09-08T12:00:00.000Z'
|
||||
const policy = {
|
||||
cancellationFeePercent: 10,
|
||||
cancellationFeePercent12To24Hours: 10,
|
||||
cancellationFeePercentMoreThan24Hours: 10,
|
||||
}
|
||||
|
||||
it('matches the backend floor-based commission formula', () => {
|
||||
// 7% mirrors the platform default (backend/src/common/constants/business.constants.ts#COMMISSION_PERCENT);
|
||||
// commissionPercent is always passed in from the booking's resolved rate, never hardcoded here.
|
||||
expect(calculateCancellationPreview(101_000, policy, 7, startsAt, new Date('2026-09-08T11:00:00Z'))).toEqual({
|
||||
ticketPrice: 101_000,
|
||||
cancellationFeePercent: 10,
|
||||
platformCommission: 707,
|
||||
commissionPercent: 7,
|
||||
hostCancellationFee: 9_393,
|
||||
refundToGuest: 90_900,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not produce fractional Toman values', () => {
|
||||
// A different rate here (5%) than the test above (7%) is deliberate — it
|
||||
// exercises an event-level commission override, not just the platform default.
|
||||
expect(
|
||||
calculateCancellationPreview(999, { ...policy, cancellationFeePercent: 7 }, 5, startsAt, new Date('2026-09-08T11:00:00Z'))
|
||||
).toEqual({
|
||||
ticketPrice: 999,
|
||||
cancellationFeePercent: 7,
|
||||
platformCommission: 3,
|
||||
commissionPercent: 5,
|
||||
hostCancellationFee: 66,
|
||||
refundToGuest: 930,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses post-discount paid amount so fees match refund.service gross', () => {
|
||||
// List 10_000, discount 2_000 → paid 8_000; 10% penalty → refund 7_200.
|
||||
expect(calculateCancellationPreview(8_000, policy, 7, startsAt, new Date('2026-09-08T11:00:00Z'))).toEqual({
|
||||
ticketPrice: 8_000,
|
||||
cancellationFeePercent: 10,
|
||||
platformCommission: 56,
|
||||
commissionPercent: 7,
|
||||
hostCancellationFee: 744,
|
||||
refundToGuest: 7_200,
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves ticket price from payableAmount when a discount was applied', () => {
|
||||
expect(resolveCancellationTicketPrice({ eventPrice: 10_000, payableAmount: 8_000 })).toBe(8_000)
|
||||
expect(resolveCancellationTicketPrice({ eventPrice: 10_000, payableAmount: null })).toBe(10_000)
|
||||
})
|
||||
})
|
||||
@ -1,373 +0,0 @@
|
||||
import type { BookingResponseDto, WaitlistResponseDto, WaitlistStatus } from '@/api/generated/models'
|
||||
import {
|
||||
errorResult,
|
||||
handleServiceError,
|
||||
type ServiceCallOptions,
|
||||
type ServiceResult,
|
||||
shouldBubbleErrorToParent,
|
||||
successResult,
|
||||
} from '@/services/errorHandler'
|
||||
import { unwrapApiData } from '@/services/apiResponse'
|
||||
import { getBookings } from '@/api/generated/bookings/bookings'
|
||||
import { getWaitlist } from '@/api/generated/waitlist/waitlist'
|
||||
import { unwrapApiPayload } from '@/helpers/listResponse'
|
||||
import { ANALYTICS_EVENTS, trackAnalyticsEventOnce } from '@/lib/analytics'
|
||||
import { getEventAttribution } from '@/lib/trafficAttribution'
|
||||
import { texts } from '@/texts'
|
||||
|
||||
// Shape mirrors the admin `bookingsColumns` fields used for the same
|
||||
// underlying entity in app/(dashboard)/users/[id]/page.tsx (bookingCode,
|
||||
// eventTitle, status, checkedInAt, createdAt) — plus `id`/`eventId`, which
|
||||
// this self-service list needs for keys and the booking-detail modal.
|
||||
// The `event*` fields come from bookingSelect's event relation
|
||||
// (backend/src/modules/bookings/bookings.select.ts) — added alongside this
|
||||
// service so the list + detail views don't need a second round-trip to
|
||||
// /events/:id for title/price/fee.
|
||||
export type MyBooking = BookingResponseDto
|
||||
|
||||
export type MyWaitlistEntry = WaitlistResponseDto
|
||||
|
||||
// Waitlist entries still considered "in progress" for this event — mirrors
|
||||
// the DB constraint in waitlist.service.ts (assertNoActiveWaitlist).
|
||||
const ACTIVE_WAITLIST_STATUSES: WaitlistStatus[] = ['waiting', 'notified', 'accepted']
|
||||
const bookingsApi = getBookings()
|
||||
const waitlistApi = getWaitlist()
|
||||
|
||||
export const GET_MY_BOOKINGS = async (options?: ServiceCallOptions): Promise<ServiceResult<MyBooking[]>> => {
|
||||
try {
|
||||
const res = await bookingsApi.bookingsControllerListMine({ page: 1, pageSize: 100 })
|
||||
const payload = unwrapApiPayload(res.data)
|
||||
const items = Array.isArray(payload) ? payload : Array.isArray(payload.items) ? payload.items : []
|
||||
|
||||
return successResult(items as MyBooking[])
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
/** Guest's own active (pending_payment/confirmed) booking for one event, if any — event-booking.md entry checks. */
|
||||
export const GET_MY_ACTIVE_BOOKING_FOR_EVENT = async (
|
||||
eventId: string,
|
||||
options?: ServiceCallOptions
|
||||
): Promise<ServiceResult<MyBooking | null>> => {
|
||||
try {
|
||||
// At most one active booking per (user, event); list endpoint still
|
||||
// requires pagination, so pageSize=1 is enough for this lookup.
|
||||
const res = await bookingsApi.bookingsControllerListMine({
|
||||
filters: { eventId },
|
||||
pageSize: 1,
|
||||
sort: '-createdAt',
|
||||
})
|
||||
const payload = unwrapApiPayload(res.data)
|
||||
const items = (Array.isArray(payload) ? payload : Array.isArray(payload.items) ? payload.items : []) as MyBooking[]
|
||||
const latest = items[0]
|
||||
const active = latest && (latest.status === 'pending_payment' || latest.status === 'confirmed') ? latest : null
|
||||
|
||||
return successResult(active)
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
/** Guest's own active waitlist entry for one event, if any — event-booking.md Path B. */
|
||||
export const GET_MY_WAITLIST_ENTRY_FOR_EVENT = async (
|
||||
eventId: string,
|
||||
options?: ServiceCallOptions
|
||||
): Promise<ServiceResult<MyWaitlistEntry | null>> => {
|
||||
try {
|
||||
// At most one active waitlist row per (user, event); pageSize=1 is enough.
|
||||
const res = await waitlistApi.waitlistControllerListMine({
|
||||
filters: { eventId },
|
||||
pageSize: 1,
|
||||
sort: '-createdAt',
|
||||
})
|
||||
const payload = unwrapApiPayload(res.data)
|
||||
const items = (Array.isArray(payload) ? payload : Array.isArray(payload.items) ? payload.items : []) as MyWaitlistEntry[]
|
||||
const latest = items[0]
|
||||
const active = latest && ACTIVE_WAITLIST_STATUSES.includes(latest.status) ? latest : null
|
||||
|
||||
return successResult(active)
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /events/:eventId/bookings — direct reserve path (event-booking.md, Path A). */
|
||||
export const CREATE_BOOKING = async (eventId: string, options?: ServiceCallOptions): Promise<ServiceResult<MyBooking>> => {
|
||||
try {
|
||||
const attributionSource = getEventAttribution(eventId)
|
||||
const res = await bookingsApi.bookingsControllerCreate(eventId, {
|
||||
...(attributionSource ? { attributionSource } : {}),
|
||||
})
|
||||
const data = res.data.success ? unwrapApiData(res.data) : undefined
|
||||
|
||||
if (data?.id) {
|
||||
trackAnalyticsEventOnce(ANALYTICS_EVENTS.BOOKING_CREATED, data.id, {
|
||||
booking_id: data.id,
|
||||
event_id: eventId,
|
||||
booking_status: data.status,
|
||||
...(attributionSource ? { traffic_source: attributionSource } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error(texts.bookings.emptyServerResponse)
|
||||
}
|
||||
|
||||
return successResult(data)
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /events/:eventId/waitlist — join waitlist path (event-booking.md, Path B). */
|
||||
export const JOIN_WAITLIST = async (eventId: string, options?: ServiceCallOptions): Promise<ServiceResult<MyWaitlistEntry>> => {
|
||||
try {
|
||||
const attributionSource = getEventAttribution(eventId)
|
||||
const res = await waitlistApi.waitlistControllerJoin(eventId, {
|
||||
...(attributionSource ? { attributionSource } : {}),
|
||||
})
|
||||
const data = res.data.success ? unwrapApiData(res.data) : undefined
|
||||
|
||||
if (!data) {
|
||||
throw new Error(texts.bookings.emptyServerResponse)
|
||||
}
|
||||
|
||||
return successResult(data)
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /events/:eventId/waitlist — host-only view of the waiting queue for
|
||||
* their own event, primarily used to hand-pick who gets an opened seat when
|
||||
* `settings.waitlistAutoOffer` is `false`.
|
||||
*/
|
||||
export const GET_EVENT_WAITLIST = async (eventId: string, options?: ServiceCallOptions): Promise<ServiceResult<MyWaitlistEntry[]>> => {
|
||||
try {
|
||||
const res = await waitlistApi.waitlistControllerListForEvent(eventId, { pageSize: 100 })
|
||||
const payload = unwrapApiPayload(res.data)
|
||||
const items = (Array.isArray(payload) ? payload : Array.isArray(payload.items) ? payload.items : []) as MyWaitlistEntry[]
|
||||
|
||||
return successResult(items)
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /events/:eventId/waitlist/:entryId/offer — host manually offers a seat to a chosen entry. */
|
||||
export const OFFER_WAITLIST_SEAT = async (
|
||||
eventId: string,
|
||||
entryId: string,
|
||||
options?: ServiceCallOptions
|
||||
): Promise<ServiceResult<MyWaitlistEntry>> => {
|
||||
try {
|
||||
const res = await waitlistApi.waitlistControllerOfferToEntry(eventId, entryId)
|
||||
const data = res.data.success ? unwrapApiData(res.data) : undefined
|
||||
|
||||
if (!data) {
|
||||
throw new Error(texts.bookings.emptyServerResponse)
|
||||
}
|
||||
|
||||
return successResult(data)
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
/** PATCH /waitlist/:id/accept — guest accepts a notified offer and receives the pending booking for checkout. */
|
||||
export const ACCEPT_WAITLIST = async (
|
||||
entryId: string,
|
||||
options?: ServiceCallOptions
|
||||
): Promise<ServiceResult<{ waitlistEntryId: string; booking: MyBooking }>> => {
|
||||
try {
|
||||
const res = await waitlistApi.waitlistControllerAccept(entryId)
|
||||
const data = res.data.success ? unwrapApiData(res.data) : undefined
|
||||
|
||||
if (!data?.booking) {
|
||||
throw new Error(texts.bookings.emptyServerResponse)
|
||||
}
|
||||
|
||||
return successResult({ waitlistEntryId: data.waitlistEntryId, booking: data.booking as MyBooking })
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /bookings/:id — single booking detail (owner only). */
|
||||
export const GET_BOOKING = async (id: string, options?: ServiceCallOptions): Promise<ServiceResult<MyBooking>> => {
|
||||
try {
|
||||
const res = await bookingsApi.bookingsControllerFindOne(id)
|
||||
const data = res.data.success ? unwrapApiData(res.data) : undefined
|
||||
|
||||
if (!data) {
|
||||
throw new Error(texts.bookings.emptyServerResponse)
|
||||
}
|
||||
|
||||
return successResult(data)
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
/** PATCH /bookings/:id/cancel — guest-initiated cancel with mandatory reason (booking-cancellation-refund.md). */
|
||||
export const CANCEL_BOOKING = async (
|
||||
id: string,
|
||||
cancellationReason: string,
|
||||
options?: ServiceCallOptions
|
||||
): Promise<ServiceResult<MyBooking>> => {
|
||||
try {
|
||||
const res = await bookingsApi.bookingsControllerCancel(id, { cancellationReason })
|
||||
const data = res.data.success ? unwrapApiData(res.data) : undefined
|
||||
|
||||
if (!data) {
|
||||
throw new Error(texts.bookings.emptyServerResponse)
|
||||
}
|
||||
|
||||
trackAnalyticsEventOnce(ANALYTICS_EVENTS.BOOKING_CANCELLED, data.id, {
|
||||
booking_id: data.id,
|
||||
event_id: data.eventId,
|
||||
booking_status: data.status,
|
||||
})
|
||||
|
||||
return successResult(data)
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side guest-cancel refund preview — booking-cancellation-refund.md
|
||||
* formula: refund = paidGross - selected cancellation fee. The platform
|
||||
* commission is taken only from that fee and the remainder belongs to the
|
||||
* host after the event completes.
|
||||
* Mirrors refund.service.ts#refundGuestCancellation exactly (same
|
||||
* floor() rounding on each component) so this preview never shows a
|
||||
* different number than what's actually credited.
|
||||
*
|
||||
* `ticketPrice` must be the **amount actually paid** (`payments.total_amount`
|
||||
* / `MyBooking.payableAmount`), not the list `eventPrice`. When a discount
|
||||
* code was applied, those diverge — using list price overstates commission,
|
||||
* host fee, and the toast refund. Callers: `payableAmount ?? eventPrice`.
|
||||
*
|
||||
* `commissionPercent` must be the booking's resolved rate
|
||||
* (`MyBooking.eventEffectiveCommissionPercent` — event override if set, else
|
||||
* the platform default; see bookings.service.ts#mapBooking on the backend)
|
||||
* and NOT a locally hardcoded constant: the platform default has changed
|
||||
* before (COMMISSION_PERCENT, backend/src/common/constants/business.constants.ts)
|
||||
* and events can carry their own override, so any constant duplicated here
|
||||
* silently drifts from what actually gets charged. Only meaningful for a
|
||||
* `confirmed` (paid) booking; `pending_payment` has nothing to refund
|
||||
* (never actually paid).
|
||||
*/
|
||||
export interface CancellationPreview {
|
||||
ticketPrice: number
|
||||
platformCommission: number
|
||||
/** The rate actually applied, echoed back so callers can label the commission line without re-touching the booking. */
|
||||
commissionPercent: number
|
||||
cancellationFeePercent: number
|
||||
hostCancellationFee: number
|
||||
refundToGuest: number
|
||||
}
|
||||
|
||||
export interface CancellationFeePolicy {
|
||||
cancellationFeePercent: number
|
||||
cancellationFeePercent12To24Hours: number
|
||||
cancellationFeePercentMoreThan24Hours: number
|
||||
}
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
|
||||
export const resolveCancellationFeePercent = (
|
||||
policy: CancellationFeePolicy,
|
||||
eventStartsAt: string | Date,
|
||||
cancelledAt: Date = new Date()
|
||||
): number => {
|
||||
const remainingMs = new Date(eventStartsAt).getTime() - cancelledAt.getTime()
|
||||
|
||||
if (remainingMs <= 12 * HOUR_MS) return policy.cancellationFeePercent
|
||||
if (remainingMs <= 24 * HOUR_MS) return policy.cancellationFeePercent12To24Hours
|
||||
|
||||
return policy.cancellationFeePercentMoreThan24Hours
|
||||
}
|
||||
|
||||
export const calculateCancellationPreview = (
|
||||
ticketPrice: number,
|
||||
policy: CancellationFeePolicy,
|
||||
commissionPercent: number,
|
||||
eventStartsAt: string | Date,
|
||||
cancelledAt: Date = new Date()
|
||||
): CancellationPreview => {
|
||||
const cancellationFeePercent = resolveCancellationFeePercent(policy, eventStartsAt, cancelledAt)
|
||||
const grossCancellationFee = Math.floor(ticketPrice * (cancellationFeePercent / 100))
|
||||
const platformCommission = Math.floor(grossCancellationFee * (commissionPercent / 100))
|
||||
const hostCancellationFee = grossCancellationFee - platformCommission
|
||||
const refundToGuest = ticketPrice - grossCancellationFee
|
||||
|
||||
return { ticketPrice, platformCommission, commissionPercent, cancellationFeePercent, hostCancellationFee, refundToGuest }
|
||||
}
|
||||
|
||||
/** Gross base for guest-cancel preview — paid amount after discount, else list price. */
|
||||
export const resolveCancellationTicketPrice = (booking: Pick<MyBooking, 'eventPrice' | 'payableAmount'>): number =>
|
||||
booking.payableAmount ?? booking.eventPrice
|
||||
@ -20,15 +20,8 @@ export const API_ROUTES = {
|
||||
ADMIN_REVISION_PENDING: (id: string) => `admin/events/${id}/revisions/pending`,
|
||||
ADMIN_REVISION_APPROVE: (id: string, revisionId: string) => `admin/events/${id}/revisions/${revisionId}/approve`,
|
||||
ADMIN_REVISION_REJECT: (id: string, revisionId: string) => `admin/events/${id}/revisions/${revisionId}/reject`,
|
||||
DETAIL: (id: string) => `events/${id}`,
|
||||
FOR_EDIT: (id: string) => `events/${id}/for-edit`,
|
||||
DELETE: (id: string) => `events/${id}`,
|
||||
OWNER_INSIGHTS: (id: string) => `events/${id}/owner-insights`,
|
||||
RESERVED_CAPACITY: (id: string) => `events/${id}/reserved-capacity`,
|
||||
ADMIN_RESERVED_CAPACITY: (id: string) => `admin/events/${id}/reserved-capacity`,
|
||||
REVISION_SUBMIT: (id: string) => `events/${id}/revisions`,
|
||||
REVISION_PENDING: (id: string) => `events/${id}/revisions/pending`,
|
||||
REVISION_WITHDRAW: (id: string) => `events/${id}/revisions/pending`,
|
||||
},
|
||||
EVENT_CATEGORIES: { ADMIN_FLAT: 'admin/event-categories/flat' },
|
||||
GEOGRAPHY: { ALL_CITIES: 'geography/cities', PROVINCES: 'geography/provinces' },
|
||||
@ -39,8 +32,6 @@ export const API_ROUTES = {
|
||||
ADMIN_REJECT: (id: string) => `admin/identity/verifications/${id}/reject`,
|
||||
ADMIN_JIBIT_INQUIRY: (id: string) => `admin/identity/verifications/${id}/jibit-inquiry`,
|
||||
REJECTION_REASONS: 'identity/rejection-reasons',
|
||||
REQUEST_OTP: 'identity/verifications/request-otp',
|
||||
CONFIRM_OTP: 'identity/verifications/confirm-otp',
|
||||
},
|
||||
PAYMENTS: { ADMIN_LIST: 'admin/payments' },
|
||||
WALLET_DEPOSITS: { ADMIN_LIST: 'admin/wallet-deposits' },
|
||||
@ -80,13 +71,9 @@ export const API_ROUTES = {
|
||||
ADMIN_BLOCK: (id: string) => `admin/user-reports/${id}/block`,
|
||||
},
|
||||
CONTACT_MESSAGES: {
|
||||
CREATE: 'contact-messages',
|
||||
ADMIN_LIST: 'admin/contact-messages',
|
||||
ADMIN_READ: (id: string) => `admin/contact-messages/${id}/read`,
|
||||
},
|
||||
TRAFFIC_VISITS: {
|
||||
CREATE: 'traffic-visits',
|
||||
},
|
||||
SUPPORT_TICKETS: {
|
||||
ADMIN_LIST: 'admin/support-tickets',
|
||||
ADMIN_DETAIL: (id: string) => `admin/support-tickets/${id}`,
|
||||
|
||||
@ -2,12 +2,10 @@ import type {
|
||||
BulkCreateDiscountCodesResponseDto,
|
||||
CreateDiscountCodesDto,
|
||||
DiscountCodeResponseDto,
|
||||
DiscountPreviewResponseDto,
|
||||
DiscountRedemptionResponseDto,
|
||||
DiscountReportSummaryDto,
|
||||
} from '@/api/generated/models'
|
||||
import { getDiscountCodes } from '@/api/generated/discount-codes/discount-codes'
|
||||
import { getFinancial } from '@/api/generated/financial/financial'
|
||||
import axiosInstance from '@/config/axios'
|
||||
import { parseRemittanceList, unwrapApiPayload } from '@/helpers/listResponse'
|
||||
import { texts } from '@/texts'
|
||||
@ -25,7 +23,6 @@ export type DiscountCode = DiscountCodeResponseDto
|
||||
export type DiscountRedemption = DiscountRedemptionResponseDto
|
||||
export type DiscountReport = DiscountReportSummaryDto
|
||||
export type DiscountBulkResult = BulkCreateDiscountCodesResponseDto
|
||||
export type DiscountPreview = DiscountPreviewResponseDto
|
||||
export type DiscountType = CreateDiscountCodesDto['type']
|
||||
export type DiscountBearer = NonNullable<CreateDiscountCodesDto['bearer']>
|
||||
|
||||
@ -59,7 +56,6 @@ export interface BulkCreateDiscountInput {
|
||||
}
|
||||
|
||||
const discountApi = getDiscountCodes()
|
||||
const financialApi = getFinancial()
|
||||
|
||||
const wrap = async <T>(run: () => Promise<T>, options?: ServiceCallOptions): Promise<ServiceResult<T>> => {
|
||||
try {
|
||||
@ -173,13 +169,3 @@ export const DELETE_DISCOUNT_CODE = (codeId: string, options?: ServiceCallOption
|
||||
wrap(async () => {
|
||||
await discountApi.discountCodesControllerRemove(codeId)
|
||||
}, options)
|
||||
|
||||
export const PREVIEW_DISCOUNT = (bookingId: string, code: string, options?: ServiceCallOptions): Promise<ServiceResult<DiscountPreview>> =>
|
||||
wrap(async () => {
|
||||
const res = await financialApi.paymentsControllerPreviewDiscount(bookingId, { code: code.trim() })
|
||||
const data = unwrapApiPayload<DiscountPreview>(res.data)
|
||||
|
||||
if (!data?.code) throw createServiceError(texts.events.discountCodeInvalid)
|
||||
|
||||
return data
|
||||
}, options)
|
||||
|
||||
@ -1,230 +0,0 @@
|
||||
import type {
|
||||
DiscoveryCategorySummaryDto,
|
||||
HomeCategoryPreviewEventDto,
|
||||
HomeFeedResponseDto,
|
||||
HomePopularEventDto,
|
||||
} from '@/api/generated/models'
|
||||
import { getEvents } from '@/api/generated/events/events'
|
||||
import { DISCOVERY_PAGE_SIZE } from '@/constants/discovery'
|
||||
import axiosInstance from '@/config/axios'
|
||||
import type { City } from '@/services/geography'
|
||||
import {
|
||||
errorResult,
|
||||
handleServiceError,
|
||||
type ServiceCallOptions,
|
||||
type ServiceResult,
|
||||
shouldBubbleErrorToParent,
|
||||
successResult,
|
||||
} from '@/services/errorHandler'
|
||||
import { getEventCategories } from '@/api/generated/event-categories/event-categories'
|
||||
import { unwrapApiPayload } from '@/helpers/listResponse'
|
||||
import type { EventStatus } from '@/types/status'
|
||||
|
||||
/** Lightweight host name on public discovery cards. */
|
||||
export interface DiscoveryEventOrganizer {
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Public discovery event shape (GET /events).
|
||||
* Hand-written so type-aware ESLint resolves it while `api/generated` stays eslint-ignored.
|
||||
*/
|
||||
export interface DiscoveryEvent {
|
||||
id: string
|
||||
organizerId: string
|
||||
categoryId: number
|
||||
clonedFromEventId: string | null
|
||||
title: string
|
||||
slug: string
|
||||
shortDescription: string | null
|
||||
description: string | null
|
||||
startsAt: string
|
||||
endsAt: string
|
||||
provinceId: number
|
||||
cityId: number
|
||||
address: string | null
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
isFree: boolean
|
||||
price: number
|
||||
capacity: number
|
||||
bookedCount: number
|
||||
reservedCapacity: number
|
||||
cancellationFeePercent: number
|
||||
cancellationFeePercent12To24Hours: number
|
||||
cancellationFeePercentMoreThan24Hours: number
|
||||
commissionPercent: number | null
|
||||
effectiveCommissionPercent: number
|
||||
status: EventStatus
|
||||
isFeatured: boolean
|
||||
publishedAt: string | null
|
||||
adminApprovedAt: string | null
|
||||
rejectionReason: string | null
|
||||
avgRating?: number | null
|
||||
reviewsCount: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
posterUrl: string | null
|
||||
squarePosterUrl: string | null
|
||||
organizer: DiscoveryEventOrganizer
|
||||
}
|
||||
|
||||
export type DiscoveryCategory = DiscoveryCategorySummaryDto
|
||||
export type HomePopularEvent = HomePopularEventDto
|
||||
export type HomeCategoryPreviewEvent = HomeCategoryPreviewEventDto
|
||||
export type HomeFeed = HomeFeedResponseDto
|
||||
|
||||
export interface DiscoveryFilters {
|
||||
categoryIds?: number[]
|
||||
cityId?: number
|
||||
provinceId?: number
|
||||
startsAtFrom?: string
|
||||
startsAtTo?: string
|
||||
}
|
||||
|
||||
export interface DiscoveryListResult {
|
||||
items: DiscoveryEvent[]
|
||||
totalItemsCount: number
|
||||
}
|
||||
|
||||
export interface DiscoveryPagination {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
sort?: string
|
||||
}
|
||||
|
||||
export interface HomeFeedQuery {
|
||||
cityId?: number
|
||||
}
|
||||
|
||||
export interface DiscoveryBootstrap {
|
||||
categories: DiscoveryCategory[]
|
||||
cities: City[]
|
||||
}
|
||||
|
||||
const eventsApi = getEvents()
|
||||
const eventCategoriesApi = getEventCategories()
|
||||
|
||||
// Cities and categories are shared public reference data, but are still
|
||||
// admin-managed. A finite window lets long-lived tabs see taxonomy changes
|
||||
// after focus/reconnect instead of keeping them stale until a deploy.
|
||||
const REFERENCE_STALE_MS = 5 * 60_000
|
||||
const HOME_FEED_STALE_MS = 60_000
|
||||
|
||||
export const discoveryReferenceStaleTime = REFERENCE_STALE_MS
|
||||
export const discoveryHomeFeedStaleTime = HOME_FEED_STALE_MS
|
||||
|
||||
export function homeCategoryPreviewMap(feed: HomeFeed | undefined): Map<number, HomeCategoryPreviewEvent[]> {
|
||||
const map = new Map<number, HomeCategoryPreviewEvent[]>()
|
||||
|
||||
for (const group of feed?.categoryPreviews ?? []) {
|
||||
map.set(group.categoryId, group.items)
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
export async function fetchDiscoveryCities(): Promise<City[]> {
|
||||
const response = await eventsApi.discoveryBootstrapControllerListCities()
|
||||
const payload = unwrapApiPayload<City[]>(response.data)
|
||||
|
||||
return payload ?? []
|
||||
}
|
||||
|
||||
export async function fetchDiscoveryCategories(): Promise<DiscoveryCategory[]> {
|
||||
const response = await eventsApi.discoveryBootstrapControllerListCategories()
|
||||
const payload = unwrapApiPayload<DiscoveryCategory[]>(response.data)
|
||||
|
||||
return payload ?? []
|
||||
}
|
||||
|
||||
export async function fetchHomeFeed(query: HomeFeedQuery = {}, options?: ServiceCallOptions): Promise<ServiceResult<HomeFeed>> {
|
||||
try {
|
||||
const response = await eventsApi.discoveryBootstrapControllerGetHomeFeed({
|
||||
cityId: query.cityId,
|
||||
})
|
||||
const payload = unwrapApiPayload<HomeFeed>(response.data)
|
||||
|
||||
if (!payload) {
|
||||
throw new Error('Home feed fetch returned no data')
|
||||
}
|
||||
|
||||
return successResult(payload)
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Prefer `fetchDiscoveryCities` + `fetchDiscoveryCategories`. */
|
||||
export async function fetchDiscoveryBootstrap(): Promise<DiscoveryBootstrap> {
|
||||
const response = await axiosInstance.get('discovery/bootstrap')
|
||||
const payload = unwrapApiPayload<DiscoveryBootstrap>(response.data)
|
||||
|
||||
return payload ?? { categories: [], cities: [] }
|
||||
}
|
||||
|
||||
/** Full category list via event-categories module (admin-adjacent surfaces). */
|
||||
export async function fetchEventCategoriesList(options?: ServiceCallOptions): Promise<ServiceResult<DiscoveryCategory[]>> {
|
||||
try {
|
||||
const response = await eventCategoriesApi.eventCategoriesControllerList()
|
||||
const payload = unwrapApiPayload(response.data)
|
||||
|
||||
return successResult(Array.isArray(payload) ? (payload as DiscoveryCategory[]) : [])
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
export async function fetchDiscoveryEvents(
|
||||
filters: DiscoveryFilters,
|
||||
paginationOptions: DiscoveryPagination = {},
|
||||
options?: ServiceCallOptions
|
||||
): Promise<ServiceResult<DiscoveryListResult>> {
|
||||
try {
|
||||
const params: {
|
||||
page: number
|
||||
pageSize: number
|
||||
sort?: string
|
||||
filters: Record<string, string>
|
||||
} = {
|
||||
page: paginationOptions.page ?? 1,
|
||||
pageSize: paginationOptions.pageSize ?? DISCOVERY_PAGE_SIZE,
|
||||
filters: {},
|
||||
}
|
||||
|
||||
if (paginationOptions.sort) params.sort = paginationOptions.sort
|
||||
if (filters.categoryIds?.length) params.filters.categoryId = filters.categoryIds.join(',')
|
||||
if (filters.cityId !== undefined) params.filters.cityId = String(filters.cityId)
|
||||
if (filters.provinceId !== undefined) params.filters.provinceId = String(filters.provinceId)
|
||||
if (filters.startsAtFrom) params.filters.startsAtFrom = filters.startsAtFrom
|
||||
if (filters.startsAtTo) params.filters.startsAtTo = filters.startsAtTo
|
||||
|
||||
const response = await eventsApi.eventsControllerDiscovery(params)
|
||||
const body = response.data as { data?: { items?: DiscoveryEvent[]; response?: { totalItemsCount?: number } } }
|
||||
const data = body.data ?? {}
|
||||
const items = Array.isArray(data.items) ? data.items : []
|
||||
const totalItemsCount = data.response?.totalItemsCount ?? items.length
|
||||
|
||||
return successResult({ items, totalItemsCount })
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import type { CreateEventCategoryDto, EventCategoryResponseDto } from '@/api/generated/models'
|
||||
import type { CreateEventCategoryDto, EventCategoryResponseDto, EventCategorySummaryResponseDto } from '@/api/generated/models'
|
||||
import {
|
||||
createServiceError,
|
||||
errorResult,
|
||||
@ -10,26 +10,20 @@ import {
|
||||
} from '@/services/errorHandler'
|
||||
import { unwrapApiData } from '@/services/apiResponse'
|
||||
import { getAdminEventCategories } from '@/api/generated/admin-event-categories/admin-event-categories'
|
||||
import { getEventCategories } from '@/api/generated/event-categories/event-categories'
|
||||
import { texts } from '@/texts'
|
||||
|
||||
export type EventCategory = EventCategoryResponseDto
|
||||
export type EventCategorySummary = EventCategorySummaryResponseDto
|
||||
export type EventCategoryPayload = CreateEventCategoryDto
|
||||
|
||||
const eventCategoriesApi = getEventCategories()
|
||||
const adminEventCategoriesApi = getAdminEventCategories()
|
||||
|
||||
/**
|
||||
* Public, unpaginated flat list of *active* categories — `GET /event-categories`
|
||||
* (see backend/src/modules/event-categories/event-categories.controller.ts).
|
||||
* Used by the consumer app's categories tab; unlike GET_CATEGORY_CHILDREN
|
||||
* below, this returns every active category in one call (parentId included
|
||||
* per row), so the caller builds the tree client-side instead of fetching
|
||||
* level-by-level.
|
||||
*/
|
||||
export const LIST_PUBLIC_CATEGORIES = async (options?: ServiceCallOptions): Promise<ServiceResult<{ items: EventCategory[] }>> => {
|
||||
/** Flat admin category list for filters/selects (id + name). */
|
||||
export const LIST_ADMIN_CATEGORIES_FLAT = async (
|
||||
options?: ServiceCallOptions
|
||||
): Promise<ServiceResult<{ items: EventCategorySummary[] }>> => {
|
||||
try {
|
||||
const res = await eventCategoriesApi.eventCategoriesControllerList()
|
||||
const res = await adminEventCategoriesApi.adminEventCategoriesControllerListFlat()
|
||||
const payload = res.data
|
||||
|
||||
if (!payload.success) {
|
||||
@ -52,10 +46,7 @@ export const LIST_PUBLIC_CATEGORIES = async (options?: ServiceCallOptions): Prom
|
||||
|
||||
/**
|
||||
* One "level" of the category tree: every direct child of `parentId`
|
||||
* (or every root category, when `parentId` is null). Page size is fixed
|
||||
* at the backend's max (100) — there's no pagination UI within a tree
|
||||
* level; if an organizer ever needs more than 100 siblings under one
|
||||
* parent, that's a real product problem, not something to paginate away.
|
||||
* (or every root category, when `parentId` is null).
|
||||
*/
|
||||
export const GET_CATEGORY_CHILDREN = async (
|
||||
parentId: number | null,
|
||||
|
||||
@ -9,8 +9,8 @@ export type EventFaq = EventFaqResponseDto
|
||||
const eventExtrasApi = getEventExtras()
|
||||
|
||||
/**
|
||||
* `GET /events/:eventId/media` — public, unpaginated, ordered by `sortOrder` asc.
|
||||
* See docs/workflows/fa/event-discovery.md, stage 3.
|
||||
* Shared public list endpoints — backend allows admin reads for event detail/edit.
|
||||
* No dedicated admin media/FAQ list routes exist in OpenAPI.
|
||||
*/
|
||||
export async function fetchEventMedia(eventId: string): Promise<EventMedia[]> {
|
||||
const response = await eventExtrasApi.eventMediaControllerList(eventId)
|
||||
@ -19,9 +19,6 @@ export async function fetchEventMedia(eventId: string): Promise<EventMedia[]> {
|
||||
return Array.isArray(payload) ? (payload as EventMedia[]) : []
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /events/:eventId/faqs` — public, unpaginated, ordered by `sortOrder` asc.
|
||||
*/
|
||||
export async function fetchEventFaqs(eventId: string): Promise<EventFaq[]> {
|
||||
const response = await eventExtrasApi.eventFaqsControllerList(eventId)
|
||||
const payload = unwrapApiPayload(response.data)
|
||||
|
||||
@ -1,36 +1,22 @@
|
||||
import type { GuestListLinkResponseDto, InvitedGuestResponseDto } from '@/api/generated/models'
|
||||
import { getAdminEventExtras } from '@/api/generated/admin-event-extras/admin-event-extras'
|
||||
import { getEventExtras } from '@/api/generated/event-extras/event-extras'
|
||||
import { unwrapApiPayload } from '@/helpers/listResponse'
|
||||
|
||||
export type EventGuestListAccessMode = 'admin' | 'owner'
|
||||
export type EventGuestListLink = GuestListLinkResponseDto
|
||||
export type EventInvitedGuest = InvitedGuestResponseDto
|
||||
|
||||
const adminEventExtrasApi = getAdminEventExtras()
|
||||
const eventExtrasApi = getEventExtras()
|
||||
|
||||
export async function fetchEventGuestListLinks(eventId: string, accessMode: EventGuestListAccessMode): Promise<EventGuestListLink[]> {
|
||||
const response =
|
||||
accessMode === 'admin'
|
||||
? await adminEventExtrasApi.adminEventGuestListLinksControllerList(eventId)
|
||||
: await eventExtrasApi.eventGuestListLinksControllerList(eventId)
|
||||
|
||||
export async function fetchEventGuestListLinks(eventId: string): Promise<EventGuestListLink[]> {
|
||||
const response = await adminEventExtrasApi.adminEventGuestListLinksControllerList(eventId)
|
||||
const payload = unwrapApiPayload<EventGuestListLink[]>(response.data)
|
||||
|
||||
return Array.isArray(payload) ? payload : []
|
||||
}
|
||||
|
||||
export async function fetchEventInvitedGuests(eventId: string, accessMode: EventGuestListAccessMode): Promise<EventInvitedGuest[]> {
|
||||
if (accessMode === 'admin') {
|
||||
export async function fetchEventInvitedGuests(eventId: string): Promise<EventInvitedGuest[]> {
|
||||
const response = await adminEventExtrasApi.adminEventInvitedGuestsControllerGet(eventId)
|
||||
const payload = unwrapApiPayload<EventInvitedGuest[]>(response.data)
|
||||
|
||||
return Array.isArray(payload) ? payload : []
|
||||
}
|
||||
|
||||
const response = await eventExtrasApi.eventGuestListLinksControllerGetInvitedGuests(eventId)
|
||||
const payload = unwrapApiPayload<EventInvitedGuest[]>(response.data)
|
||||
|
||||
return Array.isArray(payload) ? payload : []
|
||||
}
|
||||
|
||||
@ -1,25 +1,7 @@
|
||||
import type {
|
||||
EventAttendeeFilter,
|
||||
EventAttendeeResponseDto,
|
||||
MyEventsControllerAttendees200,
|
||||
OwnerEventInsightsResponseDto,
|
||||
} from '@/api/generated/models'
|
||||
import type { OwnerEventInsightsResponseDto } from '@/api/generated/models'
|
||||
import { getAdminBookings } from '@/api/generated/admin-bookings/admin-bookings'
|
||||
import { getAdminEvents } from '@/api/generated/admin-events/admin-events'
|
||||
import { getBookings } from '@/api/generated/bookings/bookings'
|
||||
import { getMyEvents } from '@/api/generated/my-events/my-events'
|
||||
import { unwrapApiPayload } from '@/helpers/listResponse'
|
||||
import axiosInstance from '@/config/axios'
|
||||
import type { CreatedEvent } from '@/services/events'
|
||||
import type { EventFaq, EventMedia } from '@/services/eventDetail'
|
||||
|
||||
export type EventAttendee = Omit<EventAttendeeResponseDto, 'firstName' | 'lastName' | 'confirmedAt' | 'checkedInAt' | 'noShowAt'> & {
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
confirmedAt: string | null
|
||||
checkedInAt: string | null
|
||||
noShowAt: string | null
|
||||
}
|
||||
|
||||
/** Extended until OpenAPI client is regenerated with traffic insights. */
|
||||
export type EventManagementInsights = OwnerEventInsightsResponseDto & {
|
||||
@ -28,41 +10,10 @@ export type EventManagementInsights = OwnerEventInsightsResponseDto & {
|
||||
telegram: { uniqueVisits: number; confirmedBookings: number }
|
||||
}
|
||||
}
|
||||
export type AttendanceFilter = EventAttendeeFilter
|
||||
|
||||
export interface EventManagementBootstrap {
|
||||
event: CreatedEvent
|
||||
insights: EventManagementInsights
|
||||
categoryName: string
|
||||
cityName: string
|
||||
provinceName: string
|
||||
media: EventMedia[]
|
||||
faqs: EventFaq[]
|
||||
}
|
||||
|
||||
export interface EventAttendeePage {
|
||||
items: EventAttendee[]
|
||||
totalItemsCount: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
const myEventsApi = getMyEvents()
|
||||
const bookingsApi = getBookings()
|
||||
const adminEventsApi = getAdminEvents()
|
||||
const adminBookingsApi = getAdminBookings()
|
||||
|
||||
export async function fetchEventManagementInsights(eventId: string): Promise<EventManagementInsights> {
|
||||
const response = await myEventsApi.myEventsControllerOwnerInsights(eventId)
|
||||
|
||||
return unwrapApiPayload<EventManagementInsights>(response.data)
|
||||
}
|
||||
|
||||
export async function fetchEventManagementBootstrap(eventId: string): Promise<EventManagementBootstrap> {
|
||||
const response = await axiosInstance.get(`events/${eventId}/management-bootstrap`)
|
||||
|
||||
return unwrapApiPayload<EventManagementBootstrap>(response.data)
|
||||
}
|
||||
|
||||
/** Admin can view insights for any organizer's event (no ownership check). */
|
||||
export async function fetchEventInsightsAsAdmin(eventId: string): Promise<EventManagementInsights> {
|
||||
const response = await adminEventsApi.adminEventsControllerInsights(eventId)
|
||||
@ -70,35 +21,6 @@ export async function fetchEventInsightsAsAdmin(eventId: string): Promise<EventM
|
||||
return unwrapApiPayload<EventManagementInsights>(response.data)
|
||||
}
|
||||
|
||||
export async function fetchEventAttendees(
|
||||
eventId: string,
|
||||
options: {
|
||||
search?: string
|
||||
attendance?: AttendanceFilter
|
||||
page?: number
|
||||
pageSize?: number
|
||||
} = {}
|
||||
): Promise<EventAttendeePage> {
|
||||
const response = await myEventsApi.myEventsControllerAttendees(eventId, {
|
||||
page: options.page ?? 1,
|
||||
pageSize: options.pageSize ?? 20,
|
||||
sort: '-createdAt',
|
||||
search: options.search?.trim() || undefined,
|
||||
attendance: options.attendance ?? 'all',
|
||||
})
|
||||
const payload = unwrapApiPayload<MyEventsControllerAttendees200>(response.data)
|
||||
|
||||
return {
|
||||
items: Array.isArray(payload.items) ? (payload.items as EventAttendee[]) : [],
|
||||
totalItemsCount: Number(payload.response?.totalItemsCount ?? 0),
|
||||
totalPages: Number(payload.response?.totalPages ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkInEventAttendee(bookingId: string): Promise<void> {
|
||||
await bookingsApi.bookingsControllerCheckIn(bookingId)
|
||||
}
|
||||
|
||||
/** Admin can check in a guest for any organizer's event (no ownership check). */
|
||||
export async function checkInBookingAsAdmin(bookingId: string): Promise<void> {
|
||||
await adminBookingsApi.adminBookingsControllerCheckIn(bookingId)
|
||||
|
||||
@ -4,112 +4,45 @@ import type {
|
||||
CreateEventMediaDto,
|
||||
EventCategoryResponseDto,
|
||||
EventResponseDto,
|
||||
OrganizerGuestListResponseDto,
|
||||
} from '@/api/generated/models'
|
||||
import { getAdminEventCategories } from '@/api/generated/admin-event-categories/admin-event-categories'
|
||||
import { getAdminEvents } from '@/api/generated/admin-events/admin-events'
|
||||
import { getEventCategories } from '@/api/generated/event-categories/event-categories'
|
||||
import { getEventExtras } from '@/api/generated/event-extras/event-extras'
|
||||
import { getMyEvents } from '@/api/generated/my-events/my-events'
|
||||
import { unwrapApiPayload } from '@/helpers/listResponse'
|
||||
import axiosInstance from '@/config/axios'
|
||||
import { API_ROUTES } from '@/services/config'
|
||||
import { texts } from '@/texts'
|
||||
import { ANALYTICS_EVENTS, trackAnalyticsEventOnce } from '@/lib/analytics'
|
||||
|
||||
export type EventCategory = EventCategoryResponseDto
|
||||
export type CreateEventPayload = CreateEventDto
|
||||
export type CreatedEvent = EventResponseDto
|
||||
export type CreateEventMediaPayload = CreateEventMediaDto
|
||||
export type CreateEventFaqPayload = CreateEventFaqDto
|
||||
export type OrganizerGuestList = OrganizerGuestListResponseDto
|
||||
export interface PreviousAttendee {
|
||||
userId: string
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
}
|
||||
|
||||
interface MyHostedEventSummary {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
status: string
|
||||
startsAt: string
|
||||
bookedCount: number
|
||||
reservedCapacity?: number
|
||||
capacity: number
|
||||
isFree: boolean
|
||||
price: number
|
||||
createdAt: string
|
||||
adminApprovedAt?: string | null
|
||||
rejectionReason?: string | null
|
||||
}
|
||||
|
||||
/** The endpoint returns the full event today while this intersection keeps older cached summaries valid. */
|
||||
export type MyHostedEvent = MyHostedEventSummary & Partial<Omit<EventResponseDto, keyof MyHostedEventSummary>>
|
||||
|
||||
const myEventsApi = getMyEvents()
|
||||
const adminEventsApi = getAdminEvents()
|
||||
const eventCategoriesApi = getEventCategories()
|
||||
const adminEventCategoriesApi = getAdminEventCategories()
|
||||
const eventExtrasApi = getEventExtras()
|
||||
|
||||
/** Full category tree for admin edit forms — admin list, not public discovery. */
|
||||
export async function fetchEventCategories(): Promise<EventCategory[]> {
|
||||
const response = await eventCategoriesApi.eventCategoriesControllerList()
|
||||
const payload = unwrapApiPayload(response.data)
|
||||
const items: EventCategory[] = []
|
||||
let page = 1
|
||||
let totalPages = 1
|
||||
|
||||
return Array.isArray(payload) ? (payload as EventCategory[]) : []
|
||||
}
|
||||
|
||||
export async function createEvent(payload: CreateEventPayload): Promise<CreatedEvent> {
|
||||
const response = await myEventsApi.myEventsControllerCreate(payload)
|
||||
const data = unwrapApiPayload<CreatedEvent>(response.data)
|
||||
|
||||
if (!data?.id) {
|
||||
throw new Error(texts.common.invalidServerResponse)
|
||||
}
|
||||
|
||||
trackAnalyticsEventOnce(ANALYTICS_EVENTS.EVENT_CREATED, data.id, {
|
||||
event_id: data.id,
|
||||
event_slug: data.slug,
|
||||
category_id: payload.categoryId,
|
||||
is_free: payload.isFree,
|
||||
while (page <= totalPages) {
|
||||
const response = await adminEventCategoriesApi.adminEventCategoriesControllerList({
|
||||
page,
|
||||
pageSize: 100,
|
||||
sort: 'sortOrder',
|
||||
})
|
||||
const payload = unwrapApiPayload<{ items?: EventCategory[]; response?: { totalPages?: number } }>(response.data)
|
||||
const batch = Array.isArray(payload.items) ? payload.items : Array.isArray(payload) ? (payload as EventCategory[]) : []
|
||||
|
||||
return data
|
||||
items.push(...batch)
|
||||
totalPages = Number(payload.response?.totalPages ?? 1)
|
||||
page += 1
|
||||
}
|
||||
|
||||
// Self-service endpoints (events/:id, events/:id/publish) — ownership-
|
||||
// checked server-side against the logged-in user. Only works if the
|
||||
// currently logged-in user is the event's own organizer_id. For an admin
|
||||
// acting on any organizer's event, use the *AsAdmin variants below instead.
|
||||
export async function publishEvent(eventId: string): Promise<CreatedEvent> {
|
||||
const response = await myEventsApi.myEventsControllerPublish(eventId)
|
||||
const event = unwrapApiPayload<CreatedEvent>(response.data)
|
||||
|
||||
trackAnalyticsEventOnce(ANALYTICS_EVENTS.EVENT_PUBLISHED, event.id, {
|
||||
event_id: event.id,
|
||||
event_slug: event.slug,
|
||||
})
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
export async function cancelEvent(eventId: string): Promise<CreatedEvent> {
|
||||
const response = await myEventsApi.myEventsControllerCancel(eventId)
|
||||
|
||||
return unwrapApiPayload<CreatedEvent>(response.data)
|
||||
}
|
||||
|
||||
export async function completeEvent(eventId: string): Promise<CreatedEvent> {
|
||||
const response = await myEventsApi.myEventsControllerComplete(eventId)
|
||||
|
||||
return unwrapApiPayload<CreatedEvent>(response.data)
|
||||
}
|
||||
|
||||
/** Soft-delete when booked_count = 0. */
|
||||
export async function deleteEvent(eventId: string): Promise<CreatedEvent> {
|
||||
const response = await axiosInstance.delete(API_ROUTES.EVENTS.DELETE(eventId))
|
||||
|
||||
return unwrapApiPayload<CreatedEvent>(response.data)
|
||||
return items
|
||||
}
|
||||
|
||||
/** Admin approval is one half of the two-key publication workflow. */
|
||||
@ -150,21 +83,8 @@ export async function deleteEventAsAdmin(eventId: string): Promise<CreatedEvent>
|
||||
return unwrapApiPayload<CreatedEvent>(response.data)
|
||||
}
|
||||
|
||||
export type EventEditAccessMode = 'admin' | 'owner'
|
||||
|
||||
export async function updateEvent(eventId: string, payload: Partial<CreateEventPayload>): Promise<CreatedEvent> {
|
||||
const response = await myEventsApi.myEventsControllerUpdate(eventId, payload)
|
||||
|
||||
return unwrapApiPayload<CreatedEvent>(response.data)
|
||||
}
|
||||
|
||||
export async function updateEventReservedCapacity(
|
||||
eventId: string,
|
||||
reservedCapacity: number,
|
||||
accessMode: EventEditAccessMode = 'owner'
|
||||
): Promise<CreatedEvent> {
|
||||
const path = accessMode === 'admin' ? API_ROUTES.EVENTS.ADMIN_RESERVED_CAPACITY(eventId) : API_ROUTES.EVENTS.RESERVED_CAPACITY(eventId)
|
||||
const response = await axiosInstance.patch(path, { reservedCapacity })
|
||||
export async function updateEventReservedCapacity(eventId: string, reservedCapacity: number): Promise<CreatedEvent> {
|
||||
const response = await axiosInstance.patch(API_ROUTES.EVENTS.ADMIN_RESERVED_CAPACITY(eventId), { reservedCapacity })
|
||||
|
||||
return unwrapApiPayload<CreatedEvent>(response.data)
|
||||
}
|
||||
@ -184,8 +104,8 @@ export async function updateEventCommissionAsAdmin(eventId: string, commissionPe
|
||||
}
|
||||
|
||||
/**
|
||||
* Full event for edit/clone forms. Backend authorizes owner OR admin;
|
||||
* callers do not pass access mode.
|
||||
* Full event for edit forms. Backend authorizes admin (and owner);
|
||||
* admin panel uses this for the edit screen.
|
||||
*/
|
||||
export async function fetchEventForEdit(eventId: string): Promise<CreatedEvent> {
|
||||
const response = await axiosInstance.get(API_ROUTES.EVENTS.FOR_EDIT(eventId))
|
||||
@ -200,6 +120,10 @@ export async function fetchAdminEventDetail(eventId: string): Promise<CreatedEve
|
||||
return unwrapApiPayload<CreatedEvent>(response.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Media/FAQ mutations share `/events/:id/...` extras endpoints; backend
|
||||
* authorizes event owner or admin. No separate admin CRUD routes exist.
|
||||
*/
|
||||
export async function createEventMedia(eventId: string, payload: CreateEventMediaPayload): Promise<void> {
|
||||
await eventExtrasApi.eventMediaControllerCreate(eventId, payload)
|
||||
}
|
||||
@ -262,29 +186,6 @@ export interface EventRevision {
|
||||
decidedAt: string | null
|
||||
}
|
||||
|
||||
export async function submitEventRevision(eventId: string, payload: EventRevisionPayload): Promise<EventRevision> {
|
||||
const response = await axiosInstance.post(API_ROUTES.EVENTS.REVISION_SUBMIT(eventId), payload)
|
||||
|
||||
return unwrapApiPayload<EventRevision>(response.data)
|
||||
}
|
||||
|
||||
export async function fetchPendingEventRevision(eventId: string): Promise<EventRevision | null> {
|
||||
try {
|
||||
const response = await axiosInstance.get(API_ROUTES.EVENTS.REVISION_PENDING(eventId))
|
||||
|
||||
return unwrapApiPayload<EventRevision>(response.data)
|
||||
} catch (error) {
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
|
||||
if (status === 404) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function withdrawPendingEventRevision(eventId: string): Promise<void> {
|
||||
await axiosInstance.delete(API_ROUTES.EVENTS.REVISION_WITHDRAW(eventId))
|
||||
}
|
||||
|
||||
export async function fetchAdminPendingEventRevision(eventId: string): Promise<EventRevision | null> {
|
||||
try {
|
||||
const response = await axiosInstance.get(API_ROUTES.EVENTS.ADMIN_REVISION_PENDING(eventId))
|
||||
@ -311,28 +212,3 @@ export async function rejectEventRevisionAsAdmin(eventId: string, revisionId: st
|
||||
|
||||
return unwrapApiPayload<EventRevision>(response.data)
|
||||
}
|
||||
|
||||
export async function linkGuestListToEvent(eventId: string, listId: string): Promise<void> {
|
||||
await eventExtrasApi.eventGuestListLinksControllerLink(eventId, { listId })
|
||||
}
|
||||
|
||||
export async function fetchOrganizerGuestLists(): Promise<OrganizerGuestList[]> {
|
||||
const response = await eventExtrasApi.organizerGuestListsControllerList({ page: 1, pageSize: 100 })
|
||||
const payload = unwrapApiPayload(response.data)
|
||||
const items = Array.isArray(payload.items) ? payload.items : Array.isArray(payload) ? payload : []
|
||||
|
||||
return items as OrganizerGuestList[]
|
||||
}
|
||||
|
||||
export async function fetchPreviousAttendees(): Promise<PreviousAttendee[]> {
|
||||
const response = await axiosInstance.get('/organizer-guest-lists/previous-attendees')
|
||||
const payload = unwrapApiPayload(response.data)
|
||||
|
||||
return Array.isArray(payload) ? (payload as PreviousAttendee[]) : []
|
||||
}
|
||||
|
||||
export async function setPreviousAttendeeGuests(eventId: string, userIds: string[]): Promise<void> {
|
||||
// Persists the invite set. SMS is sent automatically when the event is published
|
||||
// (or immediately if it is already published and has not been notified yet).
|
||||
await axiosInstance.put(`/events/${eventId}/guest-list-links/invited-guests`, { userIds })
|
||||
}
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import type { PushSubscriptionResponseDto, PushVapidPublicKeyResponseDto } from '@/api/generated/models'
|
||||
import {
|
||||
createServiceError,
|
||||
errorResult,
|
||||
handleServiceError,
|
||||
type ServiceCallOptions,
|
||||
@ -8,65 +6,12 @@ import {
|
||||
shouldBubbleErrorToParent,
|
||||
successResult,
|
||||
} from '@/services/errorHandler'
|
||||
import { unwrapApiData } from '@/services/apiResponse'
|
||||
import { getNotifications } from '@/api/generated/notifications/notifications'
|
||||
import { clearStoredPushSubscriptionId, getStoredPushSubscriptionId, unsubscribeBrowserPushManager } from '@/lib/pwa'
|
||||
import { texts } from '@/texts'
|
||||
|
||||
export type PushSubscriptionRecord = PushSubscriptionResponseDto
|
||||
export type VapidPublicKeyResult = PushVapidPublicKeyResponseDto
|
||||
|
||||
const notificationsApi = getNotifications()
|
||||
|
||||
export const GET_VAPID_PUBLIC_KEY = async (options?: ServiceCallOptions): Promise<ServiceResult<VapidPublicKeyResult>> => {
|
||||
try {
|
||||
const res = await notificationsApi.notificationsControllerVapidPublicKey()
|
||||
|
||||
if (!res.data.success) throw createServiceError(res.data.message)
|
||||
const payload = unwrapApiData(res.data)
|
||||
|
||||
return successResult({
|
||||
publicKey: String(payload?.publicKey ?? ''),
|
||||
enabled: Boolean(payload?.enabled),
|
||||
})
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
export const SUBSCRIBE_PUSH = async (
|
||||
data: { endpoint: string; p256dh: string; auth: string; userAgent?: string },
|
||||
options?: ServiceCallOptions
|
||||
): Promise<ServiceResult<PushSubscriptionRecord>> => {
|
||||
try {
|
||||
const res = await notificationsApi.notificationsControllerSubscribePush(data)
|
||||
|
||||
if (!res.data.success) throw createServiceError(res.data.message)
|
||||
const payload = unwrapApiData(res.data)
|
||||
|
||||
if (!payload?.id) {
|
||||
throw createServiceError(texts.common.invalidServerResponse)
|
||||
}
|
||||
|
||||
return successResult(payload)
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
export const UNSUBSCRIBE_PUSH = async (
|
||||
const UNSUBSCRIBE_PUSH = async (
|
||||
subscriptionId: string,
|
||||
options?: ServiceCallOptions
|
||||
): Promise<ServiceResult<{ deleted: boolean }>> => {
|
||||
@ -85,32 +30,7 @@ export const UNSUBSCRIBE_PUSH = async (
|
||||
}
|
||||
}
|
||||
|
||||
export const UNSUBSCRIBE_ALL_PUSH = async (options?: ServiceCallOptions): Promise<ServiceResult<{ deletedCount: number }>> => {
|
||||
try {
|
||||
const res = await notificationsApi.notificationsControllerUnsubscribeAllPush()
|
||||
|
||||
if (!res.data.success) throw createServiceError(res.data.message)
|
||||
const payload = unwrapApiData(res.data)
|
||||
|
||||
return successResult({ deletedCount: Number(payload?.deletedCount ?? 0) })
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes push delivery for *this* device only: deletes the server-side
|
||||
* subscription row (if we have its id) and unsubscribes the browser's
|
||||
* PushManager. Intended for logout — logging out on one device should not
|
||||
* silence push notifications on the user's other logged-in devices, which
|
||||
* UNSUBSCRIBE_ALL_PUSH would do.
|
||||
*/
|
||||
/** Best-effort cleanup on admin logout — no subscribe flow in this app. */
|
||||
export const unsubscribeCurrentDevicePush = async (): Promise<void> => {
|
||||
const subscriptionId = getStoredPushSubscriptionId()
|
||||
|
||||
@ -119,7 +39,7 @@ export const unsubscribeCurrentDevicePush = async (): Promise<void> => {
|
||||
await UNSUBSCRIBE_PUSH(subscriptionId, { errorMode: 'silent' })
|
||||
}
|
||||
} finally {
|
||||
await unsubscribeBrowserPushManager()
|
||||
clearStoredPushSubscriptionId()
|
||||
await unsubscribeBrowserPushManager().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,5 @@
|
||||
import type {
|
||||
AdminReviewResponseDto,
|
||||
CreateReviewDto,
|
||||
HostReplyDto,
|
||||
OrganizerReviewResponseDto,
|
||||
ReviewResponseDto,
|
||||
UpdateReviewDto,
|
||||
} from '@/api/generated/models'
|
||||
import type { AdminReviewResponseDto } from '@/api/generated/models'
|
||||
import { getAdminReviews } from '@/api/generated/admin-reviews/admin-reviews'
|
||||
import { getReviews } from '@/api/generated/reviews/reviews'
|
||||
import { unwrapApiPayload } from '@/helpers/listResponse'
|
||||
import { parseRemittanceList } from '@/helpers/listResponse'
|
||||
import {
|
||||
errorResult,
|
||||
@ -19,58 +10,11 @@ import {
|
||||
successResult,
|
||||
} from '@/services/errorHandler'
|
||||
|
||||
export type EventReview = ReviewResponseDto
|
||||
export type AdminEventReview = AdminReviewResponseDto
|
||||
export type OrganizerReview = OrganizerReviewResponseDto
|
||||
|
||||
const reviewsApi = getReviews()
|
||||
const adminReviewsApi = getAdminReviews()
|
||||
|
||||
export const EVENT_REVIEWS_PAGE_SIZE = 10
|
||||
export const ORGANIZER_REVIEWS_PAGE_SIZE = 10
|
||||
|
||||
interface PaginatedReviews<T> {
|
||||
items: T[]
|
||||
response?: { totalItemsCount?: number; totalPages?: number; page?: number }
|
||||
}
|
||||
|
||||
const unwrapItems = <T>(payload: unknown): T[] => {
|
||||
if (Array.isArray(payload)) return payload as T[]
|
||||
if (payload && typeof payload === 'object' && Array.isArray((payload as PaginatedReviews<T>).items)) {
|
||||
return (payload as PaginatedReviews<T>).items
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export const LIST_EVENT_REVIEWS_PAGE = async (
|
||||
eventId: string,
|
||||
page = 1,
|
||||
pageSize = EVENT_REVIEWS_PAGE_SIZE,
|
||||
options?: ServiceCallOptions
|
||||
): Promise<ServiceResult<{ items: EventReview[]; page: number; totalItemsCount: number; totalPages: number }>> => {
|
||||
try {
|
||||
const res = await reviewsApi.reviewsControllerListByEvent(eventId, {
|
||||
sort: '-createdAt',
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
const parsed = parseRemittanceList<EventReview>(res.data, 'items', page, pageSize)
|
||||
|
||||
return successResult({
|
||||
items: parsed.items,
|
||||
page: parsed.pagination.page,
|
||||
totalItemsCount: parsed.pagination.totalItemsCount,
|
||||
totalPages: parsed.pagination.totalPages ?? 0,
|
||||
})
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) throw normalizedError
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
/** Admin list for one event — includes published, hidden, and deleted reviews. */
|
||||
export const LIST_ADMIN_EVENT_REVIEWS_PAGE = async (
|
||||
@ -102,126 +46,3 @@ export const LIST_ADMIN_EVENT_REVIEWS_PAGE = async (
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
export const LIST_EVENT_REVIEWS = async (eventId: string, options?: ServiceCallOptions): Promise<ServiceResult<EventReview[]>> => {
|
||||
const result = await LIST_EVENT_REVIEWS_PAGE(eventId, 1, 50, options)
|
||||
|
||||
return result.ok ? successResult(result.data.items) : result
|
||||
}
|
||||
|
||||
export const LIST_ORGANIZER_REVIEWS = async (
|
||||
organizerId: string,
|
||||
options?: ServiceCallOptions
|
||||
): Promise<ServiceResult<OrganizerReview[]>> => {
|
||||
try {
|
||||
const res = await reviewsApi.reviewsControllerListByOrganizer(organizerId, {
|
||||
sort: '-createdAt',
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
})
|
||||
|
||||
return successResult(unwrapItems<OrganizerReview>(unwrapApiPayload(res.data)))
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) throw normalizedError
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
export const LIST_ORGANIZER_REVIEWS_PAGE = async (
|
||||
organizerId: string,
|
||||
page = 1,
|
||||
pageSize = ORGANIZER_REVIEWS_PAGE_SIZE,
|
||||
options?: ServiceCallOptions
|
||||
): Promise<ServiceResult<{ items: OrganizerReview[]; page: number; totalItemsCount: number; totalPages: number }>> => {
|
||||
try {
|
||||
const res = await reviewsApi.reviewsControllerListByOrganizer(organizerId, {
|
||||
sort: '-createdAt',
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
const parsed = parseRemittanceList<OrganizerReview>(res.data, 'items', page, pageSize)
|
||||
|
||||
return successResult({
|
||||
items: parsed.items,
|
||||
page: parsed.pagination.page,
|
||||
totalItemsCount: parsed.pagination.totalItemsCount,
|
||||
totalPages: parsed.pagination.totalPages ?? 0,
|
||||
})
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) throw normalizedError
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
/** Published review by this user for the event, if any (from the public event list). */
|
||||
export const FIND_MY_EVENT_REVIEW = async (
|
||||
eventId: string,
|
||||
userId: string,
|
||||
options?: ServiceCallOptions
|
||||
): Promise<ServiceResult<EventReview | null>> => {
|
||||
const result = await LIST_EVENT_REVIEWS(eventId, options)
|
||||
|
||||
if (!result.ok) return result
|
||||
|
||||
return successResult(result.data.find((review) => review.userId === userId) ?? null)
|
||||
}
|
||||
|
||||
export const CREATE_REVIEW = async (
|
||||
eventId: string,
|
||||
dto: CreateReviewDto,
|
||||
options?: ServiceCallOptions
|
||||
): Promise<ServiceResult<EventReview>> => {
|
||||
try {
|
||||
const res = await reviewsApi.reviewsControllerCreate(eventId, dto)
|
||||
|
||||
return successResult(unwrapApiPayload(res.data))
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) throw normalizedError
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
export const UPDATE_REVIEW = async (
|
||||
reviewId: string,
|
||||
dto: UpdateReviewDto,
|
||||
options?: ServiceCallOptions
|
||||
): Promise<ServiceResult<EventReview>> => {
|
||||
try {
|
||||
const res = await reviewsApi.reviewsControllerUpdate(reviewId, dto)
|
||||
|
||||
return successResult(unwrapApiPayload(res.data))
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) throw normalizedError
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
export const HOST_REPLY_REVIEW = async (
|
||||
reviewId: string,
|
||||
dto: HostReplyDto,
|
||||
options?: ServiceCallOptions
|
||||
): Promise<ServiceResult<EventReview>> => {
|
||||
try {
|
||||
const res = await reviewsApi.reviewsControllerHostReply(reviewId, dto)
|
||||
|
||||
return successResult(unwrapApiPayload(res.data))
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) throw normalizedError
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import axiosInstance from '@/config/axios'
|
||||
import { texts } from '@/texts'
|
||||
import { parseRemittanceList, unwrapApiDataUnknown } from '@/helpers/listResponse'
|
||||
import { unwrapApiDataUnknown } from '@/helpers/listResponse'
|
||||
|
||||
export interface SupportTicketMessage {
|
||||
id: string
|
||||
@ -31,35 +31,6 @@ export const SUPPORT_ADMIN_STATUS_LABELS: Record<string, string> = { ...texts.su
|
||||
|
||||
const unwrapTicket = (raw: unknown): SupportTicket => unwrapApiDataUnknown(raw) as SupportTicket
|
||||
|
||||
export async function listMySupportTickets(): Promise<SupportTicket[]> {
|
||||
const response = await axiosInstance.get('support-tickets/me', { params: { pageSize: 100, sort: '-lastMessageAt' } })
|
||||
|
||||
return parseRemittanceList<SupportTicket>(response.data).items
|
||||
}
|
||||
|
||||
export async function getMySupportTicket(id: string): Promise<SupportTicket> {
|
||||
const response = await axiosInstance.get(`support-tickets/${id}`)
|
||||
|
||||
return unwrapTicket(response.data)
|
||||
}
|
||||
|
||||
export async function createSupportTicket(payload: {
|
||||
subject: string
|
||||
category: string
|
||||
priority: string
|
||||
message: string
|
||||
}): Promise<SupportTicket> {
|
||||
const response = await axiosInstance.post('support-tickets', payload)
|
||||
|
||||
return unwrapTicket(response.data)
|
||||
}
|
||||
|
||||
export async function replyToSupportTicket(id: string, message: string): Promise<SupportTicket> {
|
||||
const response = await axiosInstance.post(`support-tickets/${id}/replies`, { message })
|
||||
|
||||
return unwrapTicket(response.data)
|
||||
}
|
||||
|
||||
export async function getAdminSupportTicket(id: string): Promise<SupportTicket> {
|
||||
const response = await axiosInstance.get(`admin/support-tickets/${id}`)
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { CompleteProfileDto, UpdateUserProfileDto, UserProfileResponseDto } from '@/api/generated/models'
|
||||
import type { CompleteProfileDto, UserProfileResponseDto } from '@/api/generated/models'
|
||||
import {
|
||||
createServiceError,
|
||||
errorResult,
|
||||
@ -15,76 +15,10 @@ import { texts } from '@/texts'
|
||||
|
||||
export type UserProfile = UserProfileResponseDto
|
||||
export type CompleteProfilePayload = CompleteProfileDto
|
||||
export type UpdateProfilePayload = UpdateUserProfileDto
|
||||
|
||||
const usersApi = getUsers()
|
||||
|
||||
export const GET_ME = async (options?: ServiceCallOptions): Promise<ServiceResult<UserProfile>> => {
|
||||
try {
|
||||
const res = await usersApi.usersControllerGetMe()
|
||||
const payload = res.data
|
||||
|
||||
if (!payload.success) {
|
||||
throw createServiceError(payload.message || texts.profile.fetchProfileFailed)
|
||||
}
|
||||
|
||||
const profile = unwrapApiData(payload)
|
||||
|
||||
if (!profile?.id) {
|
||||
throw createServiceError(texts.common.invalidServerResponse)
|
||||
}
|
||||
|
||||
return successResult(profile)
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
export const PATCH_ME = async (data: UpdateProfilePayload, options?: ServiceCallOptions): Promise<ServiceResult<UserProfile>> => {
|
||||
try {
|
||||
const body: UpdateProfilePayload = {}
|
||||
|
||||
if (data.firstName !== undefined) body.firstName = data.firstName.trim()
|
||||
if (data.lastName !== undefined) body.lastName = data.lastName.trim()
|
||||
if (data.gender !== undefined) body.gender = data.gender
|
||||
if (data.cityId !== undefined) body.cityId = data.cityId
|
||||
if (data.bio !== undefined) body.bio = data.bio.trim()
|
||||
if (data.avatarUrl !== undefined) body.avatarUrl = data.avatarUrl
|
||||
if (data.defaultAddress !== undefined) {
|
||||
body.defaultAddress = data.defaultAddress === null ? null : data.defaultAddress.trim()
|
||||
}
|
||||
|
||||
const res = await usersApi.usersControllerUpdateMe(body)
|
||||
const payload = res.data
|
||||
|
||||
if (!payload.success) {
|
||||
throw createServiceError(payload.message || texts.profile.updateProfileFailed)
|
||||
}
|
||||
|
||||
const profile = unwrapApiData(payload)
|
||||
|
||||
if (!profile?.id) {
|
||||
throw createServiceError(texts.common.invalidServerResponse)
|
||||
}
|
||||
|
||||
return successResult(profile)
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
/** Completes first-time admin profile after OTP registration. */
|
||||
export const COMPLETE_PROFILE = async (data: CompleteProfilePayload, options?: ServiceCallOptions): Promise<ServiceResult<UserProfile>> => {
|
||||
try {
|
||||
const res = await usersApi.usersControllerCompleteProfile({
|
||||
|
||||
@ -1,144 +0,0 @@
|
||||
import axiosInstance from '@/config/axios'
|
||||
import type { WalletResponseDto, WalletTransactionResponseDto, WalletTransactionType } from '@/api/generated/models'
|
||||
import {
|
||||
createServiceError,
|
||||
errorResult,
|
||||
handleServiceError,
|
||||
type ServiceCallOptions,
|
||||
type ServiceResult,
|
||||
shouldBubbleErrorToParent,
|
||||
successResult,
|
||||
} from '@/services/errorHandler'
|
||||
import { unwrapApiData } from '@/services/apiResponse'
|
||||
import { getFinancial } from '@/api/generated/financial/financial'
|
||||
import { parseRemittanceList } from '@/helpers/listResponse'
|
||||
import { texts } from '@/texts'
|
||||
|
||||
export type { WalletTransactionType }
|
||||
export type Wallet = WalletResponseDto
|
||||
export type WalletTransaction = WalletTransactionResponseDto
|
||||
|
||||
export interface WalletDepositCheckout {
|
||||
trackingId: string
|
||||
paymentUrl: string
|
||||
}
|
||||
|
||||
export interface CreateWalletDepositResult {
|
||||
id: string
|
||||
amount: number
|
||||
status: string
|
||||
checkout: WalletDepositCheckout | null
|
||||
isSimulated: boolean
|
||||
}
|
||||
|
||||
export const MIN_WALLET_DEPOSIT_AMOUNT = 10_000
|
||||
|
||||
export const CREATE_WALLET_DEPOSIT = async (
|
||||
amount: number,
|
||||
options?: ServiceCallOptions
|
||||
): Promise<ServiceResult<CreateWalletDepositResult>> => {
|
||||
try {
|
||||
const res = await axiosInstance.post<{ success: boolean; message?: string; data: CreateWalletDepositResult }>(
|
||||
'/users/me/wallet/deposits',
|
||||
{ amount }
|
||||
)
|
||||
const payload = res.data
|
||||
|
||||
if (!payload.success) {
|
||||
throw createServiceError(payload.message || texts.wallet.topUpFailed)
|
||||
}
|
||||
|
||||
if (!payload.data?.id) {
|
||||
throw createServiceError(texts.common.invalidServerResponse)
|
||||
}
|
||||
|
||||
return successResult(payload.data)
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
export const GET_WALLET_DEPOSIT = async (
|
||||
depositId: string,
|
||||
options?: ServiceCallOptions
|
||||
): Promise<ServiceResult<{ id: string; status: string; amount: number }>> => {
|
||||
try {
|
||||
const res = await axiosInstance.get<{ success: boolean; message?: string; data: { id: string; status: string; amount: number } }>(
|
||||
`/users/me/wallet/deposits/${depositId}`
|
||||
)
|
||||
const payload = res.data
|
||||
|
||||
if (!payload.success || !payload.data?.id) {
|
||||
throw createServiceError(payload.message || texts.wallet.topUpFailed)
|
||||
}
|
||||
|
||||
return successResult(payload.data)
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
const financialApi = getFinancial()
|
||||
|
||||
export const GET_WALLET = async (options?: ServiceCallOptions): Promise<ServiceResult<Wallet>> => {
|
||||
try {
|
||||
const res = await financialApi.walletControllerGetMyWallet()
|
||||
const payload = res.data
|
||||
|
||||
if (!payload.success) {
|
||||
throw createServiceError(payload.message || texts.wallet.fetchWalletFailed)
|
||||
}
|
||||
|
||||
const wallet = unwrapApiData(payload)
|
||||
|
||||
if (!wallet?.id) {
|
||||
throw createServiceError(texts.common.invalidServerResponse)
|
||||
}
|
||||
|
||||
return successResult(wallet)
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
|
||||
export const LIST_WALLET_TRANSACTIONS = async (options?: ServiceCallOptions): Promise<ServiceResult<{ items: WalletTransaction[] }>> => {
|
||||
try {
|
||||
const res = await financialApi.walletControllerListMyTransactions({ page: 1, pageSize: 30, sort: '-createdAt' })
|
||||
|
||||
const payload = res.data
|
||||
|
||||
if (!payload.success) {
|
||||
throw createServiceError(payload.message || texts.wallet.fetchTransactionsFailed)
|
||||
}
|
||||
|
||||
const parsed = parseRemittanceList<WalletTransaction>(unwrapApiData(payload) ?? {}, 'items')
|
||||
|
||||
return successResult({ items: parsed.items })
|
||||
} catch (error) {
|
||||
const normalizedError = handleServiceError(error, options)
|
||||
|
||||
if (shouldBubbleErrorToParent(options)) {
|
||||
throw normalizedError
|
||||
}
|
||||
|
||||
return errorResult(normalizedError)
|
||||
}
|
||||
}
|
||||
@ -1,53 +1,26 @@
|
||||
export const auth = {
|
||||
logoAlt: 'لوگوی قبیله',
|
||||
heroAlt: 'یک دورهمی هنری و تجربه گروهی در قبیله',
|
||||
preparingLogin: 'در حال آمادهسازی ورود',
|
||||
loading: 'در حال بارگذاری...',
|
||||
|
||||
otpTitle: 'کد ارسالشده را وارد کنید',
|
||||
profileTitle: 'تکمیل اطلاعات پروفایل',
|
||||
welcomeTitle: 'سلام!',
|
||||
welcomeDescription: 'به قبیله خوش آمدید. جهت استفاده از وبسایت وارد حساب کاربری خود شوید.',
|
||||
otpLoginTitle: 'ورود با کد یکبار مصرف',
|
||||
otpRegisterTitle: 'ایجاد حساب کاربری',
|
||||
otpLoginDescription: 'کد ورود به شماره {mobile} ارسال شد.',
|
||||
otpRegisterDescription: 'حساب کاربری با شماره موبایل {mobile} وجود ندارد. برای ساخت حساب جدید، کد تأیید برای این شماره ارسال گردید.',
|
||||
profileDescription: 'جهت تکمیل حساب خود، اطلاعات خود را وارد کنید.',
|
||||
termsNoticePrefix: 'ورود شما به معنای پذیرش',
|
||||
termsNoticeTermsLink: 'شرایط قبیله',
|
||||
privacyLink: 'قوانین حریم خصوصی',
|
||||
termsNoticeConnector: 'و',
|
||||
termsNoticeSuffix: 'است.',
|
||||
cityPickerTitle: 'شهرتان را انتخاب کنید',
|
||||
birthDatePickerTitle: 'تاریخ تولدتان را انتخاب کنید',
|
||||
|
||||
mobileLabel: 'شماره موبایل',
|
||||
mobilePlaceholder: 'مثال ۰۹۱۲۳۴۵۶۷۸۹',
|
||||
|
||||
termsConfirmPrefix: 'تأیید میکنم',
|
||||
termsLink: 'قوانین و مقررات قبیله',
|
||||
termsConfirmSuffix: 'را خواندهام و میپذیرم.',
|
||||
|
||||
confirmAndContinueRegister: 'تأیید و ادامه ثبتنام',
|
||||
|
||||
resendTimerAria: 'تایمر ارسال مجدد کد',
|
||||
didNotReceiveCode: 'کد را دریافت نکردید؟',
|
||||
resendInSeconds: 'ارسال مجدد کد تا {seconds} ثانیه دیگر',
|
||||
resendCode: 'ارسال مجدد کد',
|
||||
changeMobile: 'تغییر شماره موبایل',
|
||||
|
||||
loadingCities: 'در حال دریافت شهرها...',
|
||||
citiesLoadFailed: 'دریافت فهرست شهرها ناموفق بود.',
|
||||
completeRegistration: 'تکمیل ثبتنام',
|
||||
|
||||
accountSuspended: 'حساب کاربری شما مسدود شده است. برای پیگیری با پشتیبانی تماس بگیرید.',
|
||||
accountDeleted: 'این حساب کاربری حذف شده است و امکان ورود وجود ندارد.',
|
||||
otpSent: 'کد تایید به شماره موبایل شما ارسال شد',
|
||||
otpAlreadySent: 'پیامک قبلاً ارسال شده و میتوانید از همان کد استفاده کنید',
|
||||
otpSentToNumberPrefix: 'کد تایید به شماره',
|
||||
otpSentToNumberSuffix: 'ارسال شد',
|
||||
otpAlreadySentToNumberPrefix: 'پیامک قبلاً به شماره',
|
||||
otpAlreadySentToNumberSuffix: 'ارسال شده و همان کد هنوز معتبر است',
|
||||
sendOtpFailed: 'ارسال کد با خطا مواجه شد. دوباره تلاش کنید.',
|
||||
termsRequiredMessage: 'برای ادامه ثبتنام، پذیرش قوانین و مقررات الزامی است.',
|
||||
termsRequiredTitle: 'پذیرش قوانین الزامی است',
|
||||
@ -55,7 +28,6 @@ export const auth = {
|
||||
otpInvalidOrExpired: 'کد وارد شده نامعتبر یا منقضی شده است.',
|
||||
otpVerifyFailed: 'تایید کد با خطا مواجه شد. دوباره تلاش کنید.',
|
||||
profileCompleteFailed: 'تکمیل پروفایل با خطا مواجه شد. دوباره تلاش کنید.',
|
||||
|
||||
sendOtpFailedFallback: 'ارسال کد تایید ناموفق بود',
|
||||
otpInvalidFallback: 'کد تایید نامعتبر است',
|
||||
invalidLoginResponse: 'پاسخ ورود نامعتبر است',
|
||||
|
||||
@ -1,33 +1,4 @@
|
||||
export const blog = {
|
||||
magazineLabel: 'مجله قبیله',
|
||||
magazineShort: 'مجله',
|
||||
editorialName: 'تحریریه قبیله',
|
||||
readingMinutes: '{minutes} دقیقه مطالعه',
|
||||
publishedPrefix: 'انتشار {date}',
|
||||
featuredImageAlt: 'تصویر شاخص مقاله {title}',
|
||||
readArticle: 'مطالعه مقاله ← ',
|
||||
emptySection: 'مقالهای در این بخش منتشر نشده است.',
|
||||
viewAllArticles: 'مشاهده همه مقالهها',
|
||||
relatedArticles: 'مطالب مرتبط',
|
||||
topicsHeading: 'موضوعها',
|
||||
cityGuidesHeading: 'راهنمای شهرها',
|
||||
eventsInCity: 'رویدادهای {cityName}',
|
||||
relatedEventsCta: 'مشاهده رویدادهای مرتبط',
|
||||
listing: {
|
||||
indexTitle: 'راهنمای تجربههای بهتر، قبل و بعد از رویداد',
|
||||
indexDescription: 'مطلبهای دقیق و قابلاجرا برای اینکه رویداد مناسبتری پیدا کنید یا تجربه بهتری برای مهمانها بسازید.',
|
||||
categoryDescription: 'مجموعه راهنماهای کاربردی قبیله درباره {categoryName}.',
|
||||
cityTitle: 'راهنمای رویدادهای {cityName}',
|
||||
cityDescription: 'مطالب محلی برای پیدا کردن و تجربه بهتر رویدادهای {cityName}.',
|
||||
},
|
||||
meta: {
|
||||
indexTitle: 'مجله رویدادها و تجربههای گروهی',
|
||||
indexDescription: 'راهنماهای کاربردی برای انتخاب رویداد، شرکت در ورکشاپ و برگزاری تجربههای گروهی بهتر.',
|
||||
indexOgDescription: 'راهنمای تجربه و برگزاری رویدادهای بهتر',
|
||||
categoryTitle: '{categoryName}؛ مقالات و راهنماها',
|
||||
categoryDescription: 'مقالات کاربردی {categoryName} در مجله قبیله.',
|
||||
cityDescription: 'راهنما و نکات انتخاب و شرکت در رویدادهای {cityName}.',
|
||||
},
|
||||
errors: {
|
||||
fetchFailed: 'دریافت مقاله ناموفق بود',
|
||||
createFailed: 'ایجاد مقاله ناموفق بود',
|
||||
|
||||
@ -1,100 +0,0 @@
|
||||
export const bookings = {
|
||||
emptyServerResponse: 'پاسخ خالی از سرور',
|
||||
|
||||
registerForEvent: 'ثبتنام در رویداد',
|
||||
loginToReserve: 'ورود و رزرو',
|
||||
loginToJoinWaitlist: 'ورود و پیوستن به لیست انتظار',
|
||||
joinWaitlist: 'پیوستن به لیست انتظار',
|
||||
waitlistJoinedToast: 'به لیست انتظار اضافه شدید',
|
||||
checkingStatusAria: 'در حال بررسی وضعیت رزرو',
|
||||
statusCheckFailedTitle: 'بررسی وضعیت رزرو انجام نشد',
|
||||
statusCheckFailedDescription: 'وضعیت رزرو دریافت نشد. لطفاً دوباره تلاش کنید.',
|
||||
alreadyBookedGoToMyBookings: 'این رویداد رو قبلاً رزرو کردید — رزروهای من',
|
||||
alsoOnWaitlist: 'در لیست انتظار این رویداد هم هستید',
|
||||
onWaitlist: 'شما در لیست انتظار هستید',
|
||||
waitlistNotifiedConfirm: 'یک جای خالی برات باز شد سریعتر قطعیش کن',
|
||||
audienceBlockedGenderAndAge: 'سن یا جنسیت شما با شرایط این رویداد سازگار نیست.',
|
||||
audienceBlockedGender: 'این رویداد برای جنسیت دیگری است.',
|
||||
audienceBlockedAge: 'سن شما با شرایط این رویداد سازگار نیست.',
|
||||
audienceBlockedCity: 'شهر شما با شهر این رویداد یکی نیست.',
|
||||
audienceBlockedGenderAndCity: 'جنسیت یا شهر شما با شرایط این رویداد سازگار نیست.',
|
||||
audienceBlockedAgeAndCity: 'سن یا شهر شما با شرایط این رویداد سازگار نیست.',
|
||||
audienceBlockedGenderAgeAndCity: 'سن، جنسیت یا شهر شما با شرایط این رویداد سازگار نیست.',
|
||||
audienceBlockedGeneric: 'امکان ثبتنام در این رویداد براتون وجود نداره.',
|
||||
detailTitle: 'جزئیات رزرو',
|
||||
notFound: 'این رزرو یافت نشد',
|
||||
fetchFailedTitle: 'دریافت اطلاعات رزرو ناموفق بود',
|
||||
summaryHeading: 'خلاصه رزرو',
|
||||
bookingCodeLabel: 'کد رزرو',
|
||||
bookingCodeWithValue: 'کد رزرو: {code}',
|
||||
eventDateLabel: 'تاریخ رویداد',
|
||||
ticketPriceLabel: 'قیمت بلیط',
|
||||
createdAtLabel: 'تاریخ ثبت',
|
||||
discountAmountLabel: 'مبلغ تخفیف',
|
||||
discountCodeWithValue: 'کد تخفیف: {code}',
|
||||
discountAmountInline: 'تخفیف {amount}',
|
||||
cancellationReasonWithValue: 'دلیل لغو: {reason}',
|
||||
cancelBooking: 'لغو رزرو',
|
||||
completePayment: 'تکمیل پرداخت',
|
||||
unpaidChip: 'پرداخت نشده',
|
||||
checkedInChip: 'حضور ثبت شد',
|
||||
cardCancelRegistration: 'لغو ثبتنام',
|
||||
cardReviewPrompt: 'نظرتون رو درباره ایونت ثبت کنید',
|
||||
cardPaymentExpiryWarning: 'اگر پرداخت را کامل نکنی، این ظرفیت آزاد میشود.',
|
||||
cardRegistrationCodePrefix: 'کد ثبتنام:',
|
||||
cardCancelledByOrganizer: 'لغو شده (برگزارکننده)',
|
||||
cardCancelledByAttendee: 'لغو شده',
|
||||
cardRefundedToWallet: 'مبلغ پرداختی به کیف پول شما بازگشت.',
|
||||
cardRefundPending: 'مبلغ پرداختی به کیف پولت بازگردانده میشود.',
|
||||
|
||||
cancelled: 'رزرو لغو شد',
|
||||
cancelledWithRefund: 'رزرو لغو شد — {amount} به کیف پول شما بازگشت',
|
||||
confirmCancel: 'تایید لغو',
|
||||
platformCommission: 'کمیسیون پلتفرم (٪{percent})',
|
||||
hostCancellationFee: 'کارمزد لغو (میزبان)',
|
||||
refundToWallet: 'مبلغ بازگشتی به کیف پول',
|
||||
cancelNoPaymentNote: 'پرداختی انجام نشده — این کار فقط رزرو شما را لغو میکند، چیزی برای بازپرداخت نیست.',
|
||||
cancellationReasonLabel: 'دلیل لغو',
|
||||
cancellationReasonPlaceholder: 'لطفاً دلیل لغو رزرو را بنویسید',
|
||||
|
||||
copyCodeSuccess: 'کد رزرو کپی شد',
|
||||
copyCodeFailed: 'کپی کد رزرو ناموفق بود',
|
||||
ariaEventPage: 'صفحه رویداد',
|
||||
ariaCopyBookingCode: 'کپی کد رزرو',
|
||||
|
||||
payTitle: 'پرداخت رزرو',
|
||||
payFetchErrorTitle: 'خطایی در دریافت اطلاعات رزرو پیش آمد',
|
||||
payFetchErrorDescription: 'اتصال اینترنت را بررسی کنید و دوباره تلاش کنید.',
|
||||
notPayableTitle: 'امکان پرداخت این رزرو وجود ندارد',
|
||||
notPayableFallback: 'این رزرو دیگر قابل پرداخت نیست.',
|
||||
notPayableConfirmed: 'این رزرو قبلاً پرداخت و تایید شده است.',
|
||||
notPayableCancelled: 'این رزرو لغو شده است.',
|
||||
notPayableExpired: 'مهلت پرداخت این رزرو به پایان رسیده است.',
|
||||
notPayableRefunded: 'این رزرو بازپرداخت شده است.',
|
||||
notPayableNoShow: 'این رزرو بسته شده است.',
|
||||
paymentSummaryHeading: 'خلاصه پرداخت',
|
||||
discountCodePlaceholder: 'کد تخفیف را وارد کنید',
|
||||
discountCodeRequired: 'کد تخفیف را وارد کنید.',
|
||||
discountCodeInvalid: 'کد تخفیف معتبر نیست.',
|
||||
discountHint: 'در صورت داشتن کد تخفیف، آن را وارد و اعمال کنید.',
|
||||
applyDiscount: 'اعمال',
|
||||
discountAppliedWithCode: '{amount} تخفیف با کد {code}',
|
||||
paymentTimeRemaining: 'زمان باقیمانده برای پرداخت:',
|
||||
useWalletBalance: 'استفاده از موجودی کیف پول',
|
||||
currentBalanceLabel: 'موجودی فعلی: {amount}',
|
||||
totalAmountLabel: 'مبلغ کل',
|
||||
payableAmountLabel: 'مبلغ قابل پرداخت',
|
||||
paySplitWalletAndGateway: '{wallet} از کیف پول + {gateway} شارژ از درگاه',
|
||||
paySplitWalletOnly: '{wallet} از کیف پول',
|
||||
paySplitGatewayOnly: '{gateway} شارژ از درگاه',
|
||||
payHoldExpired: 'مهلت پرداخت به پایان رسیده است.',
|
||||
gatewayPaymentFailed: 'پرداخت از درگاه ناموفق بود. دوباره تلاش کنید.',
|
||||
paySeatUnavailable: 'تا زمان پرداخت مهلت رزرو تمام شد. مبلغ به کیف پول شما بازگشت.',
|
||||
paymentExpiredChip: 'مهلت پرداخت تمام شد',
|
||||
awaitingGatewayTitle: 'در حال انتظار برای تایید پرداخت از درگاه…',
|
||||
awaitingGatewayHint: 'اگر صفحهی درگاه باز نشد، دوباره تلاش کنید یا اتصال اینترنت را بررسی کنید.',
|
||||
reopenGateway: 'بازکردن دوباره درگاه پرداخت',
|
||||
paySuccessTitle: 'پرداخت با موفقیت انجام شد',
|
||||
paySuccessDescription: 'رزرو شما تایید شد.',
|
||||
simulatedPaymentNote: 'درگاه پرداخت هنوز به سامانه متصل نشده است؛ به همین دلیل این پرداخت بهصورت آزمایشی و خودکار تایید شد.',
|
||||
} as const
|
||||
@ -1,74 +1,6 @@
|
||||
export const chats = {
|
||||
listTitle: 'گفتوگوها',
|
||||
listDescription: 'پیامهای مستقیم و گروههای رویداد',
|
||||
filterAriaLabel: 'فیلتر نوع مکالمه',
|
||||
filterDirect: 'مستقیم',
|
||||
filterEventGroup: 'گروه رویداد',
|
||||
unreadAriaLabel: '{count} پیام خواندهنشده',
|
||||
listLoadFailed: 'دریافت گفتوگوها ناموفق بود',
|
||||
listEmpty: 'گفتوگویی در این دسته وجود ندارد',
|
||||
listEmptyTitle: 'هنوز گفتوگویی اینجا نیست',
|
||||
listEmptyDescription: 'وقتی با کسی پیام رد و بدل کنی یا عضو گروه رویداد شوی، گفتوگوها اینجا نمایش داده میشوند.',
|
||||
eventGroupTitleFallback: 'چت گروه رویداد',
|
||||
directTitleFallback: 'مکالمه مستقیم',
|
||||
conversationFallback: 'مکالمه',
|
||||
moreOptionsAria: 'گزینههای بیشتر',
|
||||
safetyMenuAria: 'گزینههای ایمنی چت',
|
||||
block: 'مسدود کردن',
|
||||
unblock: 'رفع مسدودیت',
|
||||
reportUser: 'گزارش کاربر',
|
||||
reportMessage: 'گزارش پیام',
|
||||
threadLoadFailed: 'دریافت گفتوگو ناموفق بود',
|
||||
youBlockedBanner: 'شما این کاربر را مسدود کردهاید و امکان ارسال یا دریافت پیام وجود ندارد.',
|
||||
reportModalTitle: 'گزارش این کاربر',
|
||||
reportMessageModalTitle: 'گزارش این پیام',
|
||||
reportSubmit: 'ارسال گزارش',
|
||||
reportReasonLabel: 'دلیل گزارش',
|
||||
reportDescriptionLabel: 'توضیحات (اختیاری)',
|
||||
reportDescriptionPlaceholder: 'در صورت نیاز توضیح بیشتری اضافه کنید...',
|
||||
reportSuccess: 'گزارش شما ثبت شد و بررسی خواهد شد',
|
||||
blockConfirm: 'این کاربر مسدود شود؟ پس از مسدودسازی، امکان ارسال و دریافت پیام مستقیم بین شما وجود نخواهد داشت.',
|
||||
unblockConfirm: 'این کاربر رفع مسدودیت شود؟ پس از آن دوباره میتوانید پیام رد و بدل کنید.',
|
||||
blockSuccess: 'کاربر مسدود شد',
|
||||
unblockSuccess: 'کاربر رفع مسدودیت شد',
|
||||
loadOlderFailed: 'بارگذاری پیامهای قدیمیتر ناموفق بود',
|
||||
sendFailed: 'ارسال پیام ناموفق بود',
|
||||
memberCount: '{count} عضو',
|
||||
composerPlaceholder: 'پیام خود را بنویسید...',
|
||||
sendAria: 'ارسال',
|
||||
attachAria: 'پیوست فایل',
|
||||
uploadImageAria: 'آپلود تصویر',
|
||||
emptyMessage: 'پیام نمیتواند خالی باشد',
|
||||
viewMembersAria: 'مشاهده اعضای گروه',
|
||||
membersTitle: 'اعضای گروه',
|
||||
membersLoadFailed: 'دریافت اعضا ناموفق بود',
|
||||
youSuffix: ' (شما)',
|
||||
chatWithSender: 'چت با {name}',
|
||||
privateChatAria: 'چت خصوصی',
|
||||
viewImageAria: 'مشاهده تصویر',
|
||||
attachmentAlt: 'پیوست',
|
||||
chatAttachmentAlt: 'پیوست چت',
|
||||
downloadImageAria: 'دانلود تصویر',
|
||||
download: 'دانلود',
|
||||
downloadFallbackToast: 'دانلود مستقیم ممکن نبود؛ تصویر در تب جدید باز شد',
|
||||
listConversationsFailed: 'دریافت مکالمات ناموفق بود',
|
||||
getConversationFailed: 'دریافت مکالمه ناموفق بود',
|
||||
unreadCountFailed: 'دریافت تعداد خواندهنشده ناموفق بود',
|
||||
markReadFailed: 'علامتگذاری خواندهشده ناموفق بود',
|
||||
listMessagesFailed: 'دریافت پیامها ناموفق بود',
|
||||
openConversationFailed: 'باز کردن گفتوگو ناموفق بود',
|
||||
listParticipantsFailed: 'دریافت اعضای گفتوگو ناموفق بود',
|
||||
blockUserFailed: 'مسدودکردن کاربر ناموفق بود',
|
||||
unblockUserFailed: 'رفع مسدودیت ناموفق بود',
|
||||
blockStatusFailed: 'بررسی وضعیت مسدودسازی ناموفق بود',
|
||||
listReportReasonsFailed: 'دریافت دلایل گزارش ناموفق بود',
|
||||
submitReportFailed: 'ثبت گزارش ناموفق بود',
|
||||
reply: 'پاسخ',
|
||||
replyPreviewAria: 'پیشنمایش پیام در حال پاسخ',
|
||||
cancelReplyAria: 'لغو پاسخ',
|
||||
replyImageLabel: 'تصویر',
|
||||
replyUnavailable: 'پیام در دسترس نیست',
|
||||
jumpToReplyFailed: 'یافتن پیام مرجع ناموفق بود',
|
||||
loadNewerFailed: 'بارگذاری پیامهای جدیدتر ناموفق بود',
|
||||
replySenderFallback: 'کاربر',
|
||||
} as const
|
||||
|
||||
@ -4,7 +4,6 @@ export const common = {
|
||||
category: 'دستهبندی',
|
||||
city: 'شهر',
|
||||
continue: 'ادامه',
|
||||
province: 'استان',
|
||||
selectCity: 'انتخاب شهر',
|
||||
reply: 'پاسخ',
|
||||
description: 'توضیحات',
|
||||
@ -16,52 +15,28 @@ export const common = {
|
||||
lastName: 'نام خانوادگی',
|
||||
lastNamePlaceholder: 'نام خانوادگی خود را وارد کنید',
|
||||
birthDatePlaceholder: 'تاریخ تولد را انتخاب کنید',
|
||||
loadingEllipsis: 'در حال بارگذاری…',
|
||||
imageUploadFailed: 'آپلود تصویر ناموفق بود',
|
||||
payment: 'پرداخت',
|
||||
discountCode: 'کد تخفیف',
|
||||
instagram: 'اینستاگرام',
|
||||
telegram: 'تلگرام',
|
||||
general: 'عمومی',
|
||||
account: 'حساب کاربری',
|
||||
operationFailed: 'عملیات ناموفق بود',
|
||||
invalidServerResponse: 'پاسخ نامعتبر از سرور',
|
||||
saveChanges: 'ذخیره تغییرات',
|
||||
|
||||
home: 'خانه',
|
||||
myEvents: 'رویدادهای من',
|
||||
chat: 'چت',
|
||||
profile: 'پروفایل',
|
||||
login: 'ورود',
|
||||
bottomNavAria: 'ناوبری پایین',
|
||||
unreadMessagesAria: '{label}، {count} پیام خواندهنشده',
|
||||
unreadBadgeOverflow: '۹۹+',
|
||||
back: 'بازگشت',
|
||||
retry: 'تلاش دوباره',
|
||||
backHome: 'بازگشت به خانه',
|
||||
|
||||
loadingCards: 'در حال دریافت کارتها',
|
||||
loadingList: 'در حال دریافت فهرست',
|
||||
loadingDetails: 'در حال دریافت جزئیات',
|
||||
loadingTable: 'در حال دریافت جدول',
|
||||
preparingPage: 'در حال آمادهسازی صفحه',
|
||||
pleaseWait: 'لطفاً چند لحظه صبر کنید',
|
||||
preparingPanel: 'در حال آمادهسازی پنل شما',
|
||||
preparingEvent: 'در حال آمادهسازی رویداد',
|
||||
loadingEventDetails: 'در حال دریافت اطلاعات رویداد',
|
||||
|
||||
somethingWentWrong: 'مشکلی رخ داده است',
|
||||
tryAgainOrSupport: 'میتوانید دوباره تلاش کنید. اگر خطا ادامه داشت، شناسه پیگیری را برای پشتیبانی ارسال کنید.',
|
||||
trackingId: 'شناسه پیگیری: {digest}',
|
||||
|
||||
accountSuspendedBanner: 'حساب کاربری شما مسدود شده است. برای پیگیری و فعالسازی مجدد با پشتیبانی تماس بگیرید.',
|
||||
contactSupport: 'تماس با پشتیبانی',
|
||||
|
||||
offlineBanner: 'اتصال اینترنت قطع است؛ تغییرات پس از برقراری اتصال دوباره قابل ارسال خواهند بود.',
|
||||
|
||||
notFoundTitle: 'یافت نشد',
|
||||
pageNotFound: 'صفحهای که دنبال آن بودید یافت نشد.',
|
||||
|
||||
enterField: '{label} را وارد کنید',
|
||||
formIncomplete: 'اطلاعات فرم کامل نیست',
|
||||
formValidationDefault: 'لطفاً اطلاعات فرم را بررسی و خطاهای مشخصشده را برطرف کنید.',
|
||||
@ -69,7 +44,6 @@ export const common = {
|
||||
showPassword: 'نمایش رمز عبور',
|
||||
hidePassword: 'پنهانکردن رمز عبور',
|
||||
toman: 'تومان',
|
||||
|
||||
uploadFile: 'آپلود فایل',
|
||||
fileMustBeAccepted: 'فایل انتخاب شده باید یک فایل مورد قبول باشد.',
|
||||
imageFileTooLarge: 'حجم فایل تصویری نباید بیشتر از {maxMb} مگابایت باشد.',
|
||||
@ -82,7 +56,6 @@ export const common = {
|
||||
removeFile: 'حذف فایل',
|
||||
removeFileNamed: 'حذف فایل {name}',
|
||||
cancelUpload: 'لغو بارگذاری فایل',
|
||||
|
||||
imagePrepareFailed: 'آمادهسازی تصویر ناموفق بود',
|
||||
imageConvertFailed: 'تبدیل تصویر ناموفق بود',
|
||||
imageProcessingTimedOut: 'پردازش تصویر بیش از حد طول کشید. لطفاً تصویر کوچکتری انتخاب کنید.',
|
||||
@ -90,10 +63,8 @@ export const common = {
|
||||
imageEmpty: 'فایل تصویر خالی است',
|
||||
imageTooLarge: 'حجم تصویر نباید بیشتر از {maxMb} مگابایت باشد',
|
||||
imageTypeNotAllowed: 'فقط تصاویر JPEG، PNG، WebP یا HEIC مجاز هستند',
|
||||
|
||||
unexpectedError: 'خطای پیشبینینشده',
|
||||
requestCancelled: 'درخواست لغو شد',
|
||||
|
||||
today: 'امروز',
|
||||
tomorrow: 'فردا',
|
||||
yesterday: 'دیروز',
|
||||
@ -117,16 +88,11 @@ export const common = {
|
||||
birthDate: 'تاریخ تولد',
|
||||
selectDate: 'انتخاب تاریخ',
|
||||
selectTime: 'انتخاب ساعت',
|
||||
|
||||
invalidUrl: 'نشانی واردشده معتبر نیست.',
|
||||
imageAltLabel: 'توضیح تصویر (alt)',
|
||||
imageAltDescription: 'برای دسترسپذیری و سئو تصویر لازم است (مثلاً «نمای سالن کارگاه عکاسی»).',
|
||||
|
||||
jalaliMonths: ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'],
|
||||
|
||||
unsavedChanges: 'تغییرات ذخیرهنشده دارید',
|
||||
noUnsavedChanges: 'تغییری برای ذخیره وجود ندارد',
|
||||
seoCharMinHint: ' (حداقل {min})',
|
||||
listJoin: '، ',
|
||||
breadcrumbAria: 'مسیر صفحه',
|
||||
} as const
|
||||
|
||||
@ -1,124 +1,3 @@
|
||||
export const discovery = {
|
||||
homeTitle: 'پیدا کردن و رزرو رویدادها و دورهمیهای حضوری',
|
||||
homeDescription: 'در قبیله رویدادها، دورهمیها و تجربههای گروهی شهر خود را پیدا کنید، جزئیات آنها را ببینید و برای شرکت آماده شوید.',
|
||||
homeOgImageAlt: 'تجربههای گروهی و رویدادهای حضوری در قبیله',
|
||||
homeEyebrow: 'تجربههای تازه، آدمهای تازه',
|
||||
homeHeadline: 'رویداد بعدیات را پیدا کن',
|
||||
homeSubhead:
|
||||
'قبیله میزبان برپایی گردهماییهای زنده توی شهرتونه. جایی که میتونی مکان رو مشخص کنی، صندلیها رو بچینی و همقبیلهایهات رو برای یک تجربه خاطرهانگیز دور هم جمع کنی.',
|
||||
homeCta: 'آتش رویداد خودت رو روشن کن',
|
||||
homeProfileCityCta: 'ببین دور آتش شهرت چخبره',
|
||||
homeHeroAriaLabel: 'دور آتش هر قبیلهای اتفاقای جالبی در جریانه',
|
||||
homeEmptyTitle: 'هنوز توی شهر {city} قبیلهای بر پا نشده!',
|
||||
homeEmptyDescription: 'به محض این که اولین جمع شکل بگیره، با پیامک از دودش با خبرت میکنیم.',
|
||||
homeOriginStory: {
|
||||
title: 'همه «ما»ها، با یه بار اول شروع شدن',
|
||||
points: {
|
||||
friend: 'آدمی که امروز رفیقته، یه روز غریبه بود.',
|
||||
place: 'جایی که حالا پاتوقته، یه بار برای اولین بار واردش شدی.',
|
||||
memory: 'خاطرهای که هنوز تعریفش میکنی، شاید از شبی شروع شد که فقط گفتی: «بریم ببینیم چه خبره.»',
|
||||
},
|
||||
reflections: [
|
||||
'– اولینبارها موقع اتفاق افتادن خودشون رو معرفی نمیکنن.',
|
||||
'– نمیدونی کدوم سلام ادامه پیدا میکنه،\nکدوم جمع، میشه جمع خودت،\nیا کدوم شب معمولی، بعداً میشه «اون شب».',
|
||||
'– فقط میری.\nتجربه میکنی.\nو بعضی وقتها، یه چیزی از همینجا شروع میشه.',
|
||||
],
|
||||
closing: 'قبیله برای همین شروعهاست…',
|
||||
},
|
||||
homeAbilities: {
|
||||
title: 'قبیله چطور کمکت میکنه؟',
|
||||
introTitle: 'تو دلیل دور هم جمع شدن رو میسازی',
|
||||
introDescription: 'قبیله کمک میکنه مسیر رسیدن آدمها بهش، از اولین دعوت تا بعد از آخرین لحظه، مرتبتر باشه.',
|
||||
create: {
|
||||
title: 'رویدادت رو یکبار کامل معرفی کن',
|
||||
details: 'زمان، مکان، ظرفیت، بلیط و هر چیزی که آدمها قبل از اومدن باید بدونن، رو مشخص کن.',
|
||||
page: 'قبیله از این اطلاعات یه صفحه مشخص برای رویدادت میسازه.',
|
||||
privateAddress: 'جایی که آدمها میتونن بفهمن قراره کجا بیان، چه چیزی در انتظارشونه و چطور جاشون رو نگه دارن.',
|
||||
privateAddress2: 'حتی اگر نخوای آدرس دقیق برای همه دیده بشه، میتونی اون رو فقط به آدمهایی نشون بدی که ثبتنامشون تأیید شده.',
|
||||
},
|
||||
publish: {
|
||||
title: 'منتشرش کن؛ ادامه مسیر مشخصه',
|
||||
share: 'لینک رویدادت رو هرجا که آدمهات هستن منتشر کن.',
|
||||
booking: 'از اونجا به بعد، هرکس میتونه جزئیات رو ببینه، ثبتنام کنه و اگر رویداد پولیه، بلیطش رو بگیره.',
|
||||
waitlist: 'اگر ظرفیت پر بشه، لیست انتظار کمک میکنه آدمهایی که هنوز دوست دارن بیان پشت در نمونن.',
|
||||
},
|
||||
manage: {
|
||||
title: 'هر لحظه بدون چه خبره',
|
||||
dashboard: 'از پنل رویدادت میبینی چه کسایی ثبتنام کردن، چقدر ظرفیت مونده و چه کسایی منتظر یه جای خالیان.',
|
||||
attendance: 'روز رویداد هم میتونی حضور آدمها رو ثبت کنی.',
|
||||
overview: 'بهجای اینکه وضعیت رویدادت بین پیامها، فهرستها و پرداختهای مختلف پخش باشه، همیشه جلوی چشمته.',
|
||||
afterTitle: 'و وقتی رویداد تموم شد',
|
||||
reviews: 'آدمهایی که اومدن میتونن تجربهشون رو ثبت کنن و تو هم میتونی جوابشون رو بدی.',
|
||||
},
|
||||
community: {
|
||||
group: 'اگر بخوای، بعد از پایان رویداد یه گروه برای آدمهای همون جمع شکل میگیره تا گفتوگو کمی بیشتر ادامه پیدا کنه.',
|
||||
returningPeople: 'و آدمهایی که اومدن، میتونن بخشی از جمعی باشن که برای تجربههای بعدی دوباره سراغش میری.',
|
||||
closingTitle: 'وقت بیشتری برای چیزی میمونه که واقعاً مهمه:',
|
||||
closingDescription: 'آدمهایی که قراره بیان و تجربهای که براشون ساختی.',
|
||||
},
|
||||
images: {
|
||||
create: 'نمای صفحه ساخت و جزئیات رویداد در قبیله',
|
||||
publish: 'نمای انتشار و فهرست رویدادها در قبیله',
|
||||
manage: 'نمای پنل مدیریت و ثبت حضور شرکتکنندگان رویداد',
|
||||
community: 'نمای گفتوگوی گروه رویداد در قبیله',
|
||||
},
|
||||
},
|
||||
homeClosing: {
|
||||
title: 'زندگی فقط روزهایی نیست که میگذرند.',
|
||||
description:
|
||||
'آدمهایی است که میبینی، جاهایی است که میروی، چیزهایی است که برای اولین بار امتحان میکنی و شبهایی است که بعدها یادت میمانند.',
|
||||
support: 'قبیله کمک میکند بیشترِ این اتفاقها بیفتند.',
|
||||
discoverCta: 'یه چیزی پیدا کن که دلت بخواد بری',
|
||||
createCta: 'یا چیزی بساز که بقیه دلشون بخواد بیان',
|
||||
},
|
||||
|
||||
filtersAria: 'فیلتر رویدادها',
|
||||
filterWhen: 'کی؟',
|
||||
filterWhere: 'کجا؟',
|
||||
filterWhat: 'چی؟',
|
||||
activeFiltersLabel: 'فیلترهای فعال:',
|
||||
whenRangePlaceholder: 'انتخاب بازه زمانی',
|
||||
whenRange: '{from} تا {to}',
|
||||
removeWhenFilter: 'حذف فیلتر زمان',
|
||||
removeCityFilter: 'حذف فیلتر شهر',
|
||||
removeCategoryFilter: 'حذف فیلتر دستهبندی',
|
||||
|
||||
applyFilter: 'اعمال فیلتر',
|
||||
rangeEndMustBeAfterStart: 'پایان بازه باید بعد از شروع آن باشد.',
|
||||
|
||||
cityModalTitle: 'کجا؟ انتخاب شهر',
|
||||
citySearchAria: 'جستجوی شهر',
|
||||
cityEmpty: 'شهری پیدا نشد',
|
||||
|
||||
categoryModalTitle: 'چی؟ دستهبندی',
|
||||
categorySingleSelectHint: '(یک انتخاب)',
|
||||
allCategories: 'همه دستهبندیها',
|
||||
ariaExpandCategory: 'باز کردن {name}',
|
||||
ariaCollapseCategory: 'بستن {name}',
|
||||
|
||||
referenceError: 'بخشی از اطلاعات فیلترها دریافت نشد. میتوانید دوباره تلاش کنید.',
|
||||
referenceIncompleteTitle: 'اطلاعات فیلترها کامل نیست',
|
||||
refetchFilters: 'دریافت دوباره فیلترها',
|
||||
eventsFetchFailed: 'دریافت رویدادها ناموفق بود. دوباره تلاش کنید.',
|
||||
eventsFetchFailedTitle: 'دریافت رویدادها ناموفق بود',
|
||||
emptyResults: 'رویدادی با این فیلترها پیدا نشد',
|
||||
loadMoreFailed: 'بارگذاری بیشتر ناموفق بود',
|
||||
loadingMoreAria: 'در حال دریافت رویدادهای بیشتر',
|
||||
|
||||
capacityFull: 'ظرفیت تکمیل شده',
|
||||
viewEventAria: 'مشاهده رویداد {title}',
|
||||
eventPosterAlt: 'پوستر رویداد {title}',
|
||||
|
||||
categoriesTitle: 'دستهبندیها',
|
||||
categoriesDescription: 'موضوع مورد علاقهتان را برای پیدا کردن رویداد انتخاب کنید',
|
||||
categoriesFetchFailedTitle: 'دریافت دستهبندیها ناموفق بود',
|
||||
categoriesEmpty: 'دستهبندیای یافت نشد',
|
||||
viewCategoryEvents: 'مشاهده رویدادها',
|
||||
|
||||
latestEventsPrefix: 'جدیدترین رویدادهای',
|
||||
homePopularListAria: 'رویدادهای پرطرفدار',
|
||||
homeCategoryPreviewAria: 'جدیدترین رویدادهای دسته',
|
||||
ariaPreviousEvent: 'رویداد قبلی',
|
||||
ariaNextEvent: 'رویداد بعدی',
|
||||
ariaActiveEvent: 'رویداد فعال: {title}',
|
||||
} as const
|
||||
|
||||
408
texts/events.ts
408
texts/events.ts
@ -1,114 +1,18 @@
|
||||
export const events = {
|
||||
// HostedEventActionsModal (keep stable)
|
||||
publishRequested: 'درخواست انتشار ثبت شد؛ در صورت نیاز پس از تأیید ادمین منتشر میشود',
|
||||
publishedSuccess: 'رویداد منتشر شد',
|
||||
cancelConfirm: 'آیا از لغو این رویداد مطمئن هستید؟ همهی رزروها لغو خواهند شد.',
|
||||
cancelled: 'رویداد لغو شد',
|
||||
completeConfirm: 'آیا این رویداد به پایان رسیده است؟',
|
||||
completed: 'رویداد بهعنوان پایانیافته علامت خورد',
|
||||
deleteConfirm: 'آیا از حذف این رویداد مطمئن هستید؟ از لیست شما حذف میشود.',
|
||||
deleted: 'رویداد حذف شد',
|
||||
deleteFailed: 'حذف رویداد ناموفق بود',
|
||||
shareLinkCopied: 'لینک {channel} رویداد کپی شد',
|
||||
shareLinkCopiedGeneric: 'لینک رویداد کپی شد',
|
||||
shareLinkCopyFailed: 'کپی لینک ناموفق بود',
|
||||
rejectionReasonPrefix: 'دلیل رد:',
|
||||
pendingAdminReview: 'در انتظار بررسی ادمین',
|
||||
ariaDelete: 'حذف',
|
||||
ariaClone: 'کپی',
|
||||
ariaShare: 'اشتراکگذاری',
|
||||
ariaPublish: 'انتشار',
|
||||
ariaComplete: 'پایان',
|
||||
ariaCancel: 'لغو',
|
||||
moreActions: 'بیشتر',
|
||||
moreActionsAria: 'عملیات بیشتر رویداد',
|
||||
actionManageDescription: 'حضور، مالی و جزئیات',
|
||||
actionEditDescription: 'ویرایش اطلاعات رویداد',
|
||||
actionCloneDescription: 'ساخت پیشنویس مشابه',
|
||||
actionShareDescription: 'کپی لینک عمومی رویداد',
|
||||
actionIncreaseCapacityDescription: 'افزایش ظرفیت رویداد',
|
||||
actionCompleteDescription: 'علامتگذاری بهعنوان برگزارشده',
|
||||
actionCancelDescription: 'لغو رویداد و همه رزروها',
|
||||
actionDeleteDescription: 'حذف رویداد از لیست شما',
|
||||
tabOverview: 'خلاصه',
|
||||
tabAttendance: 'حضور و غیاب',
|
||||
tabFinancial: 'مالی',
|
||||
tabDetails: 'اطلاعات رویداد',
|
||||
shareLabelInstagram: 'اینستا',
|
||||
manageActions: 'مدیریت',
|
||||
eventActionsTitle: 'مدیریت رویداد',
|
||||
managementSections: 'بخشهای مدیریت',
|
||||
quickActions: 'عملیات رویداد',
|
||||
shareLinks: 'اشتراکگذاری',
|
||||
|
||||
// Shared
|
||||
free: 'رایگان',
|
||||
save: 'ذخیره',
|
||||
edit: 'ویرایش',
|
||||
nextStep: 'مرحله بعد',
|
||||
saveAndContinue: 'ثبت و ادامه',
|
||||
startOver: 'شروع از ابتدا',
|
||||
configureEventLink: 'تنظیم لینک رویداد',
|
||||
collapseDetails: 'بستن جزئیات',
|
||||
timeRange: '{start} تا {end}',
|
||||
dateRangeMultiDay: 'از {start} تا {end}',
|
||||
prevStep: 'مرحله قبل',
|
||||
return: 'برگشت',
|
||||
guest: 'مهمان',
|
||||
host: 'میزبان',
|
||||
start: 'شروع',
|
||||
end: 'پایان',
|
||||
location: 'مکان',
|
||||
price: 'قیمت',
|
||||
capacity: 'ظرفیت',
|
||||
title: 'عنوان',
|
||||
address: 'آدرس',
|
||||
faqs: 'سوالات متداول',
|
||||
aboutEvent: 'درباره رویداد',
|
||||
locationCardTitle: 'مکان رویداد',
|
||||
dateCardTitle: 'تاریخ رویداد',
|
||||
timeCardTitle: 'زمان رویداد',
|
||||
categoryCardTitle: 'دسته بندی',
|
||||
priceCardTitle: 'هزینه رویداد',
|
||||
navigateWithNeshan: 'مسیریابی با نشان',
|
||||
navigateWithGoogleMaps: 'مسیریابی با گوگل مپ',
|
||||
ageRestrictionChip: 'محدودیت سنی {label}',
|
||||
ageChip15_19: '۱۵-۱۹ سال',
|
||||
ageChip20_24: '۲۰-۲۴ سال',
|
||||
ageChip25Plus: '۲۵+ سال',
|
||||
peopleLimitChip: 'محدودیت',
|
||||
chipFemaleOnly: 'خانمها',
|
||||
chipMaleOnly: 'آقایان',
|
||||
reviews: 'نظرات',
|
||||
waitlist: 'لیست انتظار',
|
||||
eventEnded: 'اتمام رویداد',
|
||||
invalidDate: 'تاریخ نامعتبر است',
|
||||
tomanSuffix: ' تومان',
|
||||
uploadFailed: 'آپلود ناموفق بود',
|
||||
personCount: '{count} نفر',
|
||||
percentSymbol: '٪',
|
||||
viewDetails: 'مشاهده جزئیات',
|
||||
manageEventDetails: 'جزئیات مدیریت رویداد',
|
||||
capacityFullBanner: 'ظرفیت رویداد تکمیل است!',
|
||||
increaseCapacity: 'افزایش ظرفیت',
|
||||
registeringInProgress: 'درحال ثبت نام',
|
||||
registeredCount: '{count} نفر ثبتنام کردهاند',
|
||||
extendRegistrationDeadline: 'تمدید مهلت ثبتنام',
|
||||
ariaSaveEvent: 'ذخیره ایونت',
|
||||
ariaDuplicateEvent: 'کپی ایونت',
|
||||
ariaDeleteEvent: 'حذف ایونت',
|
||||
ariaEditEvent: 'ویرایش ایونت',
|
||||
ariaEditImage: 'ویرایش تصویر',
|
||||
ariaEditDate: 'ویرایش تاریخ',
|
||||
ariaEditTime: 'ویرایش زمان',
|
||||
ariaEditPrice: 'ویرایش قیمت',
|
||||
ariaEditCategory: 'ویرایش دستهبندی',
|
||||
ariaEditLocation: 'ویرایش مکان',
|
||||
ariaEditTitle: 'ویرایش عنوان',
|
||||
audienceUnrestricted: 'بدون محدودیت',
|
||||
audienceRestricted: 'دارد · {label}',
|
||||
reservedLabel: 'رزرو شده',
|
||||
|
||||
guestAccessLoadFailed: 'دریافت اطلاعات مهمانان ناموفق بود.',
|
||||
guestAccessTitle: 'مهمانان ویژه',
|
||||
guestAccessLoading: 'در حال دریافت اطلاعات مهمانان…',
|
||||
@ -120,138 +24,27 @@ export const events = {
|
||||
guestListsEmpty: 'لیست مهمانی به این رویداد متصل نشده است.',
|
||||
invitedGuestsTitle: 'مهمانان دعوتشده',
|
||||
invitedGuestsEmpty: 'مهمانی برای دعوت انتخاب نشده است.',
|
||||
scheduleSectionTitle: 'زمان برگزاری',
|
||||
addressVisibilitySectionTitle: 'حریم خصوصی آدرس',
|
||||
inviteGuestsSectionTitle: 'دعوت مهمانان',
|
||||
eventDetails: 'جزئیات رویداد',
|
||||
skip: 'رد کردن',
|
||||
|
||||
// My events
|
||||
myEventsDescription: 'رزروها و رویدادهایی که میزبانی میکنید',
|
||||
myEventsLoadFailed: 'دریافت رویدادهای شما ناموفق بود',
|
||||
tabCreate: 'ساخت رویداد',
|
||||
tabCreated: 'ایجاد شدهها',
|
||||
tabRegistered: 'ثبتنام شدهها',
|
||||
tabUpcoming: 'پیش رو',
|
||||
tabHeld: 'برگزار شده',
|
||||
tabCancelled: 'لغو شده',
|
||||
tabPendingReview: 'در انتظار تایید',
|
||||
tabPublished: 'منتشر شده',
|
||||
tabRejected: 'لغو یا رد شده',
|
||||
tabAttending: 'شرکتکرده',
|
||||
tabHosting: 'میزبانی',
|
||||
emptyListTitle: 'هنوز رویدادی اینجا نیست',
|
||||
emptyListDescription: 'هر رویدادی که برایش ثبتنام کنی، از اینجا در دسترس خواهد بود.',
|
||||
createEvent: 'ایجاد رویداد',
|
||||
createIdentityTitle: 'برای ساخت رویداد، اول هویتت را تأیید کن!',
|
||||
createIdentityDescription: 'برای اینکه بتوانی بهعنوان برگزارکننده رویداد بسازی، لازم است مراحل تأیید هویت را کامل کنی.',
|
||||
startIdentityProcess: 'شروع فرایند احراز هویت',
|
||||
identityResubmit: 'ارسال دوباره احراز هویت',
|
||||
identityRequiredToCreate: 'برای ایجاد رویداد، احراز هویت کن',
|
||||
occupiedSeats: '{occupied} / {capacity} نفر اشغالشده',
|
||||
|
||||
// Consumer event detail
|
||||
eventNotFound: 'رویداد یافت نشد',
|
||||
eventLoadFailedTitle: 'دریافت اطلاعات رویداد ناموفق بود',
|
||||
eventLoadFailedDescription: 'این رویداد یافت نشد یا دیگر در دسترس نیست.',
|
||||
eventUnavailable: 'این رویداد در حال حاضر در دسترس نیست',
|
||||
eventImageAlt: 'تصویر رویداد {title}',
|
||||
eventThumbAlt: 'تصویر بندانگشتی رویداد {title}',
|
||||
eventDefaultImageAlt: 'تصویر پیشفرض رویداد {title}',
|
||||
addressHiddenUntilConfirmed: 'آدرس دقیق پس از تأیید رزرو نمایش داده میشود.',
|
||||
guestRating: 'امتیاز مهمانان',
|
||||
reviewCountParen: '({count} نظر)',
|
||||
hostProfileAria: 'پروفایل {name}',
|
||||
eventHostLabel: 'میزبان این جمع',
|
||||
pastEventsHeld: '{count} رویداد برگزارشده',
|
||||
hostPastEventsBadge: '{count} رویداد برگزار کرده',
|
||||
hostTotalGuestsBadge: '{count} نفر تا حالا در جمعهاش بودن',
|
||||
hostParticipantFeedbackBadge: '{rating}/۵ بازخورد از شرکتکنندهها',
|
||||
hostMemberSinceBadge: 'از {monthYear} در قبیله',
|
||||
hostContactLinksLabel: 'راههای ارتباطی',
|
||||
bookmarkedByCount: 'این رویداد توسط {count} نفر ذخیره شده است.',
|
||||
capacityOccupiedOf: '{occupied} از {capacity} نفر',
|
||||
|
||||
// Booking CTA
|
||||
|
||||
// Create wizard — steps & layout
|
||||
wizardStep1Label: 'معرفی رویداد',
|
||||
wizardStep2Label: 'زمان و مکان',
|
||||
wizardStep3Label: 'بلیط و ظرفیت',
|
||||
wizardStep4Label: 'بازبینی نهایی',
|
||||
wizardStep1Hint: 'عنوان، دستهبندی، توضیحات و تصاویر رویداد',
|
||||
wizardStep2Hint: 'زمانبندی و محل برگزاری رویداد',
|
||||
wizardStep3Hint: 'ظرفیت، قیمت و جریمه لغو',
|
||||
wizardStep4Hint: 'اطلاعات را از نگاه مهمان بررسی کن و پیشنویس را بساز',
|
||||
wizardTitle: 'رویدادت را بساز',
|
||||
wizardDescription: 'اطلاعات اصلی را مرحلهبهمرحله کامل کن؛ تا زمانی که پیشنویس را نساختهای، تغییرات روی همین دستگاه نگهداری میشوند.',
|
||||
wizardAutosaveOn: 'ذخیرهٔ خودکار روشن است',
|
||||
wizardDraftRestoredStatus: 'پیشنویس بازیابی شد',
|
||||
wizardCloneReadyStatus: 'کپی آمادهٔ ویرایش',
|
||||
wizardAutosaveHint: 'تغییرات این فرم بهصورت خودکار روی این دستگاه ذخیره میشوند.',
|
||||
wizardDraftRestoredHint: 'پیشنویس قبلی بازیابی شد. تغییرات بهصورت خودکار ذخیره میشوند.',
|
||||
wizardCloneHint: 'اطلاعات رویداد قبلی کپی شد. در صورت نیاز تغییر دهید و رویداد جدید را ذخیره کنید.',
|
||||
wizardCloneLoadFailed: 'بارگذاری رویداد برای کپی ناموفق بود',
|
||||
postersRequiredTitle: 'هر دو پوستر الزامی است',
|
||||
postersRequiredDescription: 'پوستر عمودی (۹:۱۶) و پوستر مربعی (۱:۱) را آپلود کنید و سپس پیشنویس را ذخیره کنید.',
|
||||
postersRequiredShort: 'پوستر عمودی (۹:۱۶) و پوستر مربعی (۱:۱) را آپلود کنید.',
|
||||
draftSaved: 'پیشنویس رویداد ذخیره شد',
|
||||
createdPartialTitle: 'رویداد ایجاد شد اما برخی موارد ذخیره نشد',
|
||||
createdPartialDescription: 'به صفحه جزئیات رویداد بروید و تکمیل کنید.',
|
||||
draftSaveFailed: 'ذخیره پیشنویس ناموفق بود',
|
||||
invitePreviousGuestsTitle: 'دعوت مهمانان قبلی',
|
||||
invitePreviousGuestsStatus: 'پیشنویس ساخته شد',
|
||||
invitePreviousGuestsDescription:
|
||||
'اگر مایل باشی، مهمانانی که قبلاً در رویدادهایت بودهاند را انتخاب کن. بعد از انتشار رویداد، پیامک دعوت برایشان ارسال میشود.',
|
||||
selectGuestsTitle: 'انتخاب مهمانان',
|
||||
selectGuestsDescription: 'این اقدام اختیاری است و جزو مراحل لازم برای ساخت رویداد نیست.',
|
||||
cloneTitleSuffix: '{title} (کپی)',
|
||||
wizardStepsAria: 'مراحل ایجاد رویداد',
|
||||
wizardProgressTitle: 'آتشی بر قبیله بیافروز',
|
||||
wizardProgressStep1: 'شروع رویداد',
|
||||
wizardProgressStep2: 'تاریخ و زمان',
|
||||
wizardProgressStep3: 'بلیط و ظرفیت',
|
||||
wizardProgressStep4: 'تایید نهایی',
|
||||
wizardProgressAria: '{progress} درصد تکمیل شده',
|
||||
wizardInProgress: 'در حال تکمیل',
|
||||
formatToman: '{value} تومان',
|
||||
|
||||
// Step 1
|
||||
categoriesLoadFailed: 'بارگذاری دستهبندیها ناموفق بود',
|
||||
mediaSectionDescription: 'پوستر عمودی و پوستر مربعی هر دو الزامیاند. تصاویر گالری اختیاریاند و کراپ نمیشوند.',
|
||||
basicInfoSectionDescription: 'اطلاعاتی را وارد کن که به مهمان کمک میکند سریع موضوع رویداد را بفهمد.',
|
||||
eventTitleLabel: 'عنوان رویداد',
|
||||
shortDescriptionLabel: 'معرفی کوتاه',
|
||||
fullDescriptionLabel: 'توضیحات کامل',
|
||||
advanced: '(پیشرفته)',
|
||||
slugLabel: 'لینک اختصاصی(پیشرفته)',
|
||||
slugHintFromTitle: 'از روی عنوان ساخته میشود؛ در صورت نیاز میتوانید عوض کنید.',
|
||||
slugPublicLink: 'لینک عمومی: /events/{slug}',
|
||||
categoryPlaceholder: 'یک دستهبندی را انتخاب کنید',
|
||||
|
||||
// Step 2
|
||||
addressVisibilityAttendeesOnly: 'فقط برای رزروکنندگان تأییدشده',
|
||||
addressVisibilityPublic: 'نمایش برای همه',
|
||||
citiesLoadFailed: 'بارگذاری شهرها ناموفق بود',
|
||||
scheduleSectionDescription: 'شروع و پایان را دقیق وارد کن؛ پس از اولین رزرو، تغییر این اطلاعات محدود میشود.',
|
||||
startsAtDateLabel: 'تاریخ شروع',
|
||||
startsAtTimeLabel: 'ساعت شروع',
|
||||
endsAtDateLabel: 'تاریخ پایان',
|
||||
endsAtTimeLabel: 'ساعت پایان',
|
||||
venueSectionTitle: 'مکان برگزاری',
|
||||
venueSectionDescription: 'شهر، آدرس و نقطهٔ دقیق محل برگزاری را مشخص کن.',
|
||||
consumerVenueSectionDescription: 'آدرس و نقطهٔ دقیق محل برگزاری را مشخص کن.',
|
||||
hostCityLabel: 'شهر رویداد',
|
||||
hostCityMissing: 'شهر پروفایل شما مشخص نیست. ابتدا شهر را در پروفایل تکمیل کنید.',
|
||||
addressPlaceholder: 'آدرس محل برگزاری',
|
||||
addressHostContactHint: 'پیشفرض از «اطلاعات تماس» میزبان؛ در صورت نیاز ویرایش کنید.',
|
||||
addressVisibilitySectionDescription: 'اگر محل خصوصی است، میتوانی آدرس دقیق را فقط به مهمانان قطعی نشان بدهی.',
|
||||
addressVisibilityLabel: 'نمایش آدرس دقیق',
|
||||
generalAreaLabel: 'منطقه کلی ',
|
||||
generalAreaPlaceholder: 'مثلاً: مشهد، خیابان هفتتیر',
|
||||
mapTitle: 'موقعیت روی نقشه',
|
||||
mapLocked: 'موقعیت بهخاطر رزرو فعال مهمانها قفل شده است.',
|
||||
reservedCapacityEditLockedHint: 'ظرفیت رزرو خارج از قبیله را از صفحه مدیریت رویداد تغییر دهید.',
|
||||
mapHint: 'روی نقشه کلیک کنید تا موقعیت انتخاب شود؛ آدرس بهصورت خودکار پر میشود و قابل ویرایش است.',
|
||||
mapModalHint: 'برای تعیین نقطهٔ دقیق، نقشه را باز کنید؛ آدرس پس از انتخاب پر میشود و قابل ویرایش است.',
|
||||
mapSelectModalTitle: 'انتخاب مکان از روی نقشه',
|
||||
@ -259,66 +52,19 @@ export const events = {
|
||||
mapKeyMissing: 'کلید نقشه (MAP_API_KEY) تنظیم نشده است',
|
||||
mapLoading: 'در حال بارگذاری نقشه…',
|
||||
mapAddressNotFound: 'آدرسی برای این نقطه پیدا نشد',
|
||||
|
||||
// Step 3
|
||||
pricingSectionDescription: 'نوع ثبتنام را انتخاب کن؛ برای رویداد پولی، مبلغ نهایی بلیط را وارد کن.',
|
||||
ticketTypeSectionTitle: 'نوع بلیط',
|
||||
ticketPaid: 'با تعیین قیمت',
|
||||
reservedCapacityWizardHint:
|
||||
'مهمانهایی که از قبل یا از کانال دیگری (مثلاً اینستاگرام) رزرو کردهاند. این عدد از ظرفیت قابلرزرو در قبیله کم میشود.',
|
||||
audienceSectionTitle: 'محدودیت شرکتکنندگان',
|
||||
cancellationSectionTitle: 'لغو و بازپرداخت',
|
||||
moreSettings: 'تنظیمات بیشتر',
|
||||
pricePerTicketLabel: 'قیمت هر بلیط (تومان)',
|
||||
reservedCapacityLabel: 'ظرفیت رزرو شده',
|
||||
audienceSectionDescription: 'اگر رویداد برای همه نیست، محدودیت جنسیت یا سن را انتخاب کن. پیشفرض آزاد است.',
|
||||
genderRestrictionLabel: 'محدودیت جنسی',
|
||||
peopleRestrictionLabel: 'محدودیتافراد',
|
||||
ageRestrictionLabel: 'محدودیت سنی',
|
||||
cancellationSectionDescription: 'شرایط لغو را پیش از رزرو به مهمان نشان میدهیم تا تصمیم شفافی بگیرد.',
|
||||
cancellationFeeLabel: 'لغو لحظه آخری — ۱ دقیقه تا ۱۲ ساعت مانده',
|
||||
cancellationFee12To24HoursLabel: 'لغو در آستانه برگزاری — ۱۲ تا ۲۴ ساعت مانده',
|
||||
cancellationFeeMoreThan24HoursLabel: 'لغو زودهنگام — بیش از ۲۴ ساعت مانده',
|
||||
advancedSettingsHint: 'پیشنهاد میکنیم مقادیر پیشفرض را نگه داری',
|
||||
discoverableLabel: 'نمایش در جستجوی عمومی',
|
||||
autoCreateGroupLabel: 'ساخت خودکار گروه چت پس از رویداد',
|
||||
waitlistAutoOfferLabel: 'پیشنهاد خودکار جای خالی به لیست انتظار',
|
||||
sendReviewSmsLabel: 'پیامک درخواست نظر پس از رویداد',
|
||||
|
||||
// Step 4 preview / FAQ
|
||||
previewSectionDescription: 'این خلاصه همان اطلاعاتی است که مهمان برای تصمیمگیری به آن نیاز دارد.',
|
||||
posterAria: 'پوستر رویداد',
|
||||
noPosterAria: 'رویداد بدون پوستر',
|
||||
eventCategoryFallback: 'دستهبندی رویداد',
|
||||
readyToSave: 'آمادهٔ ذخیره است',
|
||||
previewEventTitle: 'پیشنمایش رویداد',
|
||||
checklistIntroDone: 'معرفی و دستهبندی تکمیل شده',
|
||||
checklistScheduleDone: 'زمان و مکان مشخص است',
|
||||
checklistPricingDone: 'نوع بلیط و ظرفیت تعیین شده',
|
||||
checklistPosterDone: 'پوستر رویداد انتخاب شده',
|
||||
addQuestion: 'افزودن سوال',
|
||||
faqSectionDescription: 'اگر مهمانها معمولاً سؤال مشخصی دارند، همینجا پاسخ آن را اضافه کن. این بخش اختیاری است.',
|
||||
questionLabel: 'سوال',
|
||||
saveDraft: 'ذخیره پیشنویس',
|
||||
|
||||
// Step 5 guests
|
||||
guestsSaved: 'مهمانان انتخاب شدند. بعد از انتشار رویداد پیامک دعوت برایشان ارسال میشود',
|
||||
guestsSaveFailed: 'ذخیره مهمانان ناموفق بود',
|
||||
guestsSectionDescription:
|
||||
'از میان افرادی که قبلاً در رویدادهای شما حضور داشتهاند انتخاب کنید. بعد از انتشار رویداد، پیامک دعوت برایشان ارسال میشود. این مرحله اختیاری است.',
|
||||
sendInvitation: 'ذخیره مهمانان',
|
||||
selectAllGuests: 'انتخاب همه',
|
||||
guestSearchAria: 'جستجوی کاربران',
|
||||
guestSearchPlaceholder: 'نام مهمان را جستجو کنید',
|
||||
finish: 'اتمام',
|
||||
|
||||
// Media uploader
|
||||
imageReadFailed: 'خواندن تصویر ناموفق بود',
|
||||
postersBothRequired: 'پوستر عمودی (۹:۱۶) و پوستر مربعی (۱:۱) هر دو الزامی هستند.',
|
||||
portraitPosterLabel: 'پوستر عمودی',
|
||||
portraitPosterEmpty: 'افزودن پوستر عمودی',
|
||||
portraitPosterHint: 'نسبت ۹:۱۶',
|
||||
squarePosterLabel: 'پوستر مربعی',
|
||||
squarePosterEmpty: 'افزودن پوستر مربعی',
|
||||
squarePosterHint: 'نسبت ۱:۱',
|
||||
galleryTitle: 'تصاویر گالری',
|
||||
@ -336,21 +82,13 @@ export const events = {
|
||||
cropPortraitHint: 'پوستر کارتهای عمودی با نسبت ۹:۱۶ برش داده میشود.',
|
||||
pickOtherSquarePoster: 'انتخاب عکس دیگر برای پوستر مربعی',
|
||||
change: 'تغییر',
|
||||
|
||||
// New / identity gate
|
||||
createEventPageTitle: 'ایجاد رویداد جدید',
|
||||
nationalIdImageLabel: 'تصویر کارت ملی',
|
||||
|
||||
// Edit
|
||||
editEventTitle: 'ویرایش',
|
||||
invalidEventId: 'شناسه رویداد نامعتبر است',
|
||||
eventLoadFailed: 'بارگذاری رویداد ناموفق بود',
|
||||
eventUpdated: 'رویداد بهروزرسانی شد',
|
||||
eventUpdateFailed: 'ذخیره تغییرات ناموفق بود',
|
||||
editOwnerOnly: 'فقط سازندهی رویداد میتواند آن را ویرایش کند.',
|
||||
editStatusBlocked: 'این رویداد در وضعیت «{status}» دیگر قابل ویرایش نیست.',
|
||||
capacityBelowOccupied: 'ظرفیت نمیتواند کمتر از صندلیهای اشغالشده ({min}) باشد',
|
||||
capacityMinOccupiedHint: 'با توجه به رزروهای فعال، ظرفیت نمیتواند کمتر از {min} باشد.',
|
||||
basicInfoCardTitle: 'اطلاعات پایه',
|
||||
basicInfoCardDescription: 'عنوان، دستهبندی و توضیحات اصلی رویداد',
|
||||
slugLockedHint: 'پس از انتشار، تغییر اسلاگ تا تایید ادمین روی لینک عمومی اعمال نمیشود.',
|
||||
@ -362,16 +100,6 @@ export const events = {
|
||||
autoCreateGroupAfterEndLabel: 'ساخت خودکار گروه چت بعد از پایان رویداد',
|
||||
sendReviewSmsAfterEndLabel: 'ارسال پیامک درخواست نظر بعد از پایان رویداد',
|
||||
editLockedWithBookings: 'این رویداد {count} رزرو فعال دارد؛ زمان، مکان، قیمت، جریمه لغو، محدودیت جنسی/سنی و تنظیمات رفتاری قفل شدهاند.',
|
||||
editRevisionConfirmTitle: 'نیاز به بررسی مجدد ادمین',
|
||||
editRevisionConfirmMessage:
|
||||
'با ذخیره، تغییرات برای بررسی ادمین ارسال میشود. تا زمان تایید، مهمانها همان نسخهٔ فعلی را میبینند. پس از تایید ادمین، رویداد با اطلاعات جدید فوراً منتشر میشود و نیازی به تایید دوباره شما نیست.',
|
||||
editRevisionConfirmAccept: 'ارسال برای بررسی',
|
||||
editRevisionSubmitted: 'ویرایش برای بررسی ادمین ارسال شد',
|
||||
editRevisionPendingBanner: 'یک ویرایش در انتظار تایید ادمین دارید. ذخیرهٔ دوباره جایگزین آن میشود.',
|
||||
editRevisionWithdraw: 'انصراف از ویرایش',
|
||||
editRevisionWithdrawn: 'ویرایش لغو شد',
|
||||
editRevisionWithdrawFailed: 'لغو ویرایش ناموفق بود',
|
||||
editSaved: 'تغییرات ذخیره شد',
|
||||
editMediaCardTitle: 'پوستر و تصاویر',
|
||||
editFaqCardTitle: 'سوالات متداول',
|
||||
reservedCapacityEditHint: 'ظرفیت رزرو شده: {count} نفر (مهمانهای خارج از قبیله). تغییر این عدد از صفحه مدیریت ایونت انجام میشود.',
|
||||
@ -381,148 +109,15 @@ export const events = {
|
||||
revisionApprove: 'تایید و اعمال ویرایش',
|
||||
revisionReject: 'رد ویرایش',
|
||||
revisionRejectReasonLabel: 'دلیل رد',
|
||||
revisionNoPending: 'ویرایش معلقی برای این رویداد نیست',
|
||||
revisionLoadFailed: 'بارگذاری ویرایش ناموفق بود',
|
||||
|
||||
// Audience
|
||||
genderOpen: 'آزاد برای عموم',
|
||||
genderFemaleOnly: 'خانمها',
|
||||
genderMaleOnly: 'آقایان',
|
||||
age15_19: '۱۵-۱۹ سال',
|
||||
age20_24: '۲۰-۲۴ سال',
|
||||
age25Plus: '+۲۵',
|
||||
|
||||
// Manage dashboard
|
||||
manageTitle: 'مدیریت ایونت',
|
||||
manageLoadFailedTitle: 'دریافت اطلاعات مدیریتی ناموفق بود',
|
||||
manageLoadFailedDescription: 'اطلاعات مدیریتی این ایونت در دسترس نیست یا شما مالک آن نیستید.',
|
||||
shareLinkCopiedShort: 'لینک {channel} کپی شد',
|
||||
reservedCapacityMin: 'ظرفیت رزرو شده باید صفر یا بیشتر باشد',
|
||||
reservedCapacityUpdated: 'ظرفیت رزرو شده بهروز شد',
|
||||
reservedCapacityUpdateFailed: 'تغییر ظرفیت رزرو شده ناموفق بود',
|
||||
preview: 'پیشنمایش',
|
||||
ariaCapacityFill: 'درصد تکمیل ظرفیت',
|
||||
activeRegistrations: '{count} ثبتنام فعال',
|
||||
remainingCapacity: '{count} نفر باقیمانده',
|
||||
reservedCapacityDisplay: 'ظرفیت رزرو شده: {count}',
|
||||
reservedCapacityHelp:
|
||||
'مهمانهای خارج از قبیله (مثلاً اینستاگرام). صندلیهای در حال پرداخت هم اشغالشدهاند؛ حداکثر قابلتنظیم {max} است.',
|
||||
changeReservedCapacity: 'تغییر ظرفیت رزرو',
|
||||
manageCapacity: 'مدیریت ظرفیت',
|
||||
currentAvailableCapacity: 'ظرفیت خالی فعلی: {count} نفر',
|
||||
capacityValuesInvalid: 'ظرفیتها باید عدد صحیح صفر یا بزرگتر باشند',
|
||||
capacityUpdated: 'ظرفیت رویداد بهروز شد',
|
||||
capacityUpdateFailed: 'تغییر ظرفیت رویداد ناموفق بود',
|
||||
manageSectionsAria: 'بخشهای مدیریت ایونت',
|
||||
confirmedRegistrations: 'ثبتنام قطعی',
|
||||
ofCapacityHint: 'از {count} نفر ظرفیت',
|
||||
checkedIn: 'حاضر شده',
|
||||
awaitingCheckInHint: '{count} نفر در انتظار',
|
||||
waitlistLabel: 'فهرست انتظار',
|
||||
pendingPaymentHint: '{count} پرداخت ناقص',
|
||||
netRevenue: 'درآمد خالص',
|
||||
bookmarksHint: '{count} ذخیره',
|
||||
registrationStatusTitle: 'وضعیت ثبتنامها',
|
||||
statusConfirmed: 'قطعی',
|
||||
statusPendingPayment: 'پرداخت ناقص',
|
||||
statusRefunded: 'بازپرداخت',
|
||||
statusExpired: 'منقضی',
|
||||
statusNoShow: 'غایب',
|
||||
trafficLinksTitle: 'لینکهای جذب و منبع ورود',
|
||||
trafficLinksHint: 'برای شمارش بازدید و رزرو، لینک اختصاصی هر کانال را در اینستاگرام یا تلگرام بگذارید.',
|
||||
copyInstagramLink: 'کپی لینک اینستاگرام',
|
||||
copyTelegramLink: 'کپی لینک تلگرام',
|
||||
instagramUniqueVisits: 'بازدید یکتا از اینستاگرام',
|
||||
instagramConfirmedBookings: 'رزرو قطعی از اینستاگرام',
|
||||
telegramUniqueVisits: 'بازدید یکتا از تلگرام',
|
||||
telegramConfirmedBookings: 'رزرو قطعی از تلگرام',
|
||||
editInfo: 'ویرایش اطلاعات',
|
||||
viewEventPage: 'مشاهده صفحه ایونت',
|
||||
eventIsFreeTitle: 'این ایونت رایگان است',
|
||||
eventIsFreeFinancialHint: 'برای ایونت رایگان تراکنش درآمدی ایجاد نمیشود.',
|
||||
grossSales: 'فروش ناخالص',
|
||||
platformCommission: 'کمیسیون پلتفرم',
|
||||
guestRefunds: 'بازپرداخت مهمان',
|
||||
settlementStatusTitle: 'وضعیت تسویه درآمد',
|
||||
heldAmount: 'در انتظار آزادسازی',
|
||||
availableAmount: 'آماده تسویه',
|
||||
settledAmount: 'تسویهشده',
|
||||
registeredInfoTitle: 'اطلاعات ثبتشده',
|
||||
slugUrlLabel: 'نشانی اینترنتی',
|
||||
coordinatesLabel: 'مختصات',
|
||||
cancellationFeeField: 'جریمه لغو',
|
||||
discoverableField: 'نمایش عمومی',
|
||||
addressVisibilityField: 'نمایش آدرس',
|
||||
addressAttendeesOnlyWithArea: 'فقط رزروکنندگان تأییدشده (منطقه کلی: {area})',
|
||||
autoCreateGroupField: 'ساخت خودکار گروه چت',
|
||||
waitlistAutoOfferField: 'پیشنهاد خودکار لیست انتظار',
|
||||
sendReviewSmsField: 'پیامک درخواست نظر بعد از پایان',
|
||||
discoveryPlacementsField: 'جایگاههای نمایش',
|
||||
noSpecialPlacement: 'جایگاه ویژهای ندارد',
|
||||
publishedAtField: 'زمان انتشار',
|
||||
notPublished: 'منتشر نشده',
|
||||
createdAtField: 'زمان ایجاد',
|
||||
mediaTitle: 'رسانهها ({count})',
|
||||
mediaAlt: 'رسانه {title}',
|
||||
noMedia: 'رسانهای ثبت نشده است.',
|
||||
faqsTitle: 'سوالات متداول ({count})',
|
||||
noFaqs: 'سوالی ثبت نشده است.',
|
||||
changeReservedCapacityTitle: 'تغییر ظرفیت رزرو شده',
|
||||
reservedCapacityModalHint: 'ثبتنام فعال (قطعی و در حال پرداخت): {booked} — حداکثر ظرفیت رزرو شده: {max}',
|
||||
|
||||
// Attendance
|
||||
filterAll: 'همه',
|
||||
filterNotCheckedIn: 'در انتظار حضور',
|
||||
bookingStatusPendingPayment: 'پرداخت تکمیل نشده',
|
||||
bookingStatusExpired: 'منقضی شده',
|
||||
bookingStatusRefunded: 'بازپرداخت شده',
|
||||
attendeesLoadFailed: 'دریافت فهرست مهمانان ناموفق بود.',
|
||||
checkInSuccess: 'حضور مهمان با موفقیت ثبت شد',
|
||||
checkInFailed: 'ثبت حضور ناموفق بود',
|
||||
checkInFailedDescription: 'وضعیت ثبتنام مهمان را دوباره بررسی کنید.',
|
||||
attendanceSearchHint: 'نام، نام خانوادگی، شماره موبایل یا کد رزرو مهمان را جستوجو کنید.',
|
||||
attendanceSearchAria: 'جستوجوی مهمان با نام یا شماره موبایل',
|
||||
attendanceSearchPlaceholder: 'مثلاً سارا احمدی یا 0912...',
|
||||
attendanceFilterAria: 'فیلتر وضعیت مهمانان',
|
||||
ariaLoadingAttendees: 'در حال دریافت مهمانان',
|
||||
noAttendeeSearch: 'مهمانی با این نام یا شماره ثبتنام نکرده است.',
|
||||
noAttendeeInFilter: 'مهمان موردنظر در این فیلتر پیدا نشد.',
|
||||
noAttendeeInStatus: 'مهمانی در این وضعیت وجود ندارد.',
|
||||
searchPhoneHint: 'شماره را با 09، 98 یا ارقام فارسی هم میتوانید وارد کنید.',
|
||||
tryFilterAllHint: 'برای بررسی ثبتنام، فیلتر «همه» را انتخاب کنید.',
|
||||
checkInRecorded: 'حضور ثبت شده',
|
||||
guestUnnamed: 'مهمان بدون نام',
|
||||
attendanceStatusLabel: 'وضعیت حضور',
|
||||
present: 'حاضر',
|
||||
notYetEntered: 'هنوز وارد نشده',
|
||||
notAllowed: 'غیرمجاز',
|
||||
checkInGuest: 'ثبت حضور مهمان',
|
||||
checkInOnlyBeforeEnd: 'ثبت حضور فقط تا پیش از پایان رسمی ایونت فعال است.',
|
||||
previous: 'قبلی',
|
||||
next: 'بعدی',
|
||||
pageOf: 'صفحه {page} از {total}',
|
||||
resultsCount: '{count} نتیجه',
|
||||
searchResultsFor: 'نتیجه جستوجوی «{query}»',
|
||||
|
||||
// Waitlist manage
|
||||
waitlistStatusNotified: 'پیشنهاد ارسالشده',
|
||||
waitlistStatusAccepted: 'پذیرفتهشده',
|
||||
waitlistStatusConverted: 'تبدیل به رزرو',
|
||||
waitlistStatusCancelled: 'انصراف داده',
|
||||
waitlistLoadFailed: 'دریافت فهرست انتظار ناموفق بود.',
|
||||
offerSeatFailed: 'پیشنهاد جای خالی ناموفق بود',
|
||||
offerSeatSuccess: 'جای خالی به این نفر پیشنهاد شد',
|
||||
waitlistLoading: 'در حال بارگذاری فهرست انتظار...',
|
||||
waitlistAutoOnHint:
|
||||
'پیشنهاد خودکار روشن است: با آزادشدن جای خالی، به نفر اول صف پیشنهاد داده میشود. در صورت نیاز میتوانید همینجا شخص دیگری را دستی انتخاب کنید.',
|
||||
waitlistAutoOffHint:
|
||||
'پیشنهاد خودکار خاموش است: با آزادشدن جای خالی هیچ پیشنهادی بهصورت خودکار ارسال نمیشود؛ باید از همینجا یک نفر را برای دریافت جای خالی انتخاب کنید.',
|
||||
waitlistEmpty: 'فهرست انتظار خالی است.',
|
||||
waitlistMember: 'عضو صف انتظار',
|
||||
waitlistJoinedAt: 'عضویت: {date}',
|
||||
offerSeat: 'پیشنهاد جای خالی',
|
||||
|
||||
// Discounts
|
||||
percentOff: '{value}٪ تخفیف',
|
||||
amountOff: '{value} تخفیف',
|
||||
discountTypePercent: 'درصدی',
|
||||
@ -572,10 +167,7 @@ export const events = {
|
||||
deactivated: 'غیرفعال شده',
|
||||
redeemedCodesTitle: 'کدهای استفادهشده ({count})',
|
||||
payableAmount: 'پرداختی: {amount}',
|
||||
discountCodeInvalid: 'کد تخفیف معتبر نیست',
|
||||
discountUpdateFailed: 'بهروزرسانی کد تخفیف ناموفق بود',
|
||||
|
||||
// Services
|
||||
categoriesListFailed: 'دریافت لیست دستهبندیها ناموفق بود',
|
||||
categoryCreateFailed: 'ایجاد دستهبندی ناموفق بود',
|
||||
categoryUpdateFailed: 'ویرایش دستهبندی ناموفق بود',
|
||||
|
||||
@ -1,36 +0,0 @@
|
||||
export const identity = {
|
||||
title: 'احراز هویت',
|
||||
gateTitle: 'احراز هویت برای ایجاد رویداد',
|
||||
submitSuccess: 'درخواست احراز هویت شما ثبت شد و در حال بررسی است',
|
||||
submitSuccessShort: 'درخواست احراز هویت شما ثبت شد',
|
||||
otpSent: 'کد تأیید به شماره موبایل ثبتنامی شما ارسال شد',
|
||||
alreadyVerifiedTitle: 'هویت شما قبلاً تأیید شده است',
|
||||
alreadyVerifiedDescription: 'اکنون میتوانید رویداد جدید خود را ایجاد کنید.',
|
||||
pendingTitle: 'احراز هویت شما در حال بررسی است',
|
||||
pendingDescription: 'پس از بررسی نتیجه از طریق اعلانها به شما اطلاع داده میشود.',
|
||||
pendingChip: 'در حال بررسی',
|
||||
pendingBlockCreate: 'تا زمان تأیید احراز هویت، امکان ایجاد رویداد وجود ندارد.',
|
||||
backToMyEvents: 'بازگشت به رویدادهای من',
|
||||
rejectedFallback: 'درخواست احراز هویت شما رد شده است.',
|
||||
resubmit: 'ارسال دوباره',
|
||||
formSectionTitle: 'اطلاعات هویتی',
|
||||
formSectionDescription: 'برای میزبانی رویداد، کد ملی خود را وارد کنید و قرارداد همکاری را بپذیرید.',
|
||||
gateFormHint: 'پیش از شروع ساخت رویداد، احراز هویت را تکمیل کنید.',
|
||||
nationalCode: 'کد ملی',
|
||||
nationalCodeHint: 'کد ملی واردشده باید با شماره تلفن همراهی که با آن ثبتنام کردهاید برای یک شخص باشد.',
|
||||
contractSectionTitle: 'قرارداد همکاری',
|
||||
contractSectionDescription: 'متن کامل قرارداد را مطالعه کنید و در صورت موافقت، تأیید کنید.',
|
||||
contractVersion: 'نسخه قرارداد: {version}',
|
||||
contractCheckbox:
|
||||
'قرارداد همکاری و حقالعملکاری قبیله را مطالعه کردم و با مفاد آن موافقم. تأیید میکنم که مسئولیت قانونی برگزاری و اجرای رویدادهای من بر عهده من است.',
|
||||
acceptanceMetaNote:
|
||||
'تاریخ پذیرش، نسخه قرارداد، شناسه برگزارکننده و شناسه پذیرش الکترونیکی پس از تأیید پیامکی بهصورت خودکار ثبت میشوند.',
|
||||
sendOtp: 'دریافت کد تأیید',
|
||||
otpSectionTitle: 'تأیید پیامکی',
|
||||
otpSectionDescription: 'کد ارسالشده به شماره موبایل ثبتنامی خود را وارد کنید.',
|
||||
otpCode: 'کد تأیید',
|
||||
confirmOtp: 'تأیید و ارسال درخواست',
|
||||
resendOtp: 'ارسال دوباره کد',
|
||||
changeNationalCode: 'ویرایش کد ملی',
|
||||
loadStatusFailed: 'بررسی وضعیت احراز هویت ناموفق بود.',
|
||||
} as const
|
||||
@ -1,34 +1,26 @@
|
||||
import { auth } from './auth'
|
||||
import { blog } from './blog'
|
||||
import { bookings } from './bookings'
|
||||
import { chats } from './chats'
|
||||
import { common } from './common'
|
||||
import { discovery } from './discovery'
|
||||
import { errors } from './errors'
|
||||
import { events } from './events'
|
||||
import { identity } from './identity'
|
||||
import { notifications } from './notifications'
|
||||
import { profile } from './profile'
|
||||
import { reviews } from './reviews'
|
||||
import { seo } from './seo'
|
||||
import { support } from './support'
|
||||
import { status } from './status'
|
||||
import { validation } from './validation'
|
||||
import { wallet } from './wallet'
|
||||
|
||||
export const texts = {
|
||||
common,
|
||||
errors,
|
||||
auth,
|
||||
discovery,
|
||||
bookings,
|
||||
chats,
|
||||
events,
|
||||
profile,
|
||||
wallet,
|
||||
identity,
|
||||
support,
|
||||
notifications,
|
||||
reviews,
|
||||
blog,
|
||||
seo,
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
export const notifications = {
|
||||
title: 'اعلانها',
|
||||
loadFailed: 'دریافت اعلانها ناموفق بود',
|
||||
empty: 'هنوز اعلانی ندارید',
|
||||
|
||||
pushActive: 'اعلانهای Push روی این دستگاه فعال است',
|
||||
pushBlockedPrefix: 'اعلانها قبلاً مسدود شدهاند. برای فعالسازی دوباره: ',
|
||||
pushBlockedIos: 'به تنظیمات آیفون بروید: Settings ← قبیله ← Notifications ← Allow Notifications.',
|
||||
pushBlockedBrowser: 'روی آیکون قفل یا تنظیمات کنار آدرس سایت در مرورگر بزنید، «اعلانها» را پیدا کنید و روی «اجازه» بگذارید.',
|
||||
pushTitle: 'اعلانهای Push',
|
||||
pushDescription: 'برای دریافت اعلان پیام و رزرو حتی وقتی اپ بسته است، فعال کنید.',
|
||||
enable: 'فعالسازی',
|
||||
|
||||
installTitle: 'نصب اپلیکیشن قبیله',
|
||||
installIosHint: 'برای نصب: در نوار پایین Safari روی دکمهی اشتراکگذاری بزنید، سپس «Add to Home Screen» را انتخاب کنید.',
|
||||
installAndroidHint: 'برای دسترسی سریعتر و دریافت اعلانها، قبیله را روی گوشی خود نصب کنید.',
|
||||
install: 'نصب',
|
||||
later: 'بعداً',
|
||||
updateReady: 'نسخه جدید قبیله آماده است.',
|
||||
update: 'بهروزرسانی',
|
||||
} as const
|
||||
143
texts/profile.ts
143
texts/profile.ts
@ -1,147 +1,4 @@
|
||||
export const profile = {
|
||||
title: 'پروفایل من',
|
||||
description: 'حساب، کیف پول و تنظیمات شما',
|
||||
loadFailed: 'دریافت اطلاعات پروفایل ناموفق بود',
|
||||
avatarAlt: 'تصویر پروفایل',
|
||||
guestFallbackName: 'کاربر گرامی',
|
||||
walletExpandAria: 'باز کردن کیف پول',
|
||||
walletCollapseAria: 'بستن کیف پول',
|
||||
walletDeposit: 'واریز به کیف پول',
|
||||
walletWithdraw: 'برداشت مستقیم',
|
||||
walletSeeAll: 'مشاهده همه تراکنشها',
|
||||
walletEmptyTransactions: 'هنوز تراکنشی ثبت نشده است',
|
||||
identityBannerDescription: 'برای برگزاری رویداد لازمه که هویتتو تایید کنی!',
|
||||
identityBannerPending: 'درخواست احراز هویتت در حال بررسی است.',
|
||||
identityBannerRejected: 'احراز هویت رد شده؛ برای ارسال دوباره اینجا بزن.',
|
||||
editProfileAria: 'ویرایش پروفایل',
|
||||
moreMenuAria: 'منوی بیشتر',
|
||||
moreMenuTitle: 'تنظیمات و دسترسیها',
|
||||
followersCount: '{count} دنبالکننده',
|
||||
metaSeparator: ' • ',
|
||||
bookmarksSectionTitle: 'ذخیرهشدهها',
|
||||
bookmarksSectionCount: '{count} رویداد',
|
||||
bookmarksSectionEmpty: 'رویداد ذخیرهشدهای ندارید.',
|
||||
connectionsTitle: 'ارتباطهای من',
|
||||
connectionsFollowingCount: '{count} دنبالشده',
|
||||
connectionsEmpty: 'هنوز ارتباطی ثبت نشده است.',
|
||||
seeAll: 'مشاهده همه',
|
||||
identityStatusForHosting: 'وضعیت احراز هویت برای میزبانی',
|
||||
logout: 'خروج',
|
||||
logoutConfirm: 'برای خروج مطمئن هستید؟',
|
||||
logoutFailed: 'خروج ناموفق بود',
|
||||
|
||||
menuBookmarks: 'رویدادهای ذخیرهشده',
|
||||
menuAccount: 'اطلاعات حساب کاربری',
|
||||
menuPublicProfile: 'پروفایل عمومی',
|
||||
menuContact: 'اطلاعات تماس',
|
||||
menuSocial: 'شبکههای اجتماعی',
|
||||
menuIdentity: 'احراز هویت میزبان',
|
||||
menuSupport: 'پشتیبانی و درخواستهای من',
|
||||
menuFollowing: 'دنبالشدهها',
|
||||
notificationsUnreadAria: 'اعلانها، {count} خواندهنشده',
|
||||
unreadOverflow: '۹+',
|
||||
|
||||
editSettingsAria: 'بخشهای ویرایش پروفایل',
|
||||
contactTab: 'تماس و شبکهها',
|
||||
usernamePlaceholder: 'نام کاربری',
|
||||
accountLoadFailed: 'دریافت اطلاعات حساب ناموفق بود',
|
||||
editAvatarAria: 'ویرایش تصویر پروفایل',
|
||||
uploadAvatarAria: 'آپلود تصویر پروفایل',
|
||||
mobileLabel: 'موبایل',
|
||||
roleLabel: 'نقش',
|
||||
personalInfoTitle: 'اطلاعات شخصی',
|
||||
personalInfoDescription: 'اطلاعاتی که در پروفایل و ارتباط با سایر کاربران نمایش داده میشود.',
|
||||
genderOther: 'سایر',
|
||||
provincePlaceholder: 'انتخاب استان',
|
||||
bio: 'بیوگرافی',
|
||||
bioPlaceholder: 'کمی درباره خودتان بنویسید',
|
||||
avatarUpdated: 'تصویر پروفایل بروزرسانی شد',
|
||||
avatarUploadFailed: 'آپلود تصویر پروفایل ناموفق بود',
|
||||
accountUpdated: 'اطلاعات حساب بروزرسانی شد',
|
||||
updateFailed: 'بهروزرسانی ناموفق بود',
|
||||
|
||||
contactLoadFailed: 'دریافت اطلاعات تماس ناموفق بود',
|
||||
addressSaved: 'آدرس تماس ذخیره شد',
|
||||
saveFailed: 'ذخیره ناموفق بود',
|
||||
saveAddress: 'ذخیره آدرس',
|
||||
contactSectionDescription: 'حالت روشن: روی پروفایل عمومی نمایش داده میشود؛ حالت خاموش: نمایش داده نمیشود.',
|
||||
defaultVenueAddress: 'آدرس ثابت برگزاری',
|
||||
defaultVenueAddressPlaceholder: 'مثلاً تهران، خیابان ولیعصر، پلاک ۱',
|
||||
networksAndContact: 'شبکهها و راههای تماس',
|
||||
noLinksYet: 'هنوز لینکی ثبت نشده است.',
|
||||
linkValueRequired: 'مقدار لینک را وارد کنید',
|
||||
linkAdded: 'لینک تماس اضافه شد',
|
||||
linkUpdated: 'لینک بروزرسانی شد',
|
||||
linkDeleted: 'لینک حذف شد',
|
||||
linkPublicShown: 'لینک روی پروفایل عمومی نمایش داده میشود',
|
||||
linkPublicHidden: 'لینک روی پروفایل عمومی نمایش داده نمیشود',
|
||||
add: 'افزودن',
|
||||
contactChannelAria: 'کانال تماس',
|
||||
contactLinkValueAria: 'مقدار لینک تماس',
|
||||
contactValueAria: 'مقدار {channel}',
|
||||
allChannelsUsed: 'همه کانالهای مجاز ثبت شدهاند.',
|
||||
genericError: 'خطایی رخ داد',
|
||||
|
||||
channelTwitter: 'ایکس (توییتر)',
|
||||
channelLinkedin: 'لینکدین',
|
||||
channelLinkedinPlaceholder: 'نام کاربری پروفایل',
|
||||
channelYoutube: 'یوتیوب',
|
||||
channelYoutubePlaceholder: 'نام کانال',
|
||||
channelWebsite: 'وبسایت',
|
||||
channelWebsitePlaceholder: 'https://example.com',
|
||||
channelSupportPhone: 'تلفن پشتیبانی',
|
||||
channelSupportPhonePlaceholder: '0912… یا 98912…',
|
||||
|
||||
followingTitle: 'دنبالکردنها',
|
||||
followersTab: 'دنبالکنندگان',
|
||||
followingLoadFailed: 'دریافت میزبانها ناموفق بود',
|
||||
followingEmpty: 'هنوز هیچ میزبانی را دنبال نکردهاید',
|
||||
followersLoadFailed: 'دریافت دنبالکنندگان ناموفق بود',
|
||||
followersEmpty: 'هنوز هیچ دنبالکنندهای ندارید',
|
||||
|
||||
bookmarksLoadFailed: 'دریافت رویدادهای ذخیرهشده ناموفق بود.',
|
||||
bookmarksEmpty: 'هنوز رویدادی برای بعد ذخیره نکردهاید.',
|
||||
|
||||
follow: 'دنبال کردن',
|
||||
following: 'دنبالشده',
|
||||
followStatusLoadingAria: 'در حال بررسی وضعیت دنبالکردن',
|
||||
notifyOnAria: 'روشنکردن اعلان رویداد جدید',
|
||||
notifyOffAria: 'خاموشکردن اعلان رویداد جدید',
|
||||
unfollowTitle: 'لغو دنبالکردن',
|
||||
unfollowConfirm: 'این میزبان را دیگر دنبال نکنم؟',
|
||||
unfollowAccept: 'بله، لغو کن',
|
||||
unfollowReject: 'نه',
|
||||
|
||||
bookmarkSaved: 'برای بعد ذخیره شد',
|
||||
bookmarkRemoved: 'از ذخیرهشدهها حذف شد',
|
||||
bookmarkToggleFailed: 'تغییر وضعیت ذخیرهسازی ناموفق بود',
|
||||
bookmarkSaveAria: 'ذخیره برای بعد',
|
||||
bookmarkRemoveAria: 'حذف از ذخیرهشدهها',
|
||||
|
||||
organizerAvatarAlt: 'تصویر {name}',
|
||||
verifiedHost: 'میزبان تأییدشده',
|
||||
followerStat: 'دنبالکننده',
|
||||
hostedEventsStat: 'رویداد برگزارشده',
|
||||
reviewsCount: '{count} نظر',
|
||||
ratingStat: 'امتیاز',
|
||||
aboutTitle: 'درباره {name}',
|
||||
sendMessage: 'ارسال پیام',
|
||||
upcomingEvents: 'رویدادهای پیش رو',
|
||||
pastEvents: 'رویدادهای گذشته',
|
||||
noUpcomingEvents: 'فعلاً رویداد جدیدی منتشر نشده است.',
|
||||
noPastEvents: 'هنوز رویداد گذشتهای ثبت نشده است.',
|
||||
|
||||
fetchProfileFailed: 'دریافت پروفایل ناموفق بود',
|
||||
updateProfileFailed: 'بهروزرسانی پروفایل ناموفق بود',
|
||||
completeProfileFailed: 'تکمیل پروفایل ناموفق بود',
|
||||
fetchFollowStatusFailed: 'دریافت وضعیت دنبالکردن ناموفق بود',
|
||||
followFailed: 'دنبالکردن میزبان ناموفق بود',
|
||||
unfollowFailed: 'لغو دنبالکردن ناموفق بود',
|
||||
updateNotifyFailed: 'بروزرسانی تنظیمات اعلان ناموفق بود',
|
||||
fetchFollowingFailed: 'دریافت لیست دنبالشدهها ناموفق بود',
|
||||
fetchFollowersFailed: 'دریافت لیست دنبالکنندگان ناموفق بود',
|
||||
fetchContactLinksFailed: 'دریافت لینکهای تماس ناموفق بود',
|
||||
addContactLinkFailed: 'افزودن لینک تماس ناموفق بود',
|
||||
updateContactLinkFailed: 'بهروزرسانی لینک تماس ناموفق بود',
|
||||
deleteContactLinkFailed: 'حذف لینک تماس ناموفق بود',
|
||||
} as const
|
||||
|
||||
@ -1,51 +1,8 @@
|
||||
export const reviews = {
|
||||
// Shared
|
||||
avgFromCount: '{rating} از {count} نظر',
|
||||
loadMore: 'نمایش نظرات بیشتر',
|
||||
loadMoreAlt: 'نمایش نظرهای بیشتر',
|
||||
hostReply: 'پاسخ میزبان',
|
||||
yourReply: 'پاسخ شما',
|
||||
ratingOnly: 'فقط امتیاز ثبت شده است.',
|
||||
loading: 'در حال بارگذاری نظرات…',
|
||||
|
||||
// OrganizerReviewsSection
|
||||
guestReviewsTitle: 'نظرات مهمانان',
|
||||
organizerEmptyHint: 'هنوز نظری برای این میزبان ثبت نشده است.',
|
||||
organizerEmptyTitle: 'نظری ثبت نشده',
|
||||
|
||||
// EventReviewsSection
|
||||
peopleSayTitle: 'آدمهایی که اینجا بودن چی میگن؟',
|
||||
eventEmptyTitle: 'هنوز کسی تجربهش رو نگفته...',
|
||||
eventEmptyHint: 'اولین نفر باش که بعد از این جمع، داستان این تجربه رو تعریف میکنه.',
|
||||
emptyBeFirst: 'اولین نفری باشید که برای این رویداد امتیاز میدهد.',
|
||||
emptyAfterEvent: 'پس از اتمام رویداد، نظرات مهمانان اینجا نمایش داده میشود.',
|
||||
emptyTitle: 'نظری وجود ندارد',
|
||||
shareExperienceTitle: 'از تجربهات بگو',
|
||||
shareExperiencePlaceholder: 'چی دوست داشتی؟ چه چیزی این جمع رو خاص کرد؟',
|
||||
submitReview: 'ثبت نظر',
|
||||
|
||||
// ReviewModal
|
||||
selectRating: 'لطفاً امتیاز را انتخاب کنید',
|
||||
submitted: 'نظر شما ثبت شد',
|
||||
createTitle: 'ثبت نظر و امتیاز',
|
||||
yourRating: 'امتیاز شما',
|
||||
bodyLabel: 'نظر (اختیاری)',
|
||||
bodyPlaceholder: 'اگر مایلید تجربه خود را بنویسید…',
|
||||
ratingOnlyHint: 'میتوانید فقط امتیاز بدهید و متن را خالی بگذارید.',
|
||||
|
||||
// StarRating
|
||||
ratingValueAria: 'امتیاز {value} از ۵',
|
||||
heartAria: '{star} قلب',
|
||||
|
||||
// EventReviewsPanel (host manage)
|
||||
replyBodyRequired: 'متن پاسخ نمیتواند خالی باشد',
|
||||
replySaved: 'پاسخ ذخیره شد',
|
||||
replyDeleted: 'پاسخ حذف شد',
|
||||
hostEmptyDescription: 'وقتی مهمانان بعد از اتمام رویداد نظر بدهند، اینجا نمایش داده میشود.',
|
||||
hostEmptyTitle: 'هنوز نظری ثبت نشده',
|
||||
editReply: 'ویرایش پاسخ',
|
||||
saveReply: 'ذخیره پاسخ',
|
||||
deleteReply: 'حذف پاسخ',
|
||||
replyModalTitle: 'پاسخ به نظر',
|
||||
replyBodyLabel: 'متن پاسخ',
|
||||
} as const
|
||||
|
||||
367
texts/seo.ts
367
texts/seo.ts
@ -1,370 +1,3 @@
|
||||
export const seo = {
|
||||
titleTemplate: '%s | قبیله',
|
||||
defaultDescription: 'پلتفرم رویدادها و تجربههای گروهی قبیله',
|
||||
discoveryDescription: 'رویدادهای گروهی و تجربههای محلی را در قبیله پیدا کنید، رزرو کنید و شرکت کنید.',
|
||||
citiesBreadcrumb: 'شهرها',
|
||||
shellFooterTagline: 'قبیله؛ جایی برای تجربههای گروهی واقعی',
|
||||
organizerProfileDescription: 'پروفایل {name} در قبیله',
|
||||
filterByCity: 'فیلتر بر اساس شهر ←',
|
||||
filterByCategory: 'فیلتر بر اساس دستهبندی ←',
|
||||
categoryEventsHeading: 'رویدادهای {categoryName}',
|
||||
categoryImageAlt: 'تصویر دستهبندی {categoryName}',
|
||||
categoryEmpty: 'در حال حاضر رویداد فعالی در {categoryName} ثبت نشده است',
|
||||
categoryGuideHeading: 'راهنمای انتخاب {categoryName}',
|
||||
categoryTehranLink: 'رویدادهای {categoryName} در تهران ←',
|
||||
cityEventsHeading: 'رویدادها در {cityName}',
|
||||
cityIntro:
|
||||
'برنامهها، ایونتها و دورهمیهای {cityName} را در قبیله ببینید؛ زمان، دستهبندی و جزئیات هر رویداد پیش از ثبتنام در دسترس شماست.',
|
||||
cityEmpty: 'در حال حاضر رویداد فعالی در {cityName} ثبت نشده است.',
|
||||
cityMetaDescription: 'رویدادها و تجربههای گروهی در {cityName} را در قبیله ببینید و رزرو کنید.',
|
||||
cityFaqQuestion: 'چطور رویدادهای {cityName} را پیدا کنم؟',
|
||||
cityFaqAnswer:
|
||||
'رویدادهای فعال این صفحه مرتب بهروز میشوند. برای پیدا کردن برنامهی مناسب، دستهبندی موردنظرتان را انتخاب کنید و صفحهی هر رویداد را برای زمان، محل برگزاری و شرایط ثبتنام بررسی کنید.',
|
||||
comboHeading: 'رویدادهای {categoryName} در {cityName}',
|
||||
comboMetaDescription: 'رویدادهای {categoryName} در {cityName} را در قبیله پیدا کنید و رزرو کنید.',
|
||||
comboEmpty: 'فعلاً رویداد {categoryName} در {cityName} ثبت نشده است.',
|
||||
comboAllCityEvents: 'همه رویدادهای {cityName}',
|
||||
comboAllCategoryEvents: 'همه رویدادهای {categoryName}',
|
||||
categoryHub: {
|
||||
title: 'رویدادها بر اساس دستهبندی',
|
||||
description: 'رویدادها و تجربههای گروهی را بر اساس دستهبندی در قبیله پیدا کنید.',
|
||||
collectionDescription: 'فهرست دستهبندیهای فعال رویداد در قبیله.',
|
||||
subtitle: 'موضوع مورد علاقهتان را انتخاب کنید و رویدادهای مرتبط را ببینید.',
|
||||
empty: 'فعلاً دستهبندی فعالی ثبت نشده است',
|
||||
loading: 'در حال دریافت دستهبندیها',
|
||||
},
|
||||
cityHub: {
|
||||
title: 'رویدادها بر اساس شهر',
|
||||
description: 'رویدادها و تجربههای گروهی را بر اساس شهر خود در قبیله پیدا کنید.',
|
||||
collectionDescription: 'فهرست شهرهایی که رویداد فعال در قبیله دارند.',
|
||||
empty: 'فعلاً رویداد فعالی در هیچ شهری ثبت نشده است.',
|
||||
loading: 'در حال دریافت رویدادهای شهر',
|
||||
},
|
||||
manifest: {
|
||||
shortcutChats: 'گفتگوها',
|
||||
},
|
||||
home: {
|
||||
aboutParagraph1:
|
||||
'قبیله بستری برای کشف رویدادهای حضوری و تجربههای گروهی است. میتوانید برنامههای متناسب با علاقه و شهر خود را پیدا کنید، اطلاعات رویداد و میزبان را بررسی کنید و برای شرکت در آن آماده شوید.',
|
||||
aboutParagraph2:
|
||||
'از دورهمیهای فرهنگی و کتابخوانی تا کارگاههای هنری، برنامههای ورزشی، بازیهای گروهی و تجربههای آموزشی، هدف قبیله این است که مسیر آشنایی با تجربههای واقعی و آدمهای همعلاقه را سادهتر کند.',
|
||||
exploreHeading: 'از کجا شروع کنم؟',
|
||||
hostHeading: 'رویداد خودت را در قبیله برگزار کن',
|
||||
hostBody: 'اگر برگزارکننده، مدرس یا صاحب یک فضای مستقل هستید، با امکانات میزبانی قبیله و مراحل انتشار رویداد آشنا شوید.',
|
||||
faqHeading: 'سؤالهایی که ممکنه قبل از شروع داشته باشی',
|
||||
faqRelatedNavAria: 'پیوندهای مرتبط با سؤالات متداول',
|
||||
discoveryLinks: [
|
||||
{
|
||||
description: 'موضوع مورد علاقهات را انتخاب کن و تجربههای مرتبط را ببین.',
|
||||
href: '/category',
|
||||
},
|
||||
{
|
||||
description: 'برنامهها و تجربههای گروهی نزدیک خودت را پیدا کن.',
|
||||
href: '/city',
|
||||
},
|
||||
{
|
||||
title: 'سؤالات متداول',
|
||||
description: 'رزرو، پرداخت، لغو و میزبانی را کوتاه و مستقیم ببین.',
|
||||
href: '/faq',
|
||||
},
|
||||
{
|
||||
title: 'میزبان شوید',
|
||||
description: 'امکانات برگزارکنندگان و مسیر شروع میزبانی در قبیله.',
|
||||
href: '/become-a-host',
|
||||
},
|
||||
{
|
||||
title: 'راهنمای میزبانی',
|
||||
description: 'چکلیست قدمبهقدم طراحی، قیمتگذاری و اجرای رویداد.',
|
||||
href: '/host-guide',
|
||||
},
|
||||
{
|
||||
title: 'راهنماهای مجله قبیله',
|
||||
description: 'برای انتخاب بهتر، شرکت در رویداد و میزبانی آمادهتر شو.',
|
||||
href: '/blog',
|
||||
},
|
||||
],
|
||||
faqs: [
|
||||
{
|
||||
question: 'چطور توی یه رویداد شرکت کنم؟',
|
||||
answer:
|
||||
'رویدادی که دوست داری رو از صفحه اصلی، دستهبندی یا شهرها پیدا کن، جزئیات و شرایط لغوش رو بخون و دکمه رزرو رو بزن. برای رویداد پولی، بعد از پرداخت موفق جات قطعی میشه. راهنمای کوتاه را در سؤالات متداول ببین.',
|
||||
},
|
||||
{
|
||||
question: 'اگه ظرفیت رویداد پر شده باشه چی؟',
|
||||
answer:
|
||||
'اگه لیست انتظار فعال باشه، میتونی اسمت رو ثبت کنی. اگه جایی خالی بشه، فرصت رزرو بهت پیشنهاد میشه. جزئیات بیشتر در سؤالات متداول آمده است.',
|
||||
},
|
||||
{
|
||||
question: 'اگه نتونم برم چی میشه؟',
|
||||
answer:
|
||||
'از بخش «رویدادهای من» رزروت رو لغو کن. مبلغ قابل بازپرداخت، قبل از تأیید لغو و بر اساس شرایط لغو همون رویداد بهت نشون داده میشه. چارچوب کلی در صفحه لغو و بازگشت وجه است.',
|
||||
},
|
||||
{
|
||||
question: 'خودم هم میتونم یه رویداد بسازم؟',
|
||||
answer: 'آره. معرفی امکانات در «میزبان شوید» است و چکلیست عملی در «راهنمای میزبانی». از لینکهای زیر همین بخش شروع کن.',
|
||||
},
|
||||
{
|
||||
question: 'ساخت رویداد هزینه داره؟',
|
||||
answer:
|
||||
'ساخت و منتشر کردن رویداد هزینهای نداره. اگر رویدادت پولی باشه، کارمزد فروش از مبلغ بلیطها کم میشه و جزئیات مالی در پنل رویداد نشون داده میشه. مسیر شروع: میزبان شوید.',
|
||||
},
|
||||
{
|
||||
question: 'پول بلیطها چطور به دستم میرسه؟',
|
||||
answer:
|
||||
'بعد از نهایی شدن درآمد رویداد و کسر بازپرداختها و موارد مالی، مبلغ قابلتسویه از مسیر تسویههای میزبانی به حساب بانکی تأییدشده پرداخت میشه. مسیر میزبانی را در «میزبان شوید» ببین.',
|
||||
},
|
||||
],
|
||||
},
|
||||
categoryLandings: {
|
||||
'arts-culture': {
|
||||
intro:
|
||||
'در این صفحه اجرا، موسیقی، نمایش، نمایشگاه و تجربههای هنری و فرهنگی حضوری قبیله گرد میآیند. برنامه را بر اساس سبک، مدت، ظرفیت و نقش خودتان — تماشاگر یا سازنده — مقایسه کنید و از همان صفحه رویداد رزرو کنید.',
|
||||
highlights: [
|
||||
'مقایسه اجرا، نمایش و نمایشگاه در یک فهرست',
|
||||
'جزئیات میزبان، زمان و ظرفیت پیش از رزرو',
|
||||
'راهنمای انتخاب تجربه هنری متناسب با سطح و سلیقه',
|
||||
],
|
||||
faqs: [
|
||||
{
|
||||
question: 'رویداد هنر و فرهنگ برای مبتدی هم مناسب است؟',
|
||||
answer:
|
||||
'بسیاری از برنامهها بدون پیشنیاز مهارتی برگزار میشوند. توضیحات هر رویداد سطح مخاطب را مشخص میکند؛ اگر مبهم بود پیش از پرداخت از میزبان بپرسید.',
|
||||
},
|
||||
{
|
||||
question: 'تفاوت تماشا و کارگاه هنری چیست؟',
|
||||
answer:
|
||||
'در اجرا و نمایش معمولاً تماشاگر هستید. در کارگاه یا تجربه ساخت، خودتان کار میکنید و اغلب مواد یا ابزار لازم در صفحه رویداد آمده است.',
|
||||
},
|
||||
{
|
||||
question: 'اگر برنامه فعالی در این دسته نبود چه کنم؟',
|
||||
answer:
|
||||
'صفحه را دوباره سر بزنید یا دستههای نزدیک مثل یادگیری و تجربه را ببینید. برنامههای تازه پس از انتشار میزبان اینجا ظاهر میشوند.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'learning-experience': {
|
||||
intro:
|
||||
'کارگاهها، کلاسها و تجربههای مهارتی حضوری اینجاست؛ از ساختن و پخت تا یادگیری یک تکنیک تازه. پیش از رزرو خروجی جلسه، سطح، نسبت تمرین به توضیح و وسایل لازم را بخوانید.',
|
||||
highlights: ['خروجی مشخص هر کارگاه را پیش از خرید بسنجید', 'سطح، پیشنیاز و ظرفیت در صفحه رویداد', 'رزرو مستقیم با سیاست لغو شفاف'],
|
||||
faqs: [
|
||||
{
|
||||
question: 'چطور بفهمم کارگاه با سطح من میخواند؟',
|
||||
answer:
|
||||
'پیشنیاز و مخاطب اعلامشده را با تجربه فعلی خود مقایسه کنید. اگر فقط عنوان «از صفر تا صد» آمده و برنامه تمرین روشن نیست، از میزبان نمونه جلسه را بخواهید.',
|
||||
},
|
||||
{
|
||||
question: 'وسایل کارگاه جداگانه حساب میشود؟',
|
||||
answer:
|
||||
'بستگی به میزبان دارد. صفحه رویداد باید بگوید مواد و ابزار در هزینه هست یا باید همراه بیاورید؛ اگر نوشته نشده پیش از پرداخت بپرسید.',
|
||||
},
|
||||
{
|
||||
question: 'بعد از کارگاه چطور ادامه بدهم؟',
|
||||
answer:
|
||||
'یک تمرین کوچک را تا ۲۴ ساعت بعد تکرار کنید. اگر میزبان فایل یا راه پیگیری دارد از آن استفاده کنید؛ یادگیری ماندگار از اجرای بعد از جلسه میآید.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'games-entertainment': {
|
||||
intro:
|
||||
'بازی، رقابت و دورهمیهای سرگرمکننده گروهی در این دسته جمع شدهاند. روحیه جمع، سنگینی قوانین و میزان رقابت را با توضیحات میزبان بسنجید تا شب برای همه لذتبخش بماند.',
|
||||
highlights: [
|
||||
'رومیزی، رقابت تیمی و دورهمی شاد در یک جا',
|
||||
'ظرفیت میز و نیاز به دانستن قوانین از قبل',
|
||||
'فضای مناسب تازهوارد و گروههای قدیمی',
|
||||
],
|
||||
faqs: [
|
||||
{
|
||||
question: 'اگر قوانین بازی را بلد نباشم میتوانم بیایم؟',
|
||||
answer: 'بسیاری از برنامهها آموزش در محل دارند. صفحه رویداد را بخوانید؛ اگر مسابقه رسمی است معمولاً آشنایی قبلی لازم است.',
|
||||
},
|
||||
{
|
||||
question: 'برای گروهی که تازه با هم آشنا شدهاند چه برنامهای بهتر است؟',
|
||||
answer: 'بازی با قوانین سبک و رقابت ملایم معمولاً بهتر از مسابقه سنگین است. ظرفیت متوسط کمک میکند کسی در حاشیه نماند.',
|
||||
},
|
||||
{
|
||||
question: 'رزرو گروهی چطور انجام میشود؟',
|
||||
answer:
|
||||
'هر نفر از صفحه همان رویداد رزرو میکند مگر میزبان بلیط گروهی جداگانه تعریف کرده باشد. زمان را اول با همه هماهنگ کنید، بعد بلیط بخرید.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'sports-adventure': {
|
||||
intro:
|
||||
'ورزش، طبیعت، سفر کوتاه و فعالیت بدنی بیرون از فضای معمول در این صفحه دیده میشود. مسافت، سطح سختی، وسایل ضروری و نقطه شروع را پیش از ثبتنام با توان واقعی خودتان بسنجید.',
|
||||
highlights: ['سطح مسیر و مدت حرکت در توضیحات رویداد', 'فهرست وسایل ضروری و نقطه شروع دقیق', 'برنامه جایگزین در هوای نامساعد'],
|
||||
faqs: [
|
||||
{
|
||||
question: 'برای اولین برنامه طبیعتگردی چه سطحی انتخاب کنم؟',
|
||||
answer: 'مسیر کوتاهتر، نزدیک شهر و با توقف مشخص. عنوان «سبک» کافی نیست؛ مسافت و مدت را بخوانید و اگر مبهم بود از میزبان بپرسید.',
|
||||
},
|
||||
{
|
||||
question: 'چه وسایلی معمولاً لازم است؟',
|
||||
answer:
|
||||
'کفش مناسب، آب و پوشش متناسب با هوا حداقلاند. هر میزبان فهرست دقیق را در صفحه رویداد مینویسد؛ برنامه غروب ممکن است نور هم بخواهد.',
|
||||
},
|
||||
{
|
||||
question: 'اگر وسط مسیر خسته شوم چه کنم؟',
|
||||
answer: 'زود به راهنما بگویید. پنهان کردن خستگی خطر را بیشتر میکند. پیش از رزرو بپرسید نقطه بازگشت میانی وجود دارد یا نه.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'food-gatherings': {
|
||||
intro:
|
||||
'غذا، قهوه، شام، پیکنیک و دورهمیهایی که حول میز شکل میگیرند اینجاست. منو، موارد شامل قیمت، محدودیت غذایی و شیوه نشستن غریبهها را پیش از رزرو بخوانید.',
|
||||
highlights: ['شفافیت منو و موارد داخل قیمت', 'ظرفیت میز و ریتم معاشرت', 'امکان اعلام رژیم و حساسیت غذایی'],
|
||||
faqs: [
|
||||
{
|
||||
question: 'غذا در قیمت بلیط هست؟',
|
||||
answer: 'در بعضی برنامهها بله و در بعضی جداگانه. صفحه رویداد باید این را روشن کند؛ اگر نشده پیش از پرداخت از میزبان بپرسید.',
|
||||
},
|
||||
{
|
||||
question: 'با رژیم گیاهی یا حساسیت غذایی میتوانم شرکت کنم؟',
|
||||
answer: 'اغلب میزبانها اگر زود بگویید گزینه جایگزین میچینند. روز اجرا دیر است؛ همان موقع رزرو محدودیت را بنویسید یا بپرسید.',
|
||||
},
|
||||
{
|
||||
question: 'این دورهمیها برای آدم تنها مناسباند؟',
|
||||
answer: 'بله؛ بسیاری از میزها برای آشنایی چیده شدهاند. ظرفیت کمتر و معرفی کوتاه معمولاً ورود را آسانتر میکند.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'conversation-connection': {
|
||||
intro:
|
||||
'آشنایی، شبکهسازی، نشست موضوعی و جمعهای تخصصی در این دسته است. موضوع جلسه، وجود تسهیلگر و ظرفیت را بخوانید تا فضایی انتخاب کنید که حرف زدن در آن محترمانه و ممکن باشد.',
|
||||
highlights: ['نشست موضوعی در کنار دورهمی آشنایی', 'قوانین احترام و ظرفیت گروه', 'ورود آسان برای کسی که بار اول میآید'],
|
||||
faqs: [
|
||||
{
|
||||
question: 'اگر خجالتی باشم این جمعها مناسباند؟',
|
||||
answer:
|
||||
'برنامههایی با معرفی دو نفره یا سؤال مشخص روی میز ورود را آسانتر از میکروفن باز میکنند. لازم نیست اولین نفر حرف بزنید.',
|
||||
},
|
||||
{
|
||||
question: 'تفاوت نشست تخصصی و دورهمی آشنایی چیست؟',
|
||||
answer:
|
||||
'نشست تخصصی معمولاً موضوع و گاهی پیشزمینه میخواهد. دورهمی آشنایی روی شناخت افراد تازهوارد تمرکز دارد. توضیحات میزبان این را مشخص میکند.',
|
||||
},
|
||||
{
|
||||
question: 'آیا میتوانم بعد از جلسه با کسی در ارتباط بمانم؟',
|
||||
answer:
|
||||
'بله؛ یک پیام کوتاه با ارجاع به موضوع همان جلسه معمولاً بهتر از رد و بدل کردن شماره بدون زمینه است. اجبار به تبادل تماس در خود رویداد نیست.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
priorityLandings: {
|
||||
'arts-culture': {
|
||||
tehran: {
|
||||
title: 'رویدادهای هنر و فرهنگ تهران',
|
||||
description: 'اجرا، موسیقی، نمایش و نمایشگاههای حضوری تهران را در قبیله ببینید و برنامه مناسب را رزرو کنید.',
|
||||
intro:
|
||||
'تهران میزبان اجراهای کوچک، نمایشگاه و تجربههای هنری جمعی است. در این صفحه برنامههای هنر و فرهنگ را بر اساس سبک، زمان و محل برگزاری مقایسه کنید.',
|
||||
highlights: ['اجرا و نمایش حضوری در محلههای مختلف تهران', 'جزئیات میزبان، ظرفیت و مدت برنامه', 'رزرو مستقیم از صفحه همان رویداد'],
|
||||
faqs: [
|
||||
{
|
||||
question: 'رویداد هنری تهران معمولاً کجا برگزار میشود؟',
|
||||
answer: 'بسته به میزبان، سالن کوچک، گالری، کافه یا فضای کارگاهی است. نشانی دقیق در صفحه همان رویداد آمده است.',
|
||||
},
|
||||
{
|
||||
question: 'برای شرکت در اجرا باید پیشزمینه هنری داشته باشم؟',
|
||||
answer: 'بیشتر برنامههای تماشایی پیشنیاز ندارند. کارگاه ساخت ممکن است مواد یا سطح اعلام کند؛ توضیحات رویداد را بخوانید.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'learning-experience': {
|
||||
tehran: {
|
||||
title: 'کارگاهها و تجربههای یادگیری تهران',
|
||||
description: 'کلاسها و کارگاههای مهارتی حضوری تهران را مقایسه کنید و بر اساس سطح، خروجی و زمان رزرو کنید.',
|
||||
intro:
|
||||
'اگر دنبال ساختن یا یاد گرفتن یک مهارت تازه در تهران هستید، این فهرست کارگاهها و کلاسهای فعال را کنار هم میگذارد تا سطح، مدت و خروجی را پیش از پرداخت بسنجید.',
|
||||
highlights: ['کارگاه عملی با خروجی مشخص', 'اطلاعات سطح، وسایل و ظرفیت', 'امکان پرسش از میزبان پیش از رزرو'],
|
||||
faqs: [
|
||||
{
|
||||
question: 'کارگاههای تهران برای مبتدی مناسباند؟',
|
||||
answer: 'بسیاری با سطح مقدماتی برگزار میشوند. پیشنیاز را در صفحه رویداد بخوانید و اگر مبهم بود از میزبان بپرسید.',
|
||||
},
|
||||
{
|
||||
question: 'وسایل را باید خودم ببرم؟',
|
||||
answer: 'بستگی به کارگاه دارد. موارد شامل قیمت و وسایل همراه در توضیحات آمده؛ اگر نیامده پیش از خرید سؤال کنید.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'games-entertainment': {
|
||||
tehran: {
|
||||
title: 'رویدادهای بازی و سرگرمی تهران',
|
||||
description: 'شبهای بازی، رقابت و دورهمیهای سرگرمکننده تهران را ببینید و برنامه متناسب با گروه خود رزرو کنید.',
|
||||
intro:
|
||||
'از رومیزی تا دورهمی شاد، برنامههای بازی تهران اینجا جمع میشوند. قوانین، ظرفیت میز و میزان رقابت را با روحیه گروه خودتان بسنجید.',
|
||||
highlights: ['رومیزی و دورهمی در کافه و فضای اختصاصی', 'آموزش در محل برای تازهواردها', 'زمان، ظرفیت و هزینه روی کارت رویداد'],
|
||||
faqs: [
|
||||
{
|
||||
question: 'اگر بازی را بلد نباشم میتوانم در تهران شرکت کنم؟',
|
||||
answer: 'در بیشتر دورهمیها قوانین همان شب گفته میشود. مسابقههای رسمی را از توضیحات رویداد تشخیص دهید.',
|
||||
},
|
||||
{
|
||||
question: 'برای گروه چند نفره چطور رزرو کنیم؟',
|
||||
answer: 'زمان را با همه هماهنگ کنید و هر نفر از صفحه رویداد رزرو کند، مگر میزبان بلیط گروهی جداگانه گذاشته باشد.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'sports-adventure': {
|
||||
tehran: {
|
||||
title: 'ورزش و ماجراجویی گروهی تهران',
|
||||
description: 'پیادهروی، طبیعتگردی و فعالیت بدنی جمعی اطراف تهران را با سطح مسیر و وسایل لازم مقایسه و رزرو کنید.',
|
||||
intro:
|
||||
'برنامههای ورزشی و ماجراجویانه تهران از حرکت داخل شهر تا مسیرهای اطراف را پوشش میدهند. مسافت، مدت و نقطه شروع را پیش از ثبتنام با توان خود بسنجید.',
|
||||
highlights: ['سطح مسیر و مدت حرکت', 'فهرست وسایل و ساعت حرکت', 'شرایط آبوهوا و برنامه جایگزین'],
|
||||
faqs: [
|
||||
{
|
||||
question: 'برای اولین برنامه طبیعت اطراف تهران چه سطحی مناسب است؟',
|
||||
answer: 'مسیر کوتاهتر با توقف مشخص. فقط به کلمه «سبک» بسنده نکنید؛ مسافت و مدت را در صفحه رویداد بخوانید.',
|
||||
},
|
||||
{
|
||||
question: 'نقطه شروع برنامهها کجاست؟',
|
||||
answer: 'هر میزبان نقطه و ساعت حرکت را در رویداد مینویسد. رسیدن بعد از حرکت گروه معمولاً به معنای از دست دادن برنامه است.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'food-gatherings': {
|
||||
tehran: {
|
||||
title: 'خوراک و دورهمیهای تهران',
|
||||
description: 'شام، قهوه، پیکنیک و دورهمیهای حول میز در تهران را ببینید و با منو و ظرفیت مناسب رزرو کنید.',
|
||||
intro:
|
||||
'اگر دنبال نشستن دور یک میز در تهران هستید — از کافه گفتوگو تا شام گروهی — این صفحه برنامهها را از نظر منو، قیمت و شیوه معاشرت کنار هم میگذارد.',
|
||||
highlights: ['منو و موارد داخل قیمت', 'ظرفیت میز و ریتم آشنایی', 'امکان اعلام رژیم غذایی پیش از رزرو'],
|
||||
faqs: [
|
||||
{
|
||||
question: 'غذا در بلیط دورهمیهای تهران هست؟',
|
||||
answer: 'در بعضی برنامهها بله. صفحه رویداد باید غذا، نوشیدنی و سرویس را جدا کند؛ اگر نکرده از میزبان بپرسید.',
|
||||
},
|
||||
{
|
||||
question: 'تنهایی هم میتوانم بیایم؟',
|
||||
answer: 'بله. میزهای آشنایی معمولاً برای نفرات تکی چیده میشوند. ظرفیت کمتر ورود را آسانتر میکند.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'conversation-connection': {
|
||||
tehran: {
|
||||
title: 'گفتوگو و دورهمیهای آشنایی تهران',
|
||||
description: 'نشستهای موضوعی، شبکهسازی و جمعهای تخصصی تهران را مقایسه کنید و به دورهمی مناسب بپیوندید.',
|
||||
intro:
|
||||
'تهران جمعهای گفتوگو با موضوعهای مختلف دارد؛ از آشنایی تا نشست تخصصی. تسهیلگر، ظرفیت و قوانین احترام را بخوانید تا فضایی انتخاب کنید که حرف زدن در آن راحت باشد.',
|
||||
highlights: ['نشست موضوعی و دورهمی آشنایی', 'ظرفیت گروه و وجود تسهیلگر', 'قوانین احترام و ورود تازهوارد'],
|
||||
faqs: [
|
||||
{
|
||||
question: 'اگر در جمع غریبه خجالت میکشم چه برنامهای بهتر است؟',
|
||||
answer: 'جلسههایی با معرفی دو نفره یا سؤال مشخص روی میز معمولاً از میکروفن باز آسانترند. لازم نیست اول شما حرف بزنید.',
|
||||
},
|
||||
{
|
||||
question: 'نشست تخصصی با دورهمی آشنایی چه فرقی دارد؟',
|
||||
answer:
|
||||
'نشست تخصصی موضوع و گاهی پیشزمینه میخواهد. دورهمی آشنایی روی شناخت افراد تازه تمرکز دارد. توضیحات میزبان این را روشن میکند.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
@ -26,7 +26,6 @@ export const status = {
|
||||
published,
|
||||
draft,
|
||||
pending,
|
||||
|
||||
userRole: {
|
||||
user,
|
||||
admin,
|
||||
|
||||
@ -1,36 +1,13 @@
|
||||
import { common } from './common'
|
||||
|
||||
const open = 'باز'
|
||||
const closed = 'بسته'
|
||||
|
||||
export const support = {
|
||||
title: 'پشتیبانی',
|
||||
newRequest: 'درخواست جدید',
|
||||
subjectLabel: 'موضوع',
|
||||
priorityLabel: 'اولویت',
|
||||
loadingList: 'در حال دریافت درخواستها…',
|
||||
emptyTitle: 'هنوز درخواستی ثبت نکردهاید',
|
||||
emptyDescription: 'سؤال یا مشکلتان را برای تیم پشتیبانی بنویسید.',
|
||||
listLoadFailed: 'دریافت تیکتها ناموفق بود',
|
||||
formIncomplete: 'موضوع و توضیحات را کامل کنید',
|
||||
createSuccess: 'درخواست شما ثبت شد',
|
||||
createFailed: 'ثبت درخواست ناموفق بود',
|
||||
detailFallbackTitle: 'جزئیات درخواست',
|
||||
detailLoadFailed: 'دریافت تیکت ناموفق بود',
|
||||
loadingDetail: 'در حال دریافت…',
|
||||
statusLabel: 'وضعیت',
|
||||
adminSender: 'پشتیبانی قبیله',
|
||||
youSender: 'شما',
|
||||
replyPlaceholder: 'پاسخ شما…',
|
||||
sendReply: 'ارسال پاسخ',
|
||||
replyFailed: 'ارسال پاسخ ناموفق بود',
|
||||
closedNotice: 'این درخواست بسته شده است.',
|
||||
categories: {
|
||||
general: common.general,
|
||||
account: common.account,
|
||||
general: 'عمومی',
|
||||
account: 'حساب کاربری',
|
||||
event: 'رویداد',
|
||||
booking: 'رزرو',
|
||||
payment: common.payment,
|
||||
payment: 'پرداخت',
|
||||
technical: 'مشکل فنی',
|
||||
},
|
||||
priorities: {
|
||||
|
||||
@ -18,15 +18,6 @@ export const validation = {
|
||||
otpDigits: 'کد باید ۴ رقم باشد',
|
||||
birthDateRange: 'تاریخ تولد باید بین سالهای ۱۳۳۰ تا ۱۳۹۵ باشد',
|
||||
},
|
||||
profile: {
|
||||
bioMax: 'بیوگرافی حداکثر ۲۰۰۰ کاراکتر است',
|
||||
addressMax: 'آدرس حداکثر ۵۰۰ کاراکتر است',
|
||||
},
|
||||
identity: {
|
||||
contractRequired: 'پذیرش قرارداد همکاری الزامی است',
|
||||
otpRequired: 'کد تأیید را وارد کنید',
|
||||
otpDigits: 'کد تأیید باید ۴ رقم باشد',
|
||||
},
|
||||
withdrawal: {
|
||||
accountHolderRequired: 'نام صاحب حساب را وارد کنید',
|
||||
accountHolderMax: 'نام صاحب حساب حداکثر ۲۰۰ کاراکتر است',
|
||||
@ -38,36 +29,8 @@ export const validation = {
|
||||
amountPositive: 'مبلغ باید بیشتر از صفر باشد',
|
||||
},
|
||||
eventWizard: {
|
||||
titleRequired: 'عنوان رویداد الزامی است',
|
||||
titleMax: 'عنوان حداکثر ۲۰۰ کاراکتر است',
|
||||
slugRequired: 'اسلاگ الزامی است',
|
||||
slugMax: 'اسلاگ حداکثر ۲۲۰ کاراکتر است',
|
||||
slugPattern: 'اسلاگ فقط میتواند شامل حروف کوچک انگلیسی، عدد و خط تیره باشد',
|
||||
slugReserved: 'این اسلاگ برای مسیرهای سیستمی رزرو شده است',
|
||||
categoryRequired: 'دستهبندی را انتخاب کنید',
|
||||
shortDescriptionMax: 'توضیح کوتاه حداکثر ۵۰۰ کاراکتر است',
|
||||
startDateRequired: 'تاریخ شروع الزامی است',
|
||||
startTimeRequired: 'ساعت شروع الزامی است',
|
||||
endDateRequired: 'تاریخ پایان الزامی است',
|
||||
endTimeRequired: 'ساعت پایان الزامی است',
|
||||
addressRequired: 'آدرس الزامی است',
|
||||
startEndRequired: 'تاریخ و ساعت شروع و پایان الزامی است',
|
||||
endAfterStart: 'زمان پایان باید بعد از زمان شروع باشد',
|
||||
generalAreaRequired: 'برای نمایش «فقط برای رزروکنندگان»، منطقه کلی را وارد کنید',
|
||||
priceRequired: 'قیمت را وارد کنید',
|
||||
priceNonNegative: 'قیمت نمیتواند منفی باشد',
|
||||
capacityRequired: 'ظرفیت را وارد کنید',
|
||||
capacityInteger: 'ظرفیت باید عدد صحیح باشد',
|
||||
capacityMin: 'ظرفیت باید حداقل ۱ باشد',
|
||||
reservedCapacityRequired: 'ظرفیت رزرو شده را وارد کنید',
|
||||
reservedCapacityInteger: 'ظرفیت رزرو شده باید عدد صحیح باشد',
|
||||
reservedCapacityNonNegative: 'ظرفیت رزرو شده نمیتواند منفی باشد',
|
||||
cancelPenaltyRequired: 'درصد جریمه را وارد کنید',
|
||||
cancelPenaltyNonNegative: 'درصد جریمه نمیتواند منفی باشد',
|
||||
cancelPenaltyMax: 'درصد جریمه حداکثر ۱۰۰ است',
|
||||
cancelPenaltyOrder: 'درصد جریمه باید با نزدیکشدن به زمان رویداد بیشتر یا مساوی شود',
|
||||
paidPricePositive: 'برای رویداد پولی، قیمت باید بیشتر از صفر باشد',
|
||||
reservedExceedsCapacity: 'ظرفیت رزرو شده نمیتواند از ظرفیت کل بیشتر باشد',
|
||||
faqQuestionRequired: 'سوال را وارد کنید',
|
||||
faqAnswerRequired: 'پاسخ را وارد کنید',
|
||||
},
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
export const wallet = {
|
||||
title: 'کیف پول',
|
||||
balanceLabel: 'موجودی: {amount}',
|
||||
balance: 'موجودی کیف پول',
|
||||
useBalance: 'پرداختی از کیف پول',
|
||||
requestWithdrawal: 'درخواست برداشت',
|
||||
sectionsAria: 'بخشهای کیف پول',
|
||||
tabAccounts: 'حسابها',
|
||||
tabWithdrawals: 'برداشتها',
|
||||
tabTransactions: 'تراکنشها',
|
||||
tabSettlements: 'تسویههای میزبانی',
|
||||
addAccount: 'افزودن حساب',
|
||||
editAccount: 'ویرایش حساب',
|
||||
noBankAccounts: 'هنوز حساب بانکی ثبت نکردهاید',
|
||||
noWithdrawals: 'درخواست برداشتی ثبت نشده است',
|
||||
noTransactions: 'تراکنشی ثبت نشده است',
|
||||
noSettlements: 'هنوز تسویهای برای شما ثبت نشده است',
|
||||
trackingCodeLabel: 'شماره پیگیری:',
|
||||
viewPaymentReceipt: 'مشاهده رسید پرداخت',
|
||||
|
||||
addBankAccountTitle: 'افزودن حساب بانکی',
|
||||
editBankAccountTitle: 'ویرایش حساب بانکی',
|
||||
nationalCodeHint: 'کد ملی برای استعلام شبا لازم است. پس از تأیید احراز هویت میزبان، از روی پرونده هویت خوانده میشود.',
|
||||
withdrawalNeedsBankAccount: 'ابتدا یک حساب بانکی ثبت کنید؛ پس از تأیید حساب میتوانید درخواست برداشت بدهید',
|
||||
withdrawalNeedsApprovedBankAccount: 'حساب بانکی شما هنوز تأیید نشده است؛ پس از تأیید ادمین میتوانید درخواست برداشت بدهید',
|
||||
submitAccount: 'ثبت حساب',
|
||||
saveAccount: 'ذخیره تغییرات',
|
||||
iban: 'شماره شبا',
|
||||
|
||||
submitRequest: 'ثبت درخواست',
|
||||
bankAccount: 'حساب بانکی',
|
||||
bankAccountOption: '{iban}',
|
||||
maxAmount: 'حداکثر: {amount}',
|
||||
amountToman: 'مبلغ (تومان)',
|
||||
|
||||
txRefundGuestCancel: 'بازگشت وجه لغو رزرو',
|
||||
txRefundEventCancel: 'بازگشت وجه لغو رویداد',
|
||||
txWithdrawal: 'برداشت',
|
||||
txDeposit: 'واریز',
|
||||
|
||||
bankAccountCreated: 'حساب بانکی ثبت شد',
|
||||
bankAccountUpdated: 'حساب بانکی بهروز شد و دوباره در صف بررسی قرار گرفت',
|
||||
bankAccountCreateFailed: 'ثبت حساب بانکی ناموفق بود',
|
||||
addBankAccountFirst: 'ابتدا یک حساب بانکی اضافه کنید',
|
||||
withdrawalCreated: 'درخواست برداشت ثبت شد',
|
||||
withdrawalCreateFailed: 'ثبت درخواست برداشت ناموفق بود',
|
||||
|
||||
fetchWalletFailed: 'دریافت کیف پول ناموفق بود',
|
||||
fetchTransactionsFailed: 'دریافت تراکنشهای کیف پول ناموفق بود',
|
||||
paymentFailed: 'ثبت پرداخت ناموفق بود',
|
||||
|
||||
topUp: 'شارژ کیف پول',
|
||||
topUpSubmit: 'پرداخت و شارژ',
|
||||
topUpAmount: 'مبلغ شارژ (تومان)',
|
||||
topUpMinHint: 'حداقل {amount}',
|
||||
topUpCreated: 'درگاه پرداخت باز شد؛ پس از پرداخت موجودی بهروز میشود.',
|
||||
topUpFailed: 'شروع شارژ کیف پول ناموفق بود',
|
||||
topUpAwaiting: 'در انتظار تأیید پرداخت…',
|
||||
topUpSuccess: 'کیف پول با موفقیت شارژ شد',
|
||||
topUpSimulatedNote: 'درگاه پرداخت هنوز به سامانه متصل نشده است؛ این شارژ بهصورت آزمایشی انجام شد.',
|
||||
|
||||
paymentReturnTitle: 'نتیجه پرداخت',
|
||||
paymentReturnSuccess: 'پرداخت با موفقیت انجام شد.',
|
||||
paymentReturnFailed: 'پرداخت ناموفق بود یا لغو شد.',
|
||||
paymentReturnPending: 'در حال بررسی وضعیت پرداخت…',
|
||||
paymentReturnPendingHint: 'اگر چند لحظه پیش در درگاه پرداخت کردید، همین صفحه بهزودی بهروز میشود.',
|
||||
paymentReturnInvalid: 'لینک بازگشت از درگاه نامعتبر است.',
|
||||
paymentReturnGoWallet: 'رفتن به کیف پول',
|
||||
paymentReturnGoBooking: 'بازگشت به رزرو',
|
||||
paymentReturnGoHome: 'صفحه اصلی',
|
||||
} as const
|
||||
@ -1,89 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { texts } from '@/texts'
|
||||
|
||||
const v = texts.validation.eventWizard
|
||||
|
||||
function toDateTime(dateIso: string, minutes: number): Date | null {
|
||||
const date = new Date(dateIso)
|
||||
|
||||
if (!Number.isFinite(date.getTime()) || !Number.isFinite(minutes)) return null
|
||||
|
||||
date.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0)
|
||||
|
||||
return date
|
||||
}
|
||||
|
||||
export interface ConsumerEventWizardStep2Values {
|
||||
startDate: string
|
||||
startTime: number
|
||||
endDate: string
|
||||
endTime: number
|
||||
address: string
|
||||
addressVisibility: 'public' | 'attendees_only'
|
||||
generalArea?: string
|
||||
}
|
||||
|
||||
export type ConsumerEventWizardStep2FieldErrors = Partial<Record<keyof ConsumerEventWizardStep2Values, string>>
|
||||
|
||||
export const ConsumerEventWizardStep2Validation = z
|
||||
.object({
|
||||
startDate: z.string().min(1, v.startDateRequired),
|
||||
startTime: z.number({ error: v.startTimeRequired }),
|
||||
endDate: z.string().min(1, v.endDateRequired),
|
||||
endTime: z.number({ error: v.endTimeRequired }),
|
||||
address: z.string().trim().min(1, v.addressRequired),
|
||||
addressVisibility: z.enum(['public', 'attendees_only']),
|
||||
generalArea: z.string().trim().optional().or(z.literal('')),
|
||||
})
|
||||
.superRefine((values, ctx) => {
|
||||
const startsAt = toDateTime(values.startDate, values.startTime)
|
||||
const endsAt = toDateTime(values.endDate, values.endTime)
|
||||
|
||||
if (!startsAt || !endsAt) {
|
||||
ctx.addIssue({ code: 'custom', message: v.startEndRequired, path: ['startDate'] })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (endsAt <= startsAt) {
|
||||
ctx.addIssue({ code: 'custom', message: v.endAfterStart, path: ['endDate'] })
|
||||
}
|
||||
|
||||
if (values.addressVisibility === 'attendees_only' && !values.generalArea?.trim()) {
|
||||
ctx.addIssue({ code: 'custom', message: v.generalAreaRequired, path: ['generalArea'] })
|
||||
}
|
||||
})
|
||||
|
||||
export function parseConsumerEventWizardStep2(
|
||||
payload: ConsumerEventWizardStep2Values
|
||||
): { success: true; data: ConsumerEventWizardStep2Values } | { success: false; fieldErrors: ConsumerEventWizardStep2FieldErrors } {
|
||||
const result = ConsumerEventWizardStep2Validation.safeParse(payload)
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
startDate: result.data.startDate,
|
||||
startTime: result.data.startTime,
|
||||
endDate: result.data.endDate,
|
||||
endTime: result.data.endTime,
|
||||
address: result.data.address,
|
||||
addressVisibility: result.data.addressVisibility,
|
||||
generalArea: result.data.generalArea,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const fieldErrors: ConsumerEventWizardStep2FieldErrors = {}
|
||||
|
||||
for (const issue of result.error.issues) {
|
||||
const key = issue.path[0]
|
||||
|
||||
if (typeof key === 'string' && !(key in fieldErrors)) {
|
||||
fieldErrors[key as keyof ConsumerEventWizardStep2Values] = issue.message
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, fieldErrors }
|
||||
}
|
||||
@ -1,227 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ConsumerEventWizardStep2Validation,
|
||||
EventWizardFaqFormValidation,
|
||||
EventWizardStep1Validation,
|
||||
EventWizardStep2Validation,
|
||||
EventWizardStep3Validation,
|
||||
} from '@/validation/eventWizard'
|
||||
import { EventWizardFaqFormValidation } from '@/validation/eventWizard'
|
||||
|
||||
describe('event wizard validation', () => {
|
||||
it('requires title, slug, and category on step 1', () => {
|
||||
expect(EventWizardStep1Validation.safeParse({ title: '', slug: '', categoryId: '' }).success).toBe(false)
|
||||
expect(
|
||||
EventWizardStep1Validation.parse({
|
||||
title: 'رویداد تست',
|
||||
slug: 'test-event',
|
||||
categoryId: '1',
|
||||
shortDescription: '',
|
||||
description: '',
|
||||
})
|
||||
).toMatchObject({ title: 'رویداد تست', slug: 'test-event', categoryId: '1' })
|
||||
})
|
||||
|
||||
it('rejects invalid slug patterns', () => {
|
||||
expect(
|
||||
EventWizardStep1Validation.safeParse({
|
||||
title: 'عنوان',
|
||||
slug: 'Bad_Slug',
|
||||
categoryId: '1',
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['new', '462f10ff-7466-4a58-9631-d09f02b76eaf'])('rejects the reserved event slug %s', (slug) => {
|
||||
expect(
|
||||
EventWizardStep1Validation.safeParse({
|
||||
title: 'عنوان',
|
||||
slug,
|
||||
categoryId: '1',
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('requires schedule and location on step 2 and enforces end after start', () => {
|
||||
const base = {
|
||||
startDate: '2026-07-19T00:00:00.000Z',
|
||||
startTime: 600,
|
||||
endDate: '2026-07-19T00:00:00.000Z',
|
||||
endTime: 720,
|
||||
cityId: '10',
|
||||
address: 'تهران',
|
||||
addressVisibility: 'public' as const,
|
||||
}
|
||||
|
||||
expect(EventWizardStep2Validation.parse(base)).toMatchObject({ cityId: '10', address: 'تهران' })
|
||||
expect(EventWizardStep2Validation.safeParse({ ...base, endTime: 600 }).success).toBe(false)
|
||||
expect(EventWizardStep2Validation.safeParse({ ...base, cityId: '' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('validates consumer step 2 without city selection (city comes from host profile)', () => {
|
||||
const base = {
|
||||
startDate: '2026-07-19T00:00:00.000Z',
|
||||
startTime: 600,
|
||||
endDate: '2026-07-19T00:00:00.000Z',
|
||||
endTime: 720,
|
||||
address: 'تهران',
|
||||
addressVisibility: 'public' as const,
|
||||
}
|
||||
|
||||
expect(ConsumerEventWizardStep2Validation.parse(base)).toMatchObject({ address: 'تهران' })
|
||||
expect(ConsumerEventWizardStep2Validation.safeParse({ ...base, endTime: 600 }).success).toBe(false)
|
||||
expect(ConsumerEventWizardStep2Validation.safeParse({ ...base, address: '' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('requires generalArea when addressVisibility is attendees_only', () => {
|
||||
const base = {
|
||||
startDate: '2026-07-19T00:00:00.000Z',
|
||||
startTime: 600,
|
||||
endDate: '2026-07-19T00:00:00.000Z',
|
||||
endTime: 720,
|
||||
cityId: '10',
|
||||
address: 'تهران',
|
||||
}
|
||||
|
||||
expect(EventWizardStep2Validation.safeParse({ ...base, addressVisibility: 'attendees_only', generalArea: '' }).success).toBe(false)
|
||||
expect(
|
||||
EventWizardStep2Validation.safeParse({
|
||||
...base,
|
||||
addressVisibility: 'attendees_only',
|
||||
generalArea: 'مشهد، خیابان هفتتیر',
|
||||
}).success
|
||||
).toBe(true)
|
||||
|
||||
const consumerBase = {
|
||||
startDate: '2026-07-19T00:00:00.000Z',
|
||||
startTime: 600,
|
||||
endDate: '2026-07-19T00:00:00.000Z',
|
||||
endTime: 720,
|
||||
address: 'تهران',
|
||||
}
|
||||
|
||||
expect(
|
||||
ConsumerEventWizardStep2Validation.safeParse({
|
||||
...consumerBase,
|
||||
addressVisibility: 'attendees_only',
|
||||
generalArea: '',
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('requires positive price for paid events and capacity of at least 1', () => {
|
||||
expect(
|
||||
EventWizardStep3Validation.parse({
|
||||
isFree: true,
|
||||
price: 0,
|
||||
capacity: 20,
|
||||
reservedCapacity: 0,
|
||||
genderRestriction: 'open',
|
||||
ageRestriction: 'open',
|
||||
cancellationFeePercent: 0,
|
||||
cancellationFeePercent12To24Hours: 0,
|
||||
cancellationFeePercentMoreThan24Hours: 0,
|
||||
isDiscoverable: true,
|
||||
autoCreateGroup: true,
|
||||
waitlistAutoOffer: true,
|
||||
sendReviewRequestSms: false,
|
||||
})
|
||||
).toMatchObject({ isFree: true, price: 0, capacity: 20 })
|
||||
|
||||
expect(
|
||||
EventWizardStep3Validation.safeParse({
|
||||
isFree: false,
|
||||
price: 0,
|
||||
capacity: 20,
|
||||
reservedCapacity: 0,
|
||||
genderRestriction: 'open',
|
||||
ageRestriction: 'open',
|
||||
cancellationFeePercent: 0,
|
||||
cancellationFeePercent12To24Hours: 0,
|
||||
cancellationFeePercentMoreThan24Hours: 0,
|
||||
isDiscoverable: true,
|
||||
autoCreateGroup: true,
|
||||
waitlistAutoOffer: true,
|
||||
sendReviewRequestSms: false,
|
||||
}).success
|
||||
).toBe(false)
|
||||
|
||||
expect(
|
||||
EventWizardStep3Validation.safeParse({
|
||||
isFree: false,
|
||||
price: 100000,
|
||||
capacity: 0,
|
||||
reservedCapacity: 0,
|
||||
genderRestriction: 'open',
|
||||
ageRestriction: 'open',
|
||||
cancellationFeePercent: 0,
|
||||
cancellationFeePercent12To24Hours: 0,
|
||||
cancellationFeePercentMoreThan24Hours: 0,
|
||||
isDiscoverable: true,
|
||||
autoCreateGroup: true,
|
||||
waitlistAutoOffer: true,
|
||||
sendReviewRequestSms: false,
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unknown gender and age restriction values', () => {
|
||||
const base = {
|
||||
isFree: true,
|
||||
price: 0,
|
||||
capacity: 20,
|
||||
reservedCapacity: 0,
|
||||
genderRestriction: 'open',
|
||||
ageRestriction: 'open',
|
||||
cancellationFeePercent: 0,
|
||||
cancellationFeePercent12To24Hours: 0,
|
||||
cancellationFeePercentMoreThan24Hours: 0,
|
||||
isDiscoverable: true,
|
||||
autoCreateGroup: true,
|
||||
waitlistAutoOffer: true,
|
||||
sendReviewRequestSms: false,
|
||||
}
|
||||
|
||||
expect(EventWizardStep3Validation.safeParse({ ...base, genderRestriction: 'women' }).success).toBe(false)
|
||||
expect(EventWizardStep3Validation.safeParse({ ...base, ageRestriction: 'age_15_20' }).success).toBe(false)
|
||||
expect(EventWizardStep3Validation.safeParse({ ...base, ageRestriction: 'age_15_19' }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('requires cancellation penalties to decrease as cancellation gets earlier', () => {
|
||||
const base = {
|
||||
isFree: false,
|
||||
price: 100_000,
|
||||
capacity: 20,
|
||||
reservedCapacity: 0,
|
||||
genderRestriction: 'open',
|
||||
ageRestriction: 'open',
|
||||
cancellationFeePercent: 30,
|
||||
cancellationFeePercent12To24Hours: 20,
|
||||
cancellationFeePercentMoreThan24Hours: 10,
|
||||
isDiscoverable: true,
|
||||
autoCreateGroup: true,
|
||||
waitlistAutoOffer: true,
|
||||
sendReviewRequestSms: false,
|
||||
}
|
||||
|
||||
expect(EventWizardStep3Validation.safeParse(base).success).toBe(true)
|
||||
expect(
|
||||
EventWizardStep3Validation.safeParse({
|
||||
...base,
|
||||
cancellationFeePercentMoreThan24Hours: 25,
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
EventWizardStep3Validation.safeParse({
|
||||
...base,
|
||||
cancellationFeePercent: 0,
|
||||
cancellationFeePercent12To24Hours: 0,
|
||||
cancellationFeePercentMoreThan24Hours: 0,
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('requires both question and answer for FAQ rows', () => {
|
||||
describe('EventWizardFaqFormValidation', () => {
|
||||
it('requires question and answer', () => {
|
||||
expect(EventWizardFaqFormValidation.safeParse({ question: '', answer: 'پاسخ' }).success).toBe(false)
|
||||
expect(EventWizardFaqFormValidation.parse({ question: 'سوال؟', answer: 'پاسخ' })).toEqual({
|
||||
question: 'سوال؟',
|
||||
|
||||
@ -1,155 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { EVENT_SLUG_MAX_LENGTH } from '@/features/events/eventSlug'
|
||||
import { isPublicEventSlug } from '@/lib/routing/eventRoute'
|
||||
import { texts } from '@/texts'
|
||||
|
||||
// همتراز با اسلاگ دستهبندیها — فقط لاتین، بدون تبدیل ارقام فارسی
|
||||
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
const v = texts.validation.eventWizard
|
||||
|
||||
/** ترکیب تاریخ ISO و دقیقهٔ روز برای مقایسهٔ شروع/پایان (بدون وابستگی به components) */
|
||||
function toDateTime(dateIso: string, minutes: number): Date | null {
|
||||
const date = new Date(dateIso)
|
||||
|
||||
if (!Number.isFinite(date.getTime()) || !Number.isFinite(minutes)) return null
|
||||
|
||||
date.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0)
|
||||
|
||||
return date
|
||||
}
|
||||
|
||||
export const EventWizardStep1Validation = z.object({
|
||||
title: z.string().trim().min(1, v.titleRequired).max(200, v.titleMax),
|
||||
slug: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, v.slugRequired)
|
||||
.max(EVENT_SLUG_MAX_LENGTH, v.slugMax)
|
||||
.regex(SLUG_PATTERN, v.slugPattern)
|
||||
.refine(isPublicEventSlug, v.slugReserved),
|
||||
categoryId: z.string().min(1, v.categoryRequired),
|
||||
shortDescription: z.string().trim().max(500, v.shortDescriptionMax).optional().or(z.literal('')),
|
||||
description: z.string().optional().or(z.literal('')),
|
||||
})
|
||||
|
||||
export type EventWizardStep1Values = z.infer<typeof EventWizardStep1Validation>
|
||||
|
||||
const eventWizardStep2ScheduleRefine = (
|
||||
values: {
|
||||
startDate: string
|
||||
startTime: number
|
||||
endDate: string
|
||||
endTime: number
|
||||
addressVisibility: 'public' | 'attendees_only'
|
||||
generalArea?: string
|
||||
},
|
||||
ctx: z.RefinementCtx
|
||||
) => {
|
||||
const startsAt = toDateTime(values.startDate, values.startTime)
|
||||
const endsAt = toDateTime(values.endDate, values.endTime)
|
||||
|
||||
if (!startsAt || !endsAt) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: v.startEndRequired,
|
||||
path: ['startDate'],
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (endsAt <= startsAt) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: v.endAfterStart,
|
||||
path: ['endDate'],
|
||||
})
|
||||
}
|
||||
|
||||
if (values.addressVisibility === 'attendees_only' && !values.generalArea?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: v.generalAreaRequired,
|
||||
path: ['generalArea'],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const eventWizardStep2Fields = z.object({
|
||||
startDate: z.string().min(1, v.startDateRequired),
|
||||
startTime: z.number({ error: v.startTimeRequired }),
|
||||
endDate: z.string().min(1, v.endDateRequired),
|
||||
endTime: z.number({ error: v.endTimeRequired }),
|
||||
provinceId: z.string().optional().or(z.literal('')),
|
||||
cityId: z.string().min(1, texts.validation.cityRequired),
|
||||
address: z.string().trim().min(1, v.addressRequired),
|
||||
addressVisibility: z.enum(['public', 'attendees_only']),
|
||||
generalArea: z.string().trim().optional().or(z.literal('')),
|
||||
})
|
||||
|
||||
export const EventWizardStep2Validation = eventWizardStep2Fields.superRefine(eventWizardStep2ScheduleRefine)
|
||||
|
||||
export type EventWizardStep2Values = z.infer<typeof EventWizardStep2Validation>
|
||||
|
||||
export {
|
||||
ConsumerEventWizardStep2Validation,
|
||||
parseConsumerEventWizardStep2,
|
||||
type ConsumerEventWizardStep2FieldErrors,
|
||||
type ConsumerEventWizardStep2Values,
|
||||
} from '@/validation/consumerEventWizardStep2'
|
||||
|
||||
export const EventWizardStep3Validation = z
|
||||
.object({
|
||||
isFree: z.boolean(),
|
||||
price: z.number({ error: v.priceRequired }).min(0, v.priceNonNegative),
|
||||
capacity: z.number({ error: v.capacityRequired }).int(v.capacityInteger).min(1, v.capacityMin),
|
||||
reservedCapacity: z.number({ error: v.reservedCapacityRequired }).int(v.reservedCapacityInteger).min(0, v.reservedCapacityNonNegative),
|
||||
genderRestriction: z.enum(['open', 'female_only', 'male_only']),
|
||||
ageRestriction: z.enum(['open', 'age_15_19', 'age_20_24', 'age_25_plus']),
|
||||
cancellationFeePercent: z.number({ error: v.cancelPenaltyRequired }).min(0, v.cancelPenaltyNonNegative).max(100, v.cancelPenaltyMax),
|
||||
cancellationFeePercent12To24Hours: z
|
||||
.number({ error: v.cancelPenaltyRequired })
|
||||
.min(0, v.cancelPenaltyNonNegative)
|
||||
.max(100, v.cancelPenaltyMax),
|
||||
cancellationFeePercentMoreThan24Hours: z
|
||||
.number({ error: v.cancelPenaltyRequired })
|
||||
.min(0, v.cancelPenaltyNonNegative)
|
||||
.max(100, v.cancelPenaltyMax),
|
||||
isDiscoverable: z.boolean(),
|
||||
autoCreateGroup: z.boolean(),
|
||||
waitlistAutoOffer: z.boolean(),
|
||||
sendReviewRequestSms: z.boolean(),
|
||||
})
|
||||
.superRefine((values, ctx) => {
|
||||
if (!values.isFree && values.price <= 0) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: v.paidPricePositive,
|
||||
path: ['price'],
|
||||
})
|
||||
}
|
||||
if (values.reservedCapacity > values.capacity) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: v.reservedExceedsCapacity,
|
||||
path: ['reservedCapacity'],
|
||||
})
|
||||
}
|
||||
if (
|
||||
values.cancellationFeePercent < values.cancellationFeePercent12To24Hours ||
|
||||
values.cancellationFeePercent12To24Hours < values.cancellationFeePercentMoreThan24Hours
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: v.cancelPenaltyOrder,
|
||||
path: ['cancellationFeePercent12To24Hours'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type EventWizardStep3Values = z.infer<typeof EventWizardStep3Validation>
|
||||
|
||||
export const EventWizardFaqFormValidation = z.object({
|
||||
question: z.string().trim().min(1, v.faqQuestionRequired),
|
||||
answer: z.string().trim().min(1, v.faqAnswerRequired),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user