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:
alisaza 2026-09-07 18:09:55 +03:30
parent 99685e058d
commit 82593dc18c
75 changed files with 144 additions and 5954 deletions

View File

@ -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)
})
}, [])

View File

@ -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`}

View File

@ -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>

View File

@ -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
}

View File

@ -1,5 +0,0 @@
import AdminEventCreate from '@/features/events/create/AdminEventCreate'
const AdminEventCreatePage = () => <AdminEventCreate />
export default AdminEventCreatePage

View File

@ -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}

View File

@ -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

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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: 'شنبه' }])
})
})

View File

@ -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,
})),
}
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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 []
}
}

View File

@ -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')}`
}

View File

@ -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')
})

View File

@ -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`,

View File

@ -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 }}

View File

@ -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())
})

View File

@ -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

View File

@ -438,11 +438,7 @@ const AdminEventDetail = () => {
key="guest-lists"
title="مهمانان ویژه"
>
<EventGuestListAccessPanel
accessMode="admin"
eventId={eventId}
surface="admin"
/>
<EventGuestListAccessPanel eventId={eventId} />
</Tab>
</AppTabs>

View File

@ -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'}

View File

@ -19,10 +19,7 @@ const AdminEventReviewsTab = ({ eventId }: AdminEventReviewsTabProps) => (
مشاهده در لیست کلی
</Button>
</div>
<EventReviewsPanel
eventId={eventId}
mode="admin"
/>
<EventReviewsPanel eventId={eventId} />
</div>
)

View File

@ -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()
})
})

View File

@ -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

View File

@ -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>
)
}

View File

@ -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

View File

@ -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

View 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,
})

View File

@ -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,
})

View File

@ -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,
})

View File

@ -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,
}

View File

@ -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

View File

@ -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)
})
})

View File

@ -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

View File

@ -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}`,

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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,

View File

@ -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)

View File

@ -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 : []
}

View File

@ -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)

View File

@ -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 })
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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}`)

View File

@ -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({

View File

@ -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)
}
}

View File

@ -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: 'پاسخ ورود نامعتبر است',

View File

@ -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: 'ایجاد مقاله ناموفق بود',

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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: 'ویرایش دسته‌بندی ناموفق بود',

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -26,7 +26,6 @@ export const status = {
published,
draft,
pending,
userRole: {
user,
admin,

View File

@ -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: {

View File

@ -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: 'پاسخ را وارد کنید',
},

View File

@ -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

View File

@ -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 }
}

View File

@ -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: 'سوال؟',

View File

@ -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),